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)
Changes
nnew file mode 100644@@@ -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 };@@@ -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.nnew file mode 100644@@@ -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');@@@ -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;
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -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 };
@@ -56,6 +56,7 @@ const hls = require('./hls');
@@ -56,6 +56,7 @@ const hls = require('./hls');
const ctypes = require('./content-type');
const ctypes = require('./content-type');
const multipart = require('./multipart');
const multipart = require('./multipart');
const namespaces = require('./namespaces');
const namespaces = require('./namespaces');
+const { contentDisposition } = require('./disposition');
const { tenantAuth, tenantCors, tenantPath, namespaceGrant } = require('../auth');
const { tenantAuth, tenantCors, tenantPath, namespaceGrant } = require('../auth');
const { announce } = require('../webhooks');
const { announce } = require('../webhooks');
const { limits } = require('../actor-limits');
const { limits } = require('../actor-limits');
@@ -977,13 +978,12 @@ publicRouter.get('/:id', async (req, res) => {
@@ -977,13 +978,12 @@ publicRouter.get('/:id', async (req, res) => {
const mime = obj.mime_type || 'application/octet-stream';
const mime = obj.mime_type || 'application/octet-stream';
const md = model.parseJson(obj.metadata, {});
const md = model.parseJson(obj.metadata, {});
- const name = String(md.filename || obj.id).replace(/["\\\r\n]/g, '_');
const headers = {
const headers = {
'Content-Type': mime,
'Content-Type': mime,
'X-Content-Type-Options': 'nosniff',
'X-Content-Type-Options': 'nosniff',
'X-Robots-Tag': 'noindex',
'X-Robots-Tag': 'noindex',
'Cache-Control': obj.visibility === 'private' || sandbox ? 'private, no-store' : 'public, max-age=3600',
'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
// 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.
// object tiering reads these counts. Sandbox objects are never tiered, so they are not counted.
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -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');
@@ -104,6 +104,12 @@ const crypto = require('crypto');
@@ -104,6 +104,12 @@ const crypto = require('crypto');
r = await call('GET', local(signed), { bearer: null });
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.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"');
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 });
r = await call('GET', local(signed).replace(/sig=[^&]+/, 'sig=AAAA'), { bearer: null });
assert.strictEqual(r.status, 404, 'tampered signature');
assert.strictEqual(r.status, 404, 'tampered signature');
const past = now - 10;
const past = now - 10;