openvibers/openvibe.media Commits

Objects: an SVG file is taken as given and served as a download (#231)

OP OpenVibers committed 14 days ago · 29999611a7 · 2 files, +26 −5

The object model says file and asset take any well-formed type, and that
an SVG is never inline: routes.js INLINE already excludes image/svg+xml,
so /o/:id sends it as an attachment. content-type.js still counted every
image/* as inline, so complete sniffed an SVG's text bytes, found no
container signature and refused it with "the bytes are not a recognised
image format". No SVG could be stored as a file, and Actor's charts
(made as image/svg+xml) could not land.

servedInline() now matches INLINE: images except SVG, video, audio, PDF.
An SVG is not sniffed, is accepted as a file, and downloads with nosniff.
Thumbnails, screenshots and avatars still refuse SVG by kind.

Tested in test/objects-api.test.js: a private SVG file completes ready
and its signed download is image/svg+xml with attachment; an avatar with
image/svg+xml is still refused. The object and content suites pass.

Changes

ddiff --git a/server/objects/content-type.js b/server/objects/content-type.js
iindex 79c7d85..d173bcb 100644
---- a/server/objects/content-type.js
++++ b/server/objects/content-type.js
@@@ -3,9 +3,9 @@
*
* 1. The declared type must suit the kind: vod -> video/* or audio/*, clip -> video/*, thumbnail /
* screenshot / avatar -> image/* (not SVG). file and asset take any well-formed type.
-- * 2. Bytes that /o/:id serves inline (image/*, video/*, audio/*, application/pdf) must look like what
-- * their type says: the first bytes are checked against the container signatures below at
-- * complete. A kind with a rule is always checked.
++ * 2. Bytes that /o/:id serves inline (image/* except SVG, video/*, audio/*, application/pdf) must look like
++ * what their type says: the first bytes are checked against the container signatures below at
++ * complete. A kind with a rule is always checked. An SVG is served as an attachment (routes.js INLINE).
* Anything else is served as an attachment with nosniff, so its declared type is taken as given.
*/
'use strict';
@@@ -63,9 +63,13 @@ function sniff(head) {
return null;
}
--/** Is `type` served inline (so its bytes must match it)? */
++/**
++ * Is `type` served inline (so its bytes must match it)? An SVG is served as an attachment on this origin (it is
++ * never inline, INLINE in routes.js), so its text bytes are taken as given, like any other downloaded file.
++ */
function servedInline(type) {
-- return /^(image\/|video\/|audio\/)/.test(type || '') || type === 'application/pdf';
++ const t = String(type || '').toLowerCase();
++ return /^(image\/(?!svg)|video\/|audio\/)/.test(t) || t === 'application/pdf';
}
/** null when the bytes fit the declared type (or need no check), else the reason. */
ddiff --git a/test/objects-api.test.js b/test/objects-api.test.js
iindex 32d412a..56e7681 100644
---- a/test/objects-api.test.js
++++ b/test/objects-api.test.js
@@@ -173,6 +173,23 @@ const crypto = require('crypto');
r = await call('GET', `${privSigned.replace(/sig=[^&]+/, 'sig=AAAA')}&w=96&fmt=webp`, { bearer: null });
assert.strictEqual(r.status, 404, 'a bad signature gets no variant either');
await db.run('DELETE FROM media_objects WHERE id = ?', [privId]); // the listing checks below count the objects
++ // An SVG is a file like any other (Actor's charts): its text bytes are taken as given, and it is served as a
++ // download on this origin, never inline. An image kind that must be raster still refuses it.
++ const svgBody = Buffer.from('<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><circle cx="5" cy="5" r="4"/></svg>');
++ r = await call('POST', O, { body: { kind: 'file', visibility: 'private', mime_type: 'image/svg+xml', filename: 'chart.svg', size_bytes: svgBody.length } });
++ assert.strictEqual(r.status, 201, JSON.stringify(r.body));
++ const svgId = r.body.id;
++ r = await call('PUT', `${O}/${svgId}/content`, { raw: svgBody, headers: { 'content-type': 'image/svg+xml' } });
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ r = await call('POST', `${O}/${svgId}/complete`);
++ assert.deepStrictEqual([r.status, r.body.lifecycle_status], [200, 'ready'], JSON.stringify(r.body));
++ r = await call('GET', `${O}/${svgId}/download`);
++ const sv = await fetch(local(r.body.url));
++ assert.deepStrictEqual([sv.status, sv.headers.get('content-type'), /^attachment/.test(sv.headers.get('content-disposition') || ''), sv.headers.get('x-content-type-options')],
++ [200, 'image/svg+xml', true, 'nosniff'], 'the SVG downloads, never inline');
++ r = await call('POST', O, { body: { kind: 'avatar', visibility: 'public', mime_type: 'image/svg+xml' } });
++ assert.ok(r.status >= 400, 'an avatar still may not be an SVG: ' + r.status);
++ await db.run('DELETE FROM media_objects WHERE id = ?', [svgId]);
console.log('✅ image variants: a public image in its asked size, the original otherwise; a private one only signed');
// ── Service tokens ──