openvibers/openvibe.media Commits

Objects: a download whose stored name is outside ASCII serves with an ASCII filename= and the real name in filename*=, instead of failing Node's header check. (#56)

OP OpenVibers committed 9 Oct 2026 · b348cf715e · 4 files, +85 −2

Changes

ddiff --git a/server/objects/disposition.js b/server/objects/disposition.js
nnew file mode 100644
iindex 0000000..ba51579
---- /dev/null
++++ b/server/objects/disposition.js
@@@ -0,0 +1,30 @@
++'use strict';
++/**
++ * Content-Disposition for an object download. A stored filename can be anything a person named a file
++ * (legacy files keep their original_name, derive jobs pass metadata through), and Node refuses a header
++ * value outside Latin-1, so the header carries a printable-ASCII fallback in filename= and the real name,
++ * percent-encoded as UTF-8, in filename*= (RFC 6266 / RFC 5987): 日本.pdf downloads as 日本.pdf.
++ */
++
++/** Printable ASCII only, quotes and backslashes replaced: the filename= fallback. */
++function asciiName(name) {
++ return String(name).replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_').slice(0, 200) || 'file';
++}
++
++/** RFC 5987 ext-value: UTF-8, percent-encoded, with the characters encodeURIComponent leaves but attr-char refuses. */
++function extValue(name) {
++ return encodeURIComponent(name).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
++}
++
++/**
++ * The header value: `inline` or `attachment`, the fallback, and filename*= only when the fallback lost
++ * something. Control characters (CR, LF and the rest) never reach the header in either form.
++ */
++function contentDisposition(name, { inline = false } = {}) {
++ const real = Array.from(String(name == null || name === '' ? 'file' : name).replace(/[\u0000-\u001f\u007f]/g, '_')).slice(0, 200).join('') || 'file';
++ const fallback = asciiName(real);
++ const kind = inline ? 'inline' : 'attachment';
++ return fallback === real ? `${kind}; filename="${fallback}"` : `${kind}; filename="${fallback}"; filename*=UTF-8''${extValue(real)}`;
++}
++
++module.exports = { contentDisposition, asciiName };
ddiff --git a/server/objects/routes.js b/server/objects/routes.js
iindex d305663..828b78f 100644
---- a/server/objects/routes.js
++++ b/server/objects/routes.js
@@@ -56,6 +56,7 @@ const hls = require('./hls');
const ctypes = require('./content-type');
const multipart = require('./multipart');
const namespaces = require('./namespaces');
++const { contentDisposition } = require('./disposition');
const { tenantAuth, tenantCors, tenantPath, namespaceGrant } = require('../auth');
const { announce } = require('../webhooks');
const { limits } = require('../actor-limits');
@@@ -977,13 +978,12 @@ publicRouter.get('/:id', async (req, res) => {
const mime = obj.mime_type || 'application/octet-stream';
const md = model.parseJson(obj.metadata, {});
-- const name = String(md.filename || obj.id).replace(/["\\\r\n]/g, '_');
const headers = {
'Content-Type': mime,
'X-Content-Type-Options': 'nosniff',
'X-Robots-Tag': 'noindex',
'Cache-Control': obj.visibility === 'private' || sandbox ? 'private, no-store' : 'public, max-age=3600',
-- 'Content-Disposition': `${INLINE.test(mime) ? 'inline' : 'attachment'}; filename="${name}"`,
++ 'Content-Disposition': contentDisposition(md.filename || obj.id, { inline: INLINE.test(mime) }),
};
// A viewer of a native object, counted once a day without being identified (objects/popularity.js); the
// object tiering reads these counts. Sandbox objects are never tiered, so they are not counted.
ddiff --git a/test/object-disposition.test.js b/test/object-disposition.test.js
nnew file mode 100644
iindex 0000000..9b2bb11
---- /dev/null
++++ b/test/object-disposition.test.js
@@@ -0,0 +1,47 @@
++'use strict';
++// Content-Disposition for object downloads (server/objects/disposition.js): an ASCII fallback in filename=,
++// the real name in filename*= when the fallback lost something, and nothing Node would refuse as a header.
++const assert = require('assert');
++const http = require('http');
++const { contentDisposition, asciiName } = require('../server/objects/disposition');
++
++// Node's own header check: a value outside Latin-1 (or with CR/LF) throws ERR_INVALID_CHAR.
++function headerOk(value) {
++ http.validateHeaderValue('Content-Disposition', value);
++ return true;
++}
++
++// A plain ASCII name keeps the old header exactly.
++assert.strictEqual(contentDisposition('hello.txt', { inline: true }), 'inline; filename="hello.txt"');
++assert.strictEqual(contentDisposition('report v2.pdf'), 'attachment; filename="report v2.pdf"');
++
++// A name Node would refuse: the fallback is ASCII, the real name travels percent-encoded as UTF-8.
++const jp = contentDisposition('日本語のファイル.pdf');
++assert.ok(headerOk(jp));
++assert.strictEqual(jp, `attachment; filename="________.pdf"; filename*=UTF-8''${encodeURIComponent('日本語のファイル.pdf')}`);
++assert.throws(() => http.validateHeaderValue('Content-Disposition', 'attachment; filename="日本.pdf"'), /Invalid character/);
++
++// Latin-1 passes Node's check but browsers read it as mojibake: it gets filename*= too.
++const cafe = contentDisposition('café.txt');
++assert.ok(cafe.includes(`filename*=UTF-8''caf%C3%A9.txt`) && cafe.includes('filename="caf_.txt"'), cafe);
++
++// Quotes, backslashes and the attr-chars encodeURIComponent leaves alone.
++const odd = contentDisposition(`it's "a" (copy)*\\.txt`);
++assert.ok(headerOk(odd));
++assert.ok(odd.startsWith(`attachment; filename="it's _a_ (copy)*_.txt"; filename*=UTF-8''it%27s%20%22a%22%20%28copy%29%2A%5C.txt`), odd);
++
++// CR, LF and other control characters never reach the header (no header splitting).
++const split = contentDisposition('a\r\nSet-Cookie: x=1.txt');
++assert.ok(headerOk(split));
++assert.ok(!/[\r\n]/.test(split));
++assert.strictEqual(split, 'attachment; filename="a__Set-Cookie: x=1.txt"');
++
++// Empty and missing names, emoji (surrogate pairs are not cut in half), and a 300-character name.
++assert.strictEqual(contentDisposition(''), 'attachment; filename="file"');
++assert.strictEqual(contentDisposition(null), 'attachment; filename="file"');
++const emoji = contentDisposition('🎥'.repeat(250));
++assert.ok(headerOk(emoji));
++assert.ok(decodeURIComponent(emoji.split("UTF-8''")[1]) === '🎥'.repeat(200));
++assert.strictEqual(asciiName('x'.repeat(300)).length, 200);
++
++console.log('object-disposition: ok');
ddiff --git a/test/objects-api.test.js b/test/objects-api.test.js
iindex c0213be..a3d4924 100644
---- a/test/objects-api.test.js
++++ b/test/objects-api.test.js
@@@ -104,6 +104,12 @@ const crypto = require('crypto');
r = await call('GET', local(signed), { bearer: null });
assert.deepStrictEqual([r.status, r.text, r.headers.get('content-type'), r.headers.get('x-content-type-options'), r.headers.get('cache-control')], [200, 'hello world', 'text/plain', 'nosniff', 'private, no-store']);
assert.strictEqual(r.headers.get('content-disposition'), 'inline; filename="hello.txt"');
++ // A stored name outside Latin-1 (a legacy file's original_name, a derive job's metadata) still serves:
++ // an ASCII fallback plus filename*=, not a 500 from Node's header check.
++ await db.run('UPDATE media_objects SET metadata = ? WHERE id = ?', [JSON.stringify({ filename: '日本.txt' }), id]);
++ r = await call('GET', local(signed), { bearer: null });
++ assert.deepStrictEqual([r.status, r.text, r.headers.get('content-disposition')], [200, 'hello world', `inline; filename="__.txt"; filename*=UTF-8''${encodeURIComponent('日本.txt')}`]);
++ await db.run('UPDATE media_objects SET metadata = ? WHERE id = ?', [JSON.stringify({ filename: 'hello.txt' }), id]);
r = await call('GET', local(signed).replace(/sig=[^&]+/, 'sig=AAAA'), { bearer: null });
assert.strictEqual(r.status, 404, 'tampered signature');
const past = now - 10;