Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- Content Security Policy - defense-in-depth behind the SVG sanitizer and
escapeHtml: nothing may load from or talk to another origin.
- style-src needs 'unsafe-inline': cards, panels and custom icons carry
style="" attributes set via innerHTML.
- img-src data:/blob: - raster custom icons are data: URLs; the upload
path decodes files through a blob: object URL.
- The single-file build (dist/download/netgraph.html) inlines its JS;
vite.config.ts cspInlineScriptHashes() appends that script's sha256
to script-src at build time, so keep script-src as exactly 'self'. -->
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; base-uri 'none'; form-action 'none'" />
<link rel="icon" type="image/svg+xml" href="./src/assets/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NetGraph</title>
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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 <braininblack@gmail.com>",
Expand Down
32 changes: 32 additions & 0 deletions src/ts/svg-sanitizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <svg>'s background fetches on render (beacon).
it.each([
['single quote', `<svg xmlns="http://www.w3.org/2000/svg" style="background:url(&quot;http://evil/b?a='&quot;)"/>`],
['double quote', `<svg xmlns="http://www.w3.org/2000/svg" style='background:url("http://evil/b?a=&quot;")'/>`],
['close paren', `<svg xmlns="http://www.w3.org/2000/svg" style='background-image:url("http://evil/b?)")'/>`],
])('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(`<rect fill='url("http://evil/a.svg?)#m")'/>`));
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(`<rect fill="url('#a)b')"/>`));
expect(out!).not.toMatch(/fill=/);
});

it('keeps quoted fragment url() refs', () => {
const out = sanitizeSvg(wrap(
`<defs><linearGradient id="g"/><mask id="m"/></defs><rect style="fill:url('#g');mask:url(&quot;#m&quot;)"/>`
));
expect(out!).toMatch(/style="[^"]*url\('#g'\)/);
expect(out!).toMatch(/url\(&quot;#m&quot;\)/);
});

it('still keeps a clean style with only fragment url() refs', () => {
const out = sanitizeSvg(wrap(
'<defs><linearGradient id="g"/></defs><rect style="fill:url(#g);opacity:0.5"/>'
Expand Down
16 changes: 9 additions & 7 deletions src/ts/svg-sanitizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
39 changes: 38 additions & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -40,6 +41,40 @@ function inlineFavicon() {
};
}

/**
* Allow the single-file build's inlined script under the CSP in index.html.
* vite-plugin-singlefile turns `<script src>` into an inline `<script>`, which
* `script-src 'self'` blocks - so hash every inline script body and append the
* `'sha256-...'` sources to script-src (keeps 'unsafe-inline' out of the
* policy). Runs in the `post` generateBundle phase, after singlefile inlined
* and minified the code, so the hash covers the exact shipped bytes. Fails the
* build if the CSP marker is missing rather than shipping a page that won't run.
*/
function cspInlineScriptHashes() {
return {
name: 'csp-inline-script-hashes',
enforce: 'post' as const,
generateBundle: {
order: 'post' as const,
handler(_opts: unknown, bundle: Record<string, { fileName: string; source?: string | Uint8Array }>) {
const html = Object.values(bundle).find(b => b.fileName === 'index.html');
if (!html || typeof html.source !== 'string') return;
const hashes = [...html.source.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)]
.map(m => m[1])
.filter(body => body.length > 0)
.map(body => `'sha256-${createHash('sha256').update(body, 'utf8').digest('base64')}'`);
// Anchored to the CSP meta's content attribute, so a mention of the
// directive elsewhere (e.g. an HTML comment) can't take the hashes.
const CSP_SCRIPT_SRC = /(http-equiv="Content-Security-Policy"\s+content="[^"]*?script-src 'self')/;
if (!CSP_SCRIPT_SRC.test(html.source)) {
throw new Error("csp-inline-script-hashes: no \"script-src 'self'\" in the index.html CSP meta");
}
html.source = html.source.replace(CSP_SCRIPT_SRC, `$1 ${hashes.join(' ')}`);
},
},
};
}

/**
* Rename the emitted HTML file on disk. Vite names the single-file output
* `index.html` (after its `index.html` entry); we want it served as
Expand Down Expand Up @@ -76,7 +111,9 @@ export default defineConfig(({ mode, command }) => {
// download/netgraph.html actually exists.
__WEB_BUILD__: JSON.stringify(command === 'build' && !single),
},
plugins: single ? [viteSingleFile(), inlineFavicon(), renameHtml('netgraph.html')] : [],
plugins: single
? [viteSingleFile(), inlineFavicon(), cspInlineScriptHashes(), renameHtml('netgraph.html')]
: [],
build: {
outDir: single ? 'dist/download' : 'dist',
emptyOutDir: true,
Expand Down
Loading