diff --git a/CHANGELOG.md b/CHANGELOG.md index 55aa99b..d47354b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.3.4] - 2026-09-30 + +### 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. +- **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. + +### Changed + +- **Updated the Lucide icon set** - `lucide-static` 1.46.0 -> 1.47.0. None of + the icons NetGraph ships changed, so maps look exactly the same. + ## [1.3.3] - 2026-09-15 ### Changed @@ -115,7 +137,8 @@ describes the app as it stands at that release. - **Touch support** - drag to pan, pinch to zoom, and long-press for context menus on tablets. -[Unreleased]: https://github.com/BrainInBlack/NetGraph/compare/v1.3.3...HEAD +[Unreleased]: https://github.com/BrainInBlack/NetGraph/compare/v1.3.4...HEAD +[1.3.4]: https://github.com/BrainInBlack/NetGraph/compare/v1.3.3...v1.3.4 [1.3.3]: https://github.com/BrainInBlack/NetGraph/compare/v1.3.2...v1.3.3 [1.3.2]: https://github.com/BrainInBlack/NetGraph/compare/v1.3.1...v1.3.2 [1.3.1]: https://github.com/BrainInBlack/NetGraph/compare/v1.3.0...v1.3.1 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/package-lock.json b/package-lock.json index 6a9fc5e..d88b6f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "netgraph", - "version": "1.3.3", + "version": "1.3.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "netgraph", - "version": "1.3.3", + "version": "1.3.4", "license": "SEE LICENSE IN LICENSE", "dependencies": { "lucide-static": "^1.47.0" diff --git a/package.json b/package.json index 661bbea..8fcde51 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "netgraph", "private": true, - "version": "1.3.3", + "version": "1.3.4", "description": "Browser-based local network visualizer - map your home or lab network, stored entirely in localStorage with no backend.", "license": "SEE LICENSE IN LICENSE", "author": "BrainInBlack ", 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; } 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 `