From 7e67bb4c662f191c7a8c4c2cbd32d5637fe2de0d Mon Sep 17 00:00:00 2001 From: Kaloyan Manolov Date: Sun, 10 May 2026 17:41:37 +0300 Subject: [PATCH 1/8] feat: implement sandboxing to be used for measuring SVGs; add tests --- package-lock.json | 4 +- packages/scratch-svg-renderer/package.json | 13 +- .../scratch-svg-renderer/playwright.config.js | 30 ++ .../src/sandbox/iframe-html.js | 76 +++ .../scratch-svg-renderer/src/sandbox/index.js | 172 +++++++ .../test/playwright/harness.html | 11 + .../test/playwright/sandbox.spec.js | 446 ++++++++++++++++++ 7 files changed, 747 insertions(+), 5 deletions(-) create mode 100644 packages/scratch-svg-renderer/playwright.config.js create mode 100644 packages/scratch-svg-renderer/src/sandbox/iframe-html.js create mode 100644 packages/scratch-svg-renderer/src/sandbox/index.js create mode 100644 packages/scratch-svg-renderer/test/playwright/harness.html create mode 100644 packages/scratch-svg-renderer/test/playwright/sandbox.spec.js diff --git a/package-lock.json b/package-lock.json index 5d03c7b427c..1e8472c1ec6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34980,6 +34980,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "bin": { "uglifyjs": "bin/uglifyjs" }, @@ -36795,7 +36796,7 @@ "react-intl": "6.8.9", "react-modal": "3.16.3", "react-popover": "0.5.10", - "react-redux": "8.1.3", + "react-redux": "^8.0.0", "react-responsive": "9.0.2", "react-style-proptype": "3.2.2", "react-tabs": "5.2.0", @@ -38525,6 +38526,7 @@ "devDependencies": { "@babel/core": "7.29.0", "@babel/preset-env": "7.29.3", + "@playwright/test": "1.59.1", "babel-loader": "9.2.1", "canvas": "3.2.3", "copy-webpack-plugin": "6.4.1", diff --git a/packages/scratch-svg-renderer/package.json b/packages/scratch-svg-renderer/package.json index 48a7668850b..e80b3e8b9f5 100644 --- a/packages/scratch-svg-renderer/package.json +++ b/packages/scratch-svg-renderer/package.json @@ -14,10 +14,13 @@ "license": "AGPL-3.0-only", "author": "Massachusetts Institute of Technology", "exports": { - "webpack": "./src/index.js", - "browser": "./dist/web/scratch-svg-renderer.js", - "node": "./dist/node/scratch-svg-renderer.js", - "default": "./src/index.js" + ".": { + "webpack": "./src/index.js", + "browser": "./dist/web/scratch-svg-renderer.js", + "node": "./dist/node/scratch-svg-renderer.js", + "default": "./src/index.js" + }, + "./sandbox": "./src/sandbox/index.js" }, "main": "./dist/node/scratch-svg-renderer.js", "browser": "./dist/web/scratch-svg-renderer.js", @@ -37,6 +40,7 @@ "start": "webpack-dev-server", "test": "npm run test:lint && npm run test:unit", "test:lint": "eslint", + "test:playwright": "playwright test", "test:unit": "tap ./test/*.js", "watch": "webpack --watch" }, @@ -61,6 +65,7 @@ "devDependencies": { "@babel/core": "7.29.0", "@babel/preset-env": "7.29.3", + "@playwright/test": "1.59.1", "babel-loader": "9.2.1", "canvas": "3.2.3", "copy-webpack-plugin": "6.4.1", diff --git a/packages/scratch-svg-renderer/playwright.config.js b/packages/scratch-svg-renderer/playwright.config.js new file mode 100644 index 00000000000..525025df08c --- /dev/null +++ b/packages/scratch-svg-renderer/playwright.config.js @@ -0,0 +1,30 @@ +// @ts-check +const path = require('path'); +const {pathToFileURL} = require('url'); +const {defineConfig, devices} = require('@playwright/test'); + +module.exports = defineConfig({ + testDir: './test/playwright', + + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + + outputDir: 'test-results/playwright-artifacts', + reporter: [ + ['list'], + ['html', {outputFolder: 'test-results/playwright-html', open: 'never'}] + ], + + use: { + baseURL: `${pathToFileURL(path.resolve(__dirname, 'test/playwright'))}/`, + trace: 'on-first-retry' + }, + + projects: [ + { + name: 'chromium', + use: {...devices['Desktop Chrome']} + } + ] +}); diff --git a/packages/scratch-svg-renderer/src/sandbox/iframe-html.js b/packages/scratch-svg-renderer/src/sandbox/iframe-html.js new file mode 100644 index 00000000000..57fd2d9ad31 --- /dev/null +++ b/packages/scratch-svg-renderer/src/sandbox/iframe-html.js @@ -0,0 +1,76 @@ +/** + * HTML template injected into sandboxed iframes via `srcdoc`. + * + * The iframe receives: + * - A strict Content-Security-Policy via (default-src 'none'; + * script-src 'unsafe-inline' 'unsafe-eval'). + * - A runner + +`; + +// Support both CommonJS (Node / bundler) and plain browser + + + diff --git a/packages/scratch-svg-renderer/test/playwright/sandbox.spec.js b/packages/scratch-svg-renderer/test/playwright/sandbox.spec.js new file mode 100644 index 00000000000..a21f6dbe9fc --- /dev/null +++ b/packages/scratch-svg-renderer/test/playwright/sandbox.spec.js @@ -0,0 +1,446 @@ +// @ts-check +const {test, expect} = require('@playwright/test'); + +/** + * Playwright tests for the Sandbox iframe-host primitive. + * + * These tests exercise real browser behaviour that jsdom cannot replicate: + * opaque-origin enforcement, CSP violations, and iframe lifecycle. + */ + +test.beforeEach(async ({page}) => { + await page.goto('harness.html'); + // Wait for the harness to expose Sandbox on window. + await page.waitForFunction(() => typeof window.Sandbox === 'function'); +}); + +test('basic script execution returns a result', async ({page}) => { + const result = await page.evaluate(async () => { + const sandbox = new window.Sandbox( + 'window.onSandboxMessage = function (p) { return p.a + p.b; }' + ); + try { + return await sandbox.send({a: 2, b: 3}); + } finally { + sandbox.destroy(); + } + }); + expect(result).toBe(5); +}); + +test('iframe is removed from DOM after destroy', async ({page}) => { + await page.evaluate(async () => { + const sandbox = new window.Sandbox( + 'window.onSandboxMessage = function () { return "done"; }' + ); + await sandbox.send(null); + sandbox.destroy(); + }); + + const iframeCount = await page.evaluate(() => + document.querySelectorAll('iframe').length + ); + expect(iframeCount).toBe(0); +}); + +test('iframe has opaque origin (window.origin is "null")', async ({page}) => { + const origin = await page.evaluate(async () => { + const sandbox = new window.Sandbox( + 'window.onSandboxMessage = function () { return window.origin; }' + ); + try { + return await sandbox.send(null); + } finally { + sandbox.destroy(); + } + }); + expect(origin).toBe('null'); +}); + +test('iframe cannot access parent.location', async ({page}) => { + const result = await page.evaluate(async () => { + const sandbox = new window.Sandbox(` + window.onSandboxMessage = function () { + try { + return parent.location.href; + } catch (e) { + return {threw: true, message: e.message}; + } + } + `); + try { + return await sandbox.send(null); + } finally { + sandbox.destroy(); + } + }); + expect(result).toHaveProperty('threw', true); +}); + +test('fetch is blocked by CSP (no connect-src)', async ({page}) => { + const result = await page.evaluate(async () => { + const sandbox = new window.Sandbox(` + window.onSandboxMessage = async function () { + try { + await fetch('https://example.com'); + return {blocked: false}; + } catch (e) { + return {blocked: true, message: e.message}; + } + } + `); + try { + return await sandbox.send(null); + } finally { + sandbox.destroy(); + } + }); + expect(result).toHaveProperty('blocked', true); +}); + +test('script errors are propagated as rejections', async ({page}) => { + const error = await page.evaluate(async () => { + const sandbox = new window.Sandbox( + 'window.onSandboxMessage = function () { throw new Error("test error"); }' + ); + try { + return await sandbox.send(null).catch(e => ({message: e.message})); + } finally { + sandbox.destroy(); + } + }); + expect(error).toHaveProperty('message', 'test error'); +}); + +test('missing onSandboxMessage definition rejects', async ({page}) => { + const error = await page.evaluate(async () => { + const sandbox = new window.Sandbox( + '/* no onSandboxMessage defined */' + ); + try { + return await sandbox.send(null).catch(e => ({message: e.message})); + } finally { + sandbox.destroy(); + } + }); + expect(error.message).toContain('did not define window.onSandboxMessage'); +}); + +test('timeout rejects when script never responds', async ({page}) => { + const error = await page.evaluate(async () => { + const sandbox = new window.Sandbox( + 'window.onSandboxMessage = function () { return new Promise(() => {}); }', + {timeoutMs: 500} + ); + try { + return await sandbox.send(null).catch(e => ({message: e.message})); + } finally { + sandbox.destroy(); + } + }); + expect(error.message).toContain('timed out'); +}); + +test('async onSandboxMessage is supported', async ({page}) => { + const result = await page.evaluate(async () => { + const sandbox = new window.Sandbox(` + window.onSandboxMessage = function (p) { + return new Promise(function (resolve) { + setTimeout(function () { resolve(p.x * 2); }, 50); + }); + } + `); + try { + return await sandbox.send({x: 21}); + } finally { + sandbox.destroy(); + } + }); + expect(result).toBe(42); +}); + +test('iframe is created with sandbox="allow-scripts" attribute', async ({page}) => { + const sandboxAttr = await page.evaluate(async () => { + const originalAppendChild = document.body.appendChild.bind(document.body); + let capturedSandbox = null; + document.body.appendChild = function (node) { + if (node.tagName === 'IFRAME') { + capturedSandbox = node.getAttribute('sandbox'); + } + return originalAppendChild(node); + }; + + const sandbox = new window.Sandbox( + 'window.onSandboxMessage = function () { return true; }' + ); + try { + await sandbox.send(null); + return capturedSandbox; + } finally { + sandbox.destroy(); + } + }); + expect(sandboxAttr).toBe('allow-scripts'); +}); + +test('iframe srcdoc contains CSP meta tag', async ({page}) => { + const srcdoc = await page.evaluate(async () => { + const originalAppendChild = document.body.appendChild.bind(document.body); + let capturedSrcdoc = null; + document.body.appendChild = function (node) { + if (node.tagName === 'IFRAME') { + capturedSrcdoc = node.srcdoc; + } + return originalAppendChild(node); + }; + + const sandbox = new window.Sandbox( + 'window.onSandboxMessage = function () { return true; }' + ); + try { + await sandbox.send(null); + return capturedSrcdoc; + } finally { + sandbox.destroy(); + } + }); + expect(srcdoc).toContain('Content-Security-Policy'); + expect(srcdoc).toContain("default-src 'none'"); + expect(srcdoc).toContain("script-src 'unsafe-inline' 'unsafe-eval'"); +}); + +// --- Persistent iframe reuse tests --- + +test('iframe is reused across send calls', async ({page}) => { + const results = await page.evaluate(async () => { + const sandbox = new window.Sandbox(` + var counter = 0; + window.onSandboxMessage = function () { + counter++; + return counter; + }; + `); + try { + const r1 = await sandbox.send(null); + const r2 = await sandbox.send(null); + const r3 = await sandbox.send(null); + return [r1, r2, r3]; + } finally { + sandbox.destroy(); + } + }); + // Counter increments across calls within the same iframe context. + expect(results).toEqual([1, 2, 3]); +}); + +test('script is evaluated only once', async ({page}) => { + const results = await page.evaluate(async () => { + const sandbox = new window.Sandbox(` + if (!window.__evalCount) window.__evalCount = 0; + window.__evalCount++; + window.onSandboxMessage = function () { + return window.__evalCount; + }; + `); + try { + const r1 = await sandbox.send(null); + const r2 = await sandbox.send(null); + return [r1, r2]; + } finally { + sandbox.destroy(); + } + }); + // Script only evaluated once, so __evalCount stays at 1. + expect(results).toEqual([1, 1]); +}); + +test('destroy removes iframe from DOM', async ({page}) => { + const iframeCount = await page.evaluate(async () => { + const sandbox = new window.Sandbox( + 'window.onSandboxMessage = function () { return true; }' + ); + await sandbox.send(null); + sandbox.destroy(); + return document.querySelectorAll('iframe').length; + }); + expect(iframeCount).toBe(0); +}); + +test('destroy rejects in-flight calls', async ({page}) => { + const error = await page.evaluate(async () => { + const sandbox = new window.Sandbox( + 'window.onSandboxMessage = function () { return new Promise(() => {}); }' + ); + const pending = sandbox.send(null).catch(e => ({message: e.message})); + // Give the iframe time to start loading. + await new Promise(resolve => setTimeout(resolve, 100)); + sandbox.destroy(); + return pending; + }); + expect(error.message).toContain('Sandbox destroyed'); +}); + +test('is recreated after destroy', async ({page}) => { + const result = await page.evaluate(async () => { + const sandbox = new window.Sandbox(` + var count = 0; + window.onSandboxMessage = function () { return ++count; }; + `); + await sandbox.send(null); // returns 1 on first iframe + sandbox.destroy(); + // After destroy, the next send creates a fresh iframe with count at 0. + return sandbox.send(null); // fresh iframe: returns 1 + }); + expect(result).toBe(1); +}); + +test('multiple concurrent sends resolve independently', async ({page}) => { + const results = await page.evaluate(async () => { + const sandbox = new window.Sandbox(` + window.onSandboxMessage = function (p) { + return new Promise(function (resolve) { + setTimeout(function () { resolve(p.id); }, p.delay); + }); + } + `); + try { + return await Promise.all([ + sandbox.send({id: 'a', delay: 80}), + sandbox.send({id: 'b', delay: 10}), + sandbox.send({id: 'c', delay: 40}) + ]); + } finally { + sandbox.destroy(); + } + }); + expect(results).toEqual(['a', 'b', 'c']); +}); + +// --- Array payloads (batch-style processing) --- + +test('array payload: processes all items in one round-trip', async ({page}) => { + const results = await page.evaluate(async () => { + const sandbox = new window.Sandbox(` + window.onSandboxMessage = function (items) { + return items.map(function (x) { return x * 2; }); + } + `); + try { + return await sandbox.send([1, 2, 3, 4, 5]); + } finally { + sandbox.destroy(); + } + }); + expect(results).toEqual([2, 4, 6, 8, 10]); +}); + +test('array payload: preserves order with async handlers', async ({page}) => { + const results = await page.evaluate(async () => { + const sandbox = new window.Sandbox(` + window.onSandboxMessage = function (items) { + return Promise.all(items.map(function (p) { + return new Promise(function (resolve) { + setTimeout(function () { resolve(p.id); }, p.delay); + }); + })); + } + `); + try { + return await sandbox.send([ + {id: 'slow', delay: 80}, + {id: 'fast', delay: 10}, + {id: 'medium', delay: 40} + ]); + } finally { + sandbox.destroy(); + } + }); + expect(results).toEqual(['slow', 'fast', 'medium']); +}); + +test('array payload: rejects if handler throws', async ({page}) => { + const error = await page.evaluate(async () => { + const sandbox = new window.Sandbox(` + window.onSandboxMessage = function (items) { + return Promise.all(items.map(function (p) { + if (p === 'bad') throw new Error('payload failed'); + return p; + })); + } + `); + try { + return await sandbox.send(['ok', 'bad', 'ok']).catch(e => ({message: e.message})); + } finally { + sandbox.destroy(); + } + }); + expect(error.message).toContain('payload failed'); +}); + +test('array payload: empty array returns empty results', async ({page}) => { + const results = await page.evaluate(async () => { + const sandbox = new window.Sandbox(` + window.onSandboxMessage = function (items) { + return items.map(function (x) { return x; }); + } + `); + try { + return await sandbox.send([]); + } finally { + sandbox.destroy(); + } + }); + expect(results).toEqual([]); +}); + +// --- Performance comparison test --- +test('reused sandbox is faster than creating a new one per call', async ({page}) => { + const {freshMs, reusedMs, batchMs} = await page.evaluate(async () => { + const ITERATIONS = 500; + const BATCH_SIZE = 20; + const script = 'window.onSandboxMessage = function (p) { return p * 2; }'; + const batchScript = `window.onSandboxMessage = function (items) { + return items.map(function (x) { return x * 2; }); + }`; + + // Fresh sandbox per call: create + send + destroy each time. + const freshStart = performance.now(); + for (let i = 0; i < ITERATIONS; i++) { + const sb = new window.Sandbox(script); + await sb.send(i); + sb.destroy(); + } + const freshEnd = performance.now(); + + // Reused: one sandbox, N sequential single-item sends. + const reusedStart = performance.now(); + const sandbox = new window.Sandbox(script); + for (let i = 0; i < ITERATIONS; i++) { + await sandbox.send(i); + } + sandbox.destroy(); + const reusedEnd = performance.now(); + + // Batch: one sandbox, sends BATCH_SIZE items per round-trip. + const batchStart = performance.now(); + const sandbox2 = new window.Sandbox(batchScript); + for (let i = 0; i < ITERATIONS; i += BATCH_SIZE) { + const chunk = Array.from({length: BATCH_SIZE}, (_, j) => i + j); + await sandbox2.send(chunk); + } + sandbox2.destroy(); + const batchEnd = performance.now(); + + return { + freshMs: freshEnd - freshStart, + reusedMs: reusedEnd - reusedStart, + batchMs: batchEnd - batchStart + }; + }); + + // Reused sandbox should be substantially faster than creating a fresh iframe per call. + expect(reusedMs * 1.5).toBeLessThan(freshMs); + // Batch sends ITERATIONS/BATCH_SIZE round-trips instead of ITERATIONS, + // so it must be faster than sequential sends. + expect(batchMs * 1.5).toBeLessThan(reusedMs); +}); From 116301e0fbe3d247d3acc97cc9a5a8f87f3ba5ea Mon Sep 17 00:00:00 2001 From: Kaloyan Manolov Date: Mon, 11 May 2026 16:41:24 +0300 Subject: [PATCH 2/8] feat: add canonicalize-svg method --- package-lock.json | 87 ++++- .../scratch-svg-renderer/eslint.config.mjs | 13 + packages/scratch-svg-renderer/package.json | 1 + .../src/canonicalize-svg.js | 228 ++++++++++++ packages/scratch-svg-renderer/src/index.js | 2 + .../scratch-svg-renderer/src/sandbox/index.js | 6 +- .../scratch-svg-renderer/src/sanitize-svg.js | 91 +---- .../src/util/svg-url-helpers.js | 122 +++++++ .../test/canonicalize-svg.js | 330 ++++++++++++++++++ .../snapshots/cat-costume.canonicalized.png | Bin 0 -> 35354 bytes .../test/snapshots/cat-costume.sanitized.png | Bin 35360 -> 35354 bytes .../scratch-svg-renderer/webpack.config.js | 2 +- 12 files changed, 779 insertions(+), 103 deletions(-) create mode 100644 packages/scratch-svg-renderer/src/canonicalize-svg.js create mode 100644 packages/scratch-svg-renderer/src/util/svg-url-helpers.js create mode 100644 packages/scratch-svg-renderer/test/canonicalize-svg.js create mode 100644 packages/scratch-svg-renderer/test/snapshots/cat-costume.canonicalized.png diff --git a/package-lock.json b/package-lock.json index 1e8472c1ec6..6a1332d6062 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12242,7 +12242,6 @@ }, "node_modules/boolbase": { "version": "1.0.0", - "dev": true, "license": "ISC" }, "node_modules/bottleneck": { @@ -14149,7 +14148,6 @@ }, "node_modules/css-select": { "version": "5.2.2", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0", @@ -14184,7 +14182,6 @@ }, "node_modules/css-what": { "version": "6.2.2", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">= 6" @@ -14208,6 +14205,39 @@ "node": ">=4" } }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "license": "CC0-1.0" + }, "node_modules/cssom": { "version": "0.3.8", "dev": true, @@ -14678,7 +14708,6 @@ }, "node_modules/dom-serializer": { "version": "2.0.0", - "dev": true, "license": "MIT", "dependencies": { "domelementtype": "^2.3.0", @@ -14694,7 +14723,6 @@ }, "node_modules/domelementtype": { "version": "2.3.0", - "dev": true, "funding": [ { "type": "github", @@ -14713,7 +14741,6 @@ }, "node_modules/domhandler": { "version": "5.0.3", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "domelementtype": "^2.3.0" @@ -14734,7 +14761,6 @@ }, "node_modules/domutils": { "version": "3.2.2", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^2.0.0", @@ -14885,7 +14911,6 @@ }, "node_modules/entities": { "version": "4.5.0", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -27411,7 +27436,6 @@ }, "node_modules/nth-check": { "version": "2.1.1", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0" @@ -31037,9 +31061,13 @@ "license": "MIT" }, "node_modules/sax": { - "version": "1.4.3", - "dev": true, - "license": "BlueOak-1.0.0" + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } }, "node_modules/saxes": { "version": "3.1.11", @@ -33250,6 +33278,40 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svgo": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.1.tgz", + "integrity": "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==", + "license": "MIT", + "dependencies": { + "commander": "^11.1.0", + "css-select": "^5.1.0", + "css-tree": "^3.0.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.1.1", + "sax": "^1.5.0" + }, + "bin": { + "svgo": "bin/svgo.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/svgo/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "license": "MIT" @@ -38520,6 +38582,7 @@ "css-tree": "3.2.1", "fastestsmallesttextencoderdecoder": "1.0.22", "isomorphic-dompurify": "2.36.0", + "svgo": "^4.0.1", "transformation-matrix": "1.15.3", "tslog": "4.10.2" }, diff --git a/packages/scratch-svg-renderer/eslint.config.mjs b/packages/scratch-svg-renderer/eslint.config.mjs index c816685ea7d..638a7de215a 100644 --- a/packages/scratch-svg-renderer/eslint.config.mjs +++ b/packages/scratch-svg-renderer/eslint.config.mjs @@ -16,11 +16,24 @@ export default eslintConfigScratch.defineConfig( '*.{,c,m}js', // for example, webpack.config.js 'test/**/*.{,c,m}js' ], + ignores: ['test/playwright/**/*.{,c,m}js'], extends: [eslintConfigScratch.legacy.node], languageOptions: { globals: globals.node } }, + { + // Playwright test files run in Node but contain page.evaluate() callbacks + // that reference browser globals (window, document) executed in Chromium. + files: ['test/playwright/**/*.{,c,m}js'], + extends: [eslintConfigScratch.legacy.node], + languageOptions: { + globals: { + ...globals.node, + ...globals.browser + } + } + }, globalIgnores([ 'dist/**/*', 'node_modules/**/*', diff --git a/packages/scratch-svg-renderer/package.json b/packages/scratch-svg-renderer/package.json index e80b3e8b9f5..501cf9dd7f8 100644 --- a/packages/scratch-svg-renderer/package.json +++ b/packages/scratch-svg-renderer/package.json @@ -59,6 +59,7 @@ "css-tree": "3.2.1", "fastestsmallesttextencoderdecoder": "1.0.22", "isomorphic-dompurify": "2.36.0", + "svgo": "^4.0.1", "transformation-matrix": "1.15.3", "tslog": "4.10.2" }, diff --git a/packages/scratch-svg-renderer/src/canonicalize-svg.js b/packages/scratch-svg-renderer/src/canonicalize-svg.js new file mode 100644 index 00000000000..333e8fdf8f9 --- /dev/null +++ b/packages/scratch-svg-renderer/src/canonicalize-svg.js @@ -0,0 +1,228 @@ +/** + * Canonicalize SVG text using SVGO 4 with a security-focused, deny-by-default + * plugin set. Strips dangerous elements, attributes, and external references + * while preserving visual fidelity. + */ + +const {ident} = require('css-tree/utils'); +const { + cssHasExternalUrls, filterCssText, isInternalRef, rawTextHasExternalUrls +} = require('./util/svg-url-helpers'); + +// Cache the SVGO module after first dynamic import. +let _svgoPromise = null; +const getSvgo = () => { + if (!_svgoPromise) { + _svgoPromise = import('svgo/browser'); + } + return _svgoPromise; +}; + +/** + * Strip declarations with external url() references from a CSS declaration + * list string (used for style attributes). Returns the cleaned string, or + * an empty string if everything was removed. + * @param {string} cssText raw CSS declaration list. + * @returns {string} cleaned CSS text. + */ +const stripExternalUrlDeclarations = cssText => { + try { + return filterCssText(cssText, 'declarationList'); + } catch { + // Unparseable CSS — strip entirely rather than risk external loads. + return rawTextHasExternalUrls(ident.decode(cssText)) ? '' : cssText; + } +}; + +// ── Element / attribute classification ───────────────────────────────────── + +/** Elements removed entirely (children discarded). */ +const REMOVE_ELEMENTS = new Set([ + 'script', + 'foreignObject', + 'foreignobject', // case-normalized variant + // SVG animation elements + 'animate', + 'animateMotion', + 'animateTransform', + 'animateColor', + 'set' +]); + +/** Elements whose wrapper is removed but children are preserved. */ +const UNWRAP_ELEMENTS = new Set(['a']); + +/** Attributes that carry a direct URI reference. */ +const URI_ATTRS = new Set(['href', 'xlink:href']); + +/** + * Normalize an attribute name for security checks. + * + * Applies NFKC to collapse full-width and other compatibility equivalents + * (e.g. onclick → onclick), then strips U+200C (ZWNJ) and U+200D (ZWJ), + * which are valid XML name characters and can be embedded invisibly to break + * naive prefix checks (e.g. on\u200Cclick). All legitimate SVG attribute + * names are pure ASCII so these transformations produce no false positives. + * @param {string} name raw attribute name from the parsed SVG AST. + * @returns {string} normalized attribute name. + */ +const normalizeAttrName = name => + name.normalize('NFKC').replace(/[\u200C\u200D]/g, ''); + +const isEventHandler = name => /^on/i.test(normalizeAttrName(name)); + +// ── SVGO custom plugins ─────────────────────────────────────────────────── + +/** + * Remove dangerous elements (script, foreignObject, animation) and unwrap + * anchor elements (preserve children, drop the wrapper). + */ +const removeDangerousElements = { + name: 'removeDangerousElements', + fn: () => ({ + element: { + enter: (node, parentNode) => { + if (REMOVE_ELEMENTS.has(node.name)) { + parentNode.children = parentNode.children.filter( + child => child !== node + ); + return; + } + if (UNWRAP_ELEMENTS.has(node.name)) { + parentNode.children = parentNode.children.flatMap( + child => (child === node ? node.children : [child]) + ); + } + } + } + }) +}; + +/** + * Remove event-handler attributes (on*) and external href / xlink:href + * references. Strip individual external-url declarations from style + * attributes; remove presentation attributes that reference external URLs. + */ +const removeDangerousAttributes = { + name: 'removeDangerousAttributes', + fn: () => ({ + element: { + enter: node => { + for (const attr of Object.keys(node.attributes)) { + const normalizedAttr = normalizeAttrName(attr); + if (isEventHandler(attr)) { + delete node.attributes[attr]; + continue; + } + + // Direct URI attributes (href, xlink:href) + if (URI_ATTRS.has(normalizedAttr)) { + const val = node.attributes[attr]; + if (val && !isInternalRef(val.replace(/\s/g, ''))) { + delete node.attributes[attr]; + } + continue; + } + + // style attribute — strip only the offending declarations + if (normalizedAttr === 'style') { + const cleaned = stripExternalUrlDeclarations( + node.attributes.style + ); + if (cleaned) { + node.attributes.style = cleaned; + } else { + delete node.attributes.style; + } + continue; + } + + // Presentation attributes that might carry url() + const val = node.attributes[attr]; + if (val && /url\s*\(/i.test(val) && + cssHasExternalUrls(val, 'value')) { + delete node.attributes[attr]; + } + } + } + } + }) +}; + +// ── Plugin pipeline ──────────────────────────────────────────────────────── + +/** + * Deny-by-default plugin list. Order matters: + * 1. Inline styles from '; + const result = await canonicalizeSvgText(input); + t.notMatch(result, /