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
- curl 8 or later. See the curl manual for
-J. - Node 22 for the local server. It serves the same two-line CSV on
127.0.0.1:8935under nine header spellings. Save it ascd-server.mjs, run it, and stop it when you finish.
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'));
puppeteer-coreand the Chrome on your machine, for steps 5 and 6. Save this ascd-browser.mjs. It denies every download and reads the name Chrome would have used from theBrowser.downloadWillBeginevent.
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();
- The browser figures below are one capture on one machine, Chrome 152.0.7977.76. Another browser can pick a different name.
- RFC 6266 defines the header for HTTP, RFC 5987 the
filename*encoding, updated by RFC 8187.
Steps
- 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
.csvwhile the type says JSON, the kind of mismatch this command finds. - 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"/rawprints nothing because Node refused to send it. The third pitfall below has the error. - 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 звіт.csvThe value carries its own charset before the two apostrophes, so nothing has to be guessed.
- 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>.csvFour requests, three naming rules.
/starwas saved asstar, the last segment of the URL, because curl read no usablefilename. The fourth name is the UTF-8 bytes ofзвітread as Windows-1251, printed as code points. - Step 5.
Ask Chrome which name it would write for the same six URLs.
node cd-browser.mjs namesChrome/152.0.7977.76 /named "quarterly report.csv" /unquoted "quarterly report.csv" /star "звіт.csv" /both "звіт.csv" /rawsock "звіт.csv" /attachment "attachment.csv"On
/bothChrome tookfilename*and dropped the ASCIIfilename, as RFC 6266 section 4.3 asks. On/attachmentit inventedattachment.csvfrom the URL and the content type. - Step 6.
Compare
inlineandattachmentacross three content types in the same browser.node cd-browser.mjs renderChrome/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.
inlinerenders 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
What to check next
- How to test multipart form data: the same header name appears inside a request part, where it carries the field name rather than a download name.
- How to test a unicode file name in content disposition: the upload half of the round trip that produces the name checked here.
- How to check content-type of API response: step 1 showed a disposition and a type that disagree.
- How to check if file type is validated on the server: a name the server trusted is usually a type the server trusted too.
- How to check file type: read the bytes when the name and the header tell different stories.
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.
Related on this site
intermediate9 minpublished updated Maks Verny