openvibers/openvibe.bench Commits
SVG entries draw as pictures again, and fill their frame (#66)
Changes
@@@ -98,9 +98,23 @@ function applyNonce(html, nonce) {.replace(/<script\b/gi, '<script nonce="' + nonce + '"');}++/** The namespace names an SVG needs. They look like URLs but are never fetched, so the pass below keeps them: without++ * them the browser reads the document as plain XML and shows only its text (11-01: every svg_art entry did). */++const SVG_NAMESPACES = Object.freeze([++ 'http://www.w3.org/2000/svg',++ 'http://www.w3.org/1999/xlink',++ 'http://www.w3.org/XML/1998/namespace',++]);++/** SVG-specific pass (spec 2.3): no script, no foreignObject, no external reference, no event handler, no @import. */function sanitizeSvg(input) {let s = input == null ? '' : String(input);++ const kept = [];++ s = s.replace(/(xmlns(?::[\w-]+)?\s*=\s*)(["'])(https?:\/\/[^"']*)\2/gi, (m, attr, q, url) => {++ if (!SVG_NAMESPACES.includes(url)) return m;++ kept.push(url);++ return attr + q + '\u0000NS' + (kept.length - 1) + '\u0000' + q;++ });s = s.replace(/<!--[\s\S]*?-->/g, '');s = s.replace(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi, '');s = s.replace(/<\/?script\b[^>]*>/gi, '');@@@ -112,9 +126,20 @@ function sanitizeSvg(input) {s = s.replace(/(xlink:href|href|src)\s*=\s*(["'])\s*(?:https?:)?\/\/[^"']*\2/gi, '$1="#blocked"');s = s.replace(/(?:https?:)?\/\/[\w.-]+/gi, 'blocked:');s = s.replace(/javascript\s*:/gi, 'blocked:');++ s = s.replace(/\u0000NS(\d+)\u0000/g, (m, i) => kept[Number(i)] || '');return truncateBytes(s, MAX_ENTRY_BYTES);}++/** Shown on its own in a frame, the drawing fills the frame: the root's fixed width and height give way to 100% when++ * it has a viewBox to scale by (the aspect ratio is kept), so a 1200 by 700 poster is not cropped in a small pane. */++function fitSvg(svg) {++ const s = String(svg == null ? '' : svg);++ const m = /<svg\b[^>]*>/i.exec(s);++ if (!m || !/\sviewBox\s*=/i.test(m[0])) return s;++ const root = m[0].replace(/\s(width|height)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '').replace(/<svg\b/i, '<svg width="100%" height="100%"');++ return s.slice(0, m.index) + root + s.slice(m.index + m[0].length);++}++/** The wall-clock a kind may run, from the kind itself. */function limitMsOf(kind) {const ms = Number(kind && (kind.limit_ms || (kind.schema && kind.schema.limit_ms)));@@@ -242,5 +267,5 @@ function wrapperDocument(opts) {module.exports = {RENDER_ORIGIN, PINNED_ORIGINS, VENDOR, SANDBOX, WRAPPER_SANDBOX, DEFAULT_LIMIT_MS, MAX_ENTRY_BYTES,-- FRAME_ANCESTORS, PAYLOAD_FIELD, codeOf, payloadFor, renderablePayload, wrapperCsp, cspFor, applyNonce, sanitizeSvg, limitMsOf, modeOf, modelDocument, wrapperDocument,++ FRAME_ANCESTORS, PAYLOAD_FIELD, codeOf, payloadFor, renderablePayload, wrapperCsp, cspFor, applyNonce, sanitizeSvg, fitSvg, SVG_NAMESPACES, limitMsOf, modeOf, modelDocument, wrapperDocument,};@@@ -76,7 +76,7 @@ function createRenderRoutes(ctx) {const nonce = crypto.randomBytes(16).toString('base64');if (kind && renderLib.modeOf(kind) === 'static') {const p = renderLib.renderablePayload(kind, entry.payload);-- const svg = renderLib.sanitizeSvg(p.svg || p.html || '');++ const svg = renderLib.fitSvg(renderLib.sanitizeSvg(p.svg || p.html || ''));return bare(res).set('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox").type('image/svg+xml')@@@ -115,6 +115,20 @@ const HOSTILE = [assert.ok(own.includes('<script nonce="N2" type="module">') && !own.includes('guess'), 'a guessed nonce is replaced by ours');});++ await check('an SVG keeps its namespace and fills its frame; external references stay blocked (11-01)', async () => {++ const art = '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1200 700" width="1200" height="700">'++ + '<image href="https://evil.example/x.png"/><use xlink:href="http://evil.example/s.svg#a"/><rect fill="url(#g)" width="10" height="10"/></svg>';++ const out = renderLib.sanitizeSvg(art);++ assert.ok(out.startsWith('<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"'), out.slice(0, 120));++ assert.ok(!out.includes('evil.example'), 'external references are still blocked');++ assert.ok(out.includes('fill="url(#g)"'), 'a local reference stays');++ assert.ok(!renderLib.sanitizeSvg('<svg xmlns="https://evil.example/ns"></svg>').includes('evil.example'), 'only the real namespaces are kept');++ const fit = renderLib.fitSvg(out);++ assert.match(fit, /^<svg width="100%" height="100%" xmlns="http:\/\/www\.w3\.org\/2000\/svg"[^>]*viewBox="0 0 1200 700">/);++ assert.ok(fit.includes('<rect fill="url(#g)" width="10" height="10"/>'), 'only the root gives up its size');++ assert.strictEqual(renderLib.fitSvg('<svg width="24" height="24"></svg>'), '<svg width="24" height="24"></svg>', 'no viewBox: kept as drawn');++ });++await check('a generated answer renders from its entry type\'s field (10-30: every seeded entry was { text } and showed nothing)', async () => {assert.deepStrictEqual(renderLib.payloadFor('ui_page', 'Here you go:\n```html\n<p>hi</p>\n```\nEnjoy.'), { html: '<p>hi</p>' });assert.deepStrictEqual(renderLib.payloadFor('svg_art', '<svg viewBox="0 0 1 1"></svg>'), { svg: '<svg viewBox="0 0 1 1"></svg>' });
@@ -98,9 +98,23 @@ function applyNonce(html, nonce) {
@@ -98,9 +98,23 @@ function applyNonce(html, nonce) {
.replace(/<script\b/gi, '<script nonce="' + nonce + '"');
.replace(/<script\b/gi, '<script nonce="' + nonce + '"');
}
}
+/** The namespace names an SVG needs. They look like URLs but are never fetched, so the pass below keeps them: without
+ * them the browser reads the document as plain XML and shows only its text (11-01: every svg_art entry did). */
+const SVG_NAMESPACES = Object.freeze([
+ 'http://www.w3.org/2000/svg',
+ 'http://www.w3.org/1999/xlink',
+ 'http://www.w3.org/XML/1998/namespace',
+]);
+
/** SVG-specific pass (spec 2.3): no script, no foreignObject, no external reference, no event handler, no @import. */
/** SVG-specific pass (spec 2.3): no script, no foreignObject, no external reference, no event handler, no @import. */
function sanitizeSvg(input) {
function sanitizeSvg(input) {
let s = input == null ? '' : String(input);
let s = input == null ? '' : String(input);
+ const kept = [];
+ s = s.replace(/(xmlns(?::[\w-]+)?\s*=\s*)(["'])(https?:\/\/[^"']*)\2/gi, (m, attr, q, url) => {
+ if (!SVG_NAMESPACES.includes(url)) return m;
+ kept.push(url);
+ return attr + q + '\u0000NS' + (kept.length - 1) + '\u0000' + q;
+ });
s = s.replace(/<!--[\s\S]*?-->/g, '');
s = s.replace(/<!--[\s\S]*?-->/g, '');
s = s.replace(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi, '');
s = s.replace(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi, '');
s = s.replace(/<\/?script\b[^>]*>/gi, '');
s = s.replace(/<\/?script\b[^>]*>/gi, '');
@@ -112,9 +126,20 @@ function sanitizeSvg(input) {
@@ -112,9 +126,20 @@ function sanitizeSvg(input) {
s = s.replace(/(xlink:href|href|src)\s*=\s*(["'])\s*(?:https?:)?\/\/[^"']*\2/gi, '$1="#blocked"');
s = s.replace(/(xlink:href|href|src)\s*=\s*(["'])\s*(?:https?:)?\/\/[^"']*\2/gi, '$1="#blocked"');
s = s.replace(/(?:https?:)?\/\/[\w.-]+/gi, 'blocked:');
s = s.replace(/(?:https?:)?\/\/[\w.-]+/gi, 'blocked:');
s = s.replace(/javascript\s*:/gi, 'blocked:');
s = s.replace(/javascript\s*:/gi, 'blocked:');
+ s = s.replace(/\u0000NS(\d+)\u0000/g, (m, i) => kept[Number(i)] || '');
return truncateBytes(s, MAX_ENTRY_BYTES);
return truncateBytes(s, MAX_ENTRY_BYTES);
}
}
+/** Shown on its own in a frame, the drawing fills the frame: the root's fixed width and height give way to 100% when
+ * it has a viewBox to scale by (the aspect ratio is kept), so a 1200 by 700 poster is not cropped in a small pane. */
+function fitSvg(svg) {
+ const s = String(svg == null ? '' : svg);
+ const m = /<svg\b[^>]*>/i.exec(s);
+ if (!m || !/\sviewBox\s*=/i.test(m[0])) return s;
+ const root = m[0].replace(/\s(width|height)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '').replace(/<svg\b/i, '<svg width="100%" height="100%"');
+ return s.slice(0, m.index) + root + s.slice(m.index + m[0].length);
+}
+
/** The wall-clock a kind may run, from the kind itself. */
/** The wall-clock a kind may run, from the kind itself. */
function limitMsOf(kind) {
function limitMsOf(kind) {
const ms = Number(kind && (kind.limit_ms || (kind.schema && kind.schema.limit_ms)));
const ms = Number(kind && (kind.limit_ms || (kind.schema && kind.schema.limit_ms)));
@@ -242,5 +267,5 @@ function wrapperDocument(opts) {
@@ -242,5 +267,5 @@ function wrapperDocument(opts) {
module.exports = {
module.exports = {
RENDER_ORIGIN, PINNED_ORIGINS, VENDOR, SANDBOX, WRAPPER_SANDBOX, DEFAULT_LIMIT_MS, MAX_ENTRY_BYTES,
RENDER_ORIGIN, PINNED_ORIGINS, VENDOR, SANDBOX, WRAPPER_SANDBOX, DEFAULT_LIMIT_MS, MAX_ENTRY_BYTES,
- FRAME_ANCESTORS, PAYLOAD_FIELD, codeOf, payloadFor, renderablePayload, wrapperCsp, cspFor, applyNonce, sanitizeSvg, limitMsOf, modeOf, modelDocument, wrapperDocument,
+ FRAME_ANCESTORS, PAYLOAD_FIELD, codeOf, payloadFor, renderablePayload, wrapperCsp, cspFor, applyNonce, sanitizeSvg, fitSvg, SVG_NAMESPACES, limitMsOf, modeOf, modelDocument, wrapperDocument,
};
};
@@ -76,7 +76,7 @@ function createRenderRoutes(ctx) {
@@ -76,7 +76,7 @@ function createRenderRoutes(ctx) {
const nonce = crypto.randomBytes(16).toString('base64');
const nonce = crypto.randomBytes(16).toString('base64');
if (kind && renderLib.modeOf(kind) === 'static') {
if (kind && renderLib.modeOf(kind) === 'static') {
const p = renderLib.renderablePayload(kind, entry.payload);
const p = renderLib.renderablePayload(kind, entry.payload);
- const svg = renderLib.sanitizeSvg(p.svg || p.html || '');
+ const svg = renderLib.fitSvg(renderLib.sanitizeSvg(p.svg || p.html || ''));
return bare(res)
return bare(res)
.set('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox")
.set('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox")
.type('image/svg+xml')
.type('image/svg+xml')
@@ -115,6 +115,20 @@ const HOSTILE = [
@@ -115,6 +115,20 @@ const HOSTILE = [
assert.ok(own.includes('<script nonce="N2" type="module">') && !own.includes('guess'), 'a guessed nonce is replaced by ours');
assert.ok(own.includes('<script nonce="N2" type="module">') && !own.includes('guess'), 'a guessed nonce is replaced by ours');
});
});
+ await check('an SVG keeps its namespace and fills its frame; external references stay blocked (11-01)', async () => {
+ const art = '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1200 700" width="1200" height="700">'
+ + '<image href="https://evil.example/x.png"/><use xlink:href="http://evil.example/s.svg#a"/><rect fill="url(#g)" width="10" height="10"/></svg>';
+ const out = renderLib.sanitizeSvg(art);
+ assert.ok(out.startsWith('<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"'), out.slice(0, 120));
+ assert.ok(!out.includes('evil.example'), 'external references are still blocked');
+ assert.ok(out.includes('fill="url(#g)"'), 'a local reference stays');
+ assert.ok(!renderLib.sanitizeSvg('<svg xmlns="https://evil.example/ns"></svg>').includes('evil.example'), 'only the real namespaces are kept');
+ const fit = renderLib.fitSvg(out);
+ assert.match(fit, /^<svg width="100%" height="100%" xmlns="http:\/\/www\.w3\.org\/2000\/svg"[^>]*viewBox="0 0 1200 700">/);
+ assert.ok(fit.includes('<rect fill="url(#g)" width="10" height="10"/>'), 'only the root gives up its size');
+ assert.strictEqual(renderLib.fitSvg('<svg width="24" height="24"></svg>'), '<svg width="24" height="24"></svg>', 'no viewBox: kept as drawn');
+ });
+
await check('a generated answer renders from its entry type\'s field (10-30: every seeded entry was { text } and showed nothing)', async () => {
await check('a generated answer renders from its entry type\'s field (10-30: every seeded entry was { text } and showed nothing)', async () => {
assert.deepStrictEqual(renderLib.payloadFor('ui_page', 'Here you go:\n```html\n<p>hi</p>\n```\nEnjoy.'), { html: '<p>hi</p>' });
assert.deepStrictEqual(renderLib.payloadFor('ui_page', 'Here you go:\n```html\n<p>hi</p>\n```\nEnjoy.'), { html: '<p>hi</p>' });
assert.deepStrictEqual(renderLib.payloadFor('svg_art', '<svg viewBox="0 0 1 1"></svg>'), { svg: '<svg viewBox="0 0 1 1"></svg>' });
assert.deepStrictEqual(renderLib.payloadFor('svg_art', '<svg viewBox="0 0 1 1"></svg>'), { svg: '<svg viewBox="0 0 1 1"></svg>' });