How to check content disposition header

Read the response headers with curl -s -D - -o /dev/null <url> and find content-disposition. attachment makes the browser download, inline asks it to render. The saved name comes from filename* when that parameter is present and from filename otherwise. Chrome 152 and curl 8.21.0 disagree on that rule, so check the client your users have.

Why check this

Run this on any route that returns a file: an export button, a report download, an invoice PDF, an attachment served back from an upload. Run it at staging sign-off, and again after anyone touches the download route or the proxy in front of it.

One concrete failure it prevents. A user with a non-ASCII name in their document uploads it, downloads it back, and gets eight unrelated characters where звіт should be. Nothing errors, and the ticket arrives weeks later as "the export is broken". The header is the whole cause, and one header read finds it.

The check settles two things: whether the response downloads or renders, and which name the client writes to disk. It does not tell you the file is correct inside, which is How to check content-type of API response.

Prerequisites

import { createServer } from 'node:http';
const body = 'id,amount\n1,42\n';
const star = "UTF-8''%D0%B7%D0%B2%D1%96%D1%82.csv";             // zvit.csv, Ukrainian
const raw = Buffer.from('звіт.csv', 'utf8').toString('latin1'); // the same name, unencoded
const cd = {
  '/none': null,
  '/inline': 'inline',
  '/attachment': 'attachment',
  '/named': 'attachment; filename="quarterly report.csv"',
  '/unquoted': 'attachment; filename=quarterly report.csv',
  '/star': `attachment; filename*=${star}`,
  '/both': `attachment; filename="zvit.csv"; filename*=${star}`,
  '/raw': `attachment; filename="${raw}"`,
};
createServer((req, res) => {
  const u = new URL(req.url, 'http://127.0.0.1:8935');
  const type = u.searchParams.get('type') || 'text/csv';
  if (u.pathname === '/rawsock') {              // raw bytes, bypassing Node's header check
    res.socket.end(Buffer.from(`HTTP/1.1 200 OK\r\ncontent-type: ${type}\r\n`
      + `content-length: ${body.length}\r\ncontent-disposition: attachment; `
      + `filename="${raw}"\r\nconnection: close\r\n\r\n${body}`, 'latin1'));
    return;
  }
  if (!(u.pathname in cd)) { res.writeHead(404).end(); return; }
  const h = { 'content-type': type, 'content-length': Buffer.byteLength(body) };
  if (cd[u.pathname]) h['content-disposition'] = cd[u.pathname];
  try { res.writeHead(200, h); res.end(body); }
  catch (e) { res.writeHead(500, { 'content-length': e.code.length }).end(e.code); }
}).listen(8935, '127.0.0.1', () => console.log('listening on 8935'));
import { launch } from 'puppeteer-core';
const base = 'http://127.0.0.1:8935';
const mode = process.argv[2];
const paths = mode === 'names'
  ? ['/named', '/unquoted', '/star', '/both', '/rawsock', '/attachment']
  : ['/inline?type=text/plain', '/attachment?type=text/plain', '/inline', '/inline?type=application/octet-stream'];
const browser = await launch({
  executablePath: 'C:/Program Files/Google/Chrome/Application/chrome.exe',
  headless: true, args: ['--remote-debugging-port=0', '--no-first-run'],
});
const page = (await browser.pages())[0];
const cdp = await page.createCDPSession();
await cdp.send('Browser.setDownloadBehavior', { behavior: 'deny', eventsEnabled: true });
const saved = new Map();
cdp.on('Browser.downloadWillBegin', e => saved.set(e.url, e.suggestedFilename));
console.log(await browser.version());
for (const p of paths) {
  let shown = null;
  try { await page.goto(base + p, { timeout: 5000 }); shown = await page.evaluate(() => document.body.innerText.trim()); }
  catch (e) { shown = e.message.split('\n')[0]; }
  await new Promise(r => setTimeout(r, 400));
  const hit = saved.get(base + p);
  console.log(p.padEnd(34), mode === 'names' ? JSON.stringify(hit ?? null)
    : hit ? `downloaded as ${JSON.stringify(hit)}` : `rendered ${JSON.stringify(shown)}`);
}
await browser.close();

Steps

  1. Step 1.

    Read the header off a live host over HTTPS before you touch anything local.

    curl -s -D - -o /dev/null 'https://httpbin.org/response-headers?Content-Disposition=attachment%3B%20filename%3D%22report.csv%22' | grep -i -E '^HTTP|content-disposition|content-type'
    
    HTTP/2 200 
    content-type: application/json
    content-disposition: attachment; filename="report.csv"

    The disposition promises a .csv while the type says JSON, the kind of mismatch this command finds.

  2. Step 2.

    Start cd-server.mjs, then read all nine spellings in one pass.

    for p in none inline attachment named unquoted star both raw rawsock; do echo "--- /$p"; curl -s -D - -o /dev/null "http://127.0.0.1:8935/$p" | grep -i 'content-disposition' | tr -d '\r'; done
    
    --- /none
    --- /inline
    content-disposition: inline
    --- /attachment
    content-disposition: attachment
    --- /named
    content-disposition: attachment; filename="quarterly report.csv"
    --- /unquoted
    content-disposition: attachment; filename=quarterly report.csv
    --- /star
    content-disposition: attachment; filename*=UTF-8''%D0%B7%D0%B2%D1%96%D1%82.csv
    --- /both
    content-disposition: attachment; filename="zvit.csv"; filename*=UTF-8''%D0%B7%D0%B2%D1%96%D1%82.csv
    --- /raw
    --- /rawsock
    content-disposition: attachment; filename="звіт.csv"

    /raw prints nothing because Node refused to send it. The third pitfall below has the error.

  3. Step 3.

    Decode the filename* value into the name a user will see.

    node -e "const v=\"UTF-8''%D0%B7%D0%B2%D1%96%D1%82.csv\"; const [cs,,pct]=v.split(\"'\"); console.log(cs, decodeURIComponent(pct))"
    
    UTF-8 звіт.csv

    The value carries its own charset before the two apostrophes, so nothing has to be guessed.

  4. Step 4.

    Let curl name the files with -J, then list what landed with non-ASCII characters as code points.

    mkdir -p saved && cd saved && for p in named star both rawsock; do curl -sOJ "http://127.0.0.1:8935/$p"; done && node -e 'require("fs").readdirSync(".").forEach(f=>console.log(f.replace(/[^\x20-\x7e]/g,c=>"<U+"+c.charCodeAt(0).toString(16).toUpperCase().padStart(4,"0")+">")))'
    
    quarterly report.csv
    star
    zvit.csv
    <U+0420><U+00B7><U+0420><U+0406><U+0421><U+2013><U+0421><U+201A>.csv

    Four requests, three naming rules. /star was saved as star, the last segment of the URL, because curl read no usable filename. The fourth name is the UTF-8 bytes of звіт read as Windows-1251, printed as code points.

  5. Step 5.

    Ask Chrome which name it would write for the same six URLs.

    node cd-browser.mjs names
    
    Chrome/152.0.7977.76
    /named                             "quarterly report.csv"
    /unquoted                          "quarterly report.csv"
    /star                              "звіт.csv"
    /both                              "звіт.csv"
    /rawsock                           "звіт.csv"
    /attachment                        "attachment.csv"

    On /both Chrome took filename* and dropped the ASCII filename, as RFC 6266 section 4.3 asks. On /attachment it invented attachment.csv from the URL and the content type.

  6. Step 6.

    Compare inline and attachment across three content types in the same browser.

    node cd-browser.mjs render
    
    Chrome/152.0.7977.76
    /inline?type=text/plain            rendered "id,amount\n1,42"
    /attachment?type=text/plain        downloaded as "attachment.txt"
    /inline                            downloaded as "inline.csv"
    /inline?type=application/octet-stream downloaded as "inline"

    Same header, same bytes, three outcomes. inline renders only when the browser has a viewer for the content type.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | No content-disposition at all | The content type alone decides render against download | Fine for an image. Wrong for an export the user must keep. | | attachment with no filename | The client makes a name up from the URL and the type | Add a filename. Chrome produced attachment.csv here, which tells the user nothing. | | attachment; filename="name.csv" | ASCII name, understood by every client tested | Nothing, as long as the name has no non-ASCII character. | | filename*=UTF-8''%D0%B7... only | Chrome saves the decoded name, curl saves the URL segment | Add an ASCII filename next to it as the fallback. | | filename and filename* together | Modern clients take filename*, older ones take filename | This is the shape to ship. Check both names are the same file. | | Non-ASCII bytes inside filename | The name depends on how each client guesses the charset | Percent-encode it into filename* instead. | | inline on a type the browser cannot display | It downloads anyway | Do not rely on inline to keep the user on the page. |

Common mistakes

Sign: One tester reports the download as звіт.csv and another gets eight unrelated characters, from the same build.Cause: The name went into filename as raw UTF-8 bytes, which HTTP field values are not defined to carry. Chrome 152 guessed UTF-8 and was right. curl 8.21.0 wrote the bytes as it found them and the file system read them as Windows-1251. Neither client is wrong, because the header never said which charset it meant.
Sign: curl saves the file under the last segment of the URL although the header carried a name.Cause: curl 8.21.0 reads filename and ignores filename*, so a header with only the encoded form leaves it nothing to use. The /star request in step 4 landed as a file called star. A page verified only with curl -OJ will miss a broken filename* entirely.
Sign: The server returns 500 on the download route as soon as a customer name contains an accent.Cause: Node 22 will not put a byte above 0x7F in a header value. res.writeHead throws TypeError [ERR_INVALID_CHAR]: Invalid character in header content ["content-disposition"], and the 500 has no other clue in it. Encode the name into filename* before it reaches writeHead.
Sign: A filename with a space works in every client you tried, so the quotes look optional.Cause: filename=quarterly report.csv is outside the header grammar: an unquoted value is a token and a token has no spaces. curl 8.21.0 and Chrome 152 both took the whole string anyway, which is why the defect survives testing until it meets a parser that stops at the space.

What to check next

FAQ

Content disposition inline vs attachment: what is the difference?

attachment tells the client to save the response instead of processing it. inline asks for default processing. Step 6 shows the limit: with inline and text/csv, Chrome 152 downloaded the file as inline.csv because it has no CSV viewer.

Where does the content disposition filename come from?

From the filename parameter, or from filename* when that is present. With neither, the client builds one. Chrome 152 used the last URL segment and an extension guessed from the content type, giving attachment.csv for /attachment.

How do I put a non-ASCII name in content disposition attachment filename?

Percent-encode the UTF-8 bytes and send them as filename*=UTF-8''%D0%B7%D0%B2.... Keep a plain ASCII filename next to it for clients that ignore the encoded form. Step 5 shows Chrome taking the encoded one when both are present.

Can I read this header in DevTools instead of curl?

Yes. Open DevTools, Network tab, click the request, then the Headers panel and read content-disposition under Response Headers. DevTools shows the decoded name, so the percent-encoded bytes that curl prints are not visible there.

Does the check need a real download?

No. The browser steps set Browser.setDownloadBehavior to deny, so Chrome reports the name it would have used and writes nothing to disk.

Verified

Verified by Maks Vernycurl 8.21.0node 22.23.2Chrome 152.0.7977.76puppeteer-core 25.10.0

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate9 minpublished updated Maks Verny