openvibers/openvibe.bench Commits

SVG entries draw as pictures again, and fill their frame (#66)

OP OpenVibers committed 12 days ago · 763fe25813 · 3 files, +41 −2

sanitizeSvg turned every URL-looking string into 'blocked:', including the namespace
xmlns="http://www.w3.org/2000/svg". Without it the browser reads the document as plain XML,
so every svg_art entry on the render origin showed only its text. The real SVG, XLink and XML
namespaces are kept (they are never fetched); every other external reference is still blocked.

Shown alone in a frame, a drawing with a viewBox now scales to the frame (width and height 100%,
aspect kept) instead of being cropped at its fixed 1200 by 700 size.

Changes

ddiff --git a/server/bench/render.js b/server/bench/render.js
iindex f6d5aa7..ed92826 100644
---- a/server/bench/render.js
++++ b/server/bench/render.js
@@@ -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,
};
ddiff --git a/server/http/render.js b/server/http/render.js
iindex 25eb5f7..ba5cb86 100644
---- a/server/http/render.js
++++ b/server/http/render.js
@@@ -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')
ddiff --git a/test/showcase.test.js b/test/showcase.test.js
iindex 30e607e..08b4be7 100644
---- a/test/showcase.test.js
++++ b/test/showcase.test.js
@@@ -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>' });