From d5f6fd64022419b2136211b01e60326fa92b5917 Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 30 Sep 2026 22:16:53 +0200 Subject: [PATCH 1/3] fix(security): fail-closed url() check in SVG sanitizer A quoted url() whose target contained ', " or ) matched nothing in the old token regex, so the check returned true and off-document URLs survived in style/paint attributes (e.g. a root background that beacons the viewer's IP on every render). Count every url( opener and require each to be a strict #fragment ref; any mismatch drops the attribute. Adds regression tests for the quote/paren variants. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 ++++++++++ src/ts/svg-sanitizer.test.ts | 32 ++++++++++++++++++++++++++++++++ src/ts/svg-sanitizer.ts | 16 +++++++++------- 3 files changed, 51 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55aa99b..b6426fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Security + +- **Custom SVG icons can no longer load external URLs** - a crafted icon could + hide an off-site `url(...)` in its `style` or paint attributes by putting a + quote or `)` inside the URL, which slipped past the sanitizer. Opening a map + that used such an icon made the browser contact the attacker's server, + revealing your IP address and when you opened it. No script could run. The + check now rejects anything but plain same-icon `url(#id)` references, and + icons already saved in the browser are cleaned the next time NetGraph loads. + ## [1.3.3] - 2026-09-15 ### Changed diff --git a/src/ts/svg-sanitizer.test.ts b/src/ts/svg-sanitizer.test.ts index b24e0f2..4159d08 100644 --- a/src/ts/svg-sanitizer.test.ts +++ b/src/ts/svg-sanitizer.test.ts @@ -318,6 +318,38 @@ describe('sanitizeSvg - url() filter bypasses', () => { expect(out!).not.toMatch(/evil/); }); + // A quoted URL containing `'`, `"` or `)` is still a valid url() to CSS, but + // defeated the old "extract the target" regex - it matched nothing and the + // value passed. The root 's background fetches on render (beacon). + it.each([ + ['single quote', ``], + ['double quote', ``], + ['close paren', ``], + ])('drops style with an off-document url() holding a %s', (_label, svg) => { + const out = sanitizeSvg(svg); + expect(out!).not.toMatch(/style=/); + expect(out!).not.toMatch(/evil/); + }); + + it('drops fill with an off-document url() holding a close paren', () => { + const out = sanitizeSvg(wrap(``)); + expect(out!).not.toMatch(/fill=/); + expect(out!).not.toMatch(/evil/); + }); + + it('drops a fragment url() whose id holds characters outside the id set', () => { + const out = sanitizeSvg(wrap(``)); + expect(out!).not.toMatch(/fill=/); + }); + + it('keeps quoted fragment url() refs', () => { + const out = sanitizeSvg(wrap( + `` + )); + expect(out!).toMatch(/style="[^"]*url\('#g'\)/); + expect(out!).toMatch(/url\("#m"\)/); + }); + it('still keeps a clean style with only fragment url() refs', () => { const out = sanitizeSvg(wrap( '' diff --git a/src/ts/svg-sanitizer.ts b/src/ts/svg-sanitizer.ts index 938b7cd..48f6ab7 100644 --- a/src/ts/svg-sanitizer.ts +++ b/src/ts/svg-sanitizer.ts @@ -162,11 +162,13 @@ function areAllUrlsFragmentRefs(value: string): boolean { if (/\\/.test(value)) return false; if (/(?:image-set|image|cross-fade|element)\s*\(/i.test(value)) return false; - const URL_TOKEN = /url\(\s*(['"]?)([^'")]*)\1\s*\)/gi; - let match: RegExpExecArray | null; - while ((match = URL_TOKEN.exec(value)) !== null) { - const target = match[2].trim(); - if (!target.startsWith('#')) return false; - } - return true; + // Fail closed: count every `url(` opener, then count only the strict + // fragment-ref forms. Any opener the strict pattern can't account for - + // e.g. a quoted URL holding `'`, `"` or `)`, which a looser "extract the + // target" regex silently fails to match - makes the counts differ and the + // value is rejected. + const openers = value.match(/url\s*\(/gi)?.length ?? 0; + const fragmentRefs = + value.match(/url\(\s*(['"]?)#[a-zA-Z0-9_\-:.]+\1\s*\)/gi)?.length ?? 0; + return openers === fragmentRefs; } From b4692de64a06e86a97d7017aab15583092673b03 Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 30 Sep 2026 22:20:14 +0200 Subject: [PATCH 2/3] feat(security): add Content Security Policy Defense-in-depth behind the SVG sanitizer and escapeHtml: the page may not load or send anything cross-origin (default-src 'none', img-src 'self' data: blob:, connect-src 'self'). style-src keeps 'unsafe-inline' for the innerHTML style="" attributes. The single-file build inlines its JS, so a post-generateBundle plugin appends the inline script's sha256 to script-src instead of allowing 'unsafe-inline' scripts; it fails the build if the CSP meta is missing. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++++ index.html | 10 ++++++++++ vite.config.ts | 39 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6426fc..87e049f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 revealing your IP address and when you opened it. No script could run. The check now rejects anything but plain same-icon `url(#id)` references, and icons already saved in the browser are cleaned the next time NetGraph loads. +- **Added a Content Security Policy** - as a second line of defense, the page + (and the offline copy) now tells the browser it may not load images, scripts + or anything else from another site, or send data anywhere. Even if a crafted + map or icon ever slipped past the sanitizer again, it could not contact an + outside server. ## [1.3.3] - 2026-09-15 diff --git a/index.html b/index.html index 69a9b4f..2fe8103 100644 --- a/index.html +++ b/index.html @@ -2,6 +2,16 @@ + + NetGraph diff --git a/vite.config.ts b/vite.config.ts index e17602c..97cc6da 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,6 +3,7 @@ import { viteSingleFile } from 'vite-plugin-singlefile'; import { renameSync } from 'node:fs'; import { join } from 'node:path'; import { createRequire } from 'node:module'; +import { createHash } from 'node:crypto'; const { version: appVersion } = createRequire(import.meta.url)('./package.json') as { version: string }; @@ -40,6 +41,40 @@ function inlineFavicon() { }; } +/** + * Allow the single-file build's inlined script under the CSP in index.html. + * vite-plugin-singlefile turns `