diff --git a/socioprophet-web/client-vue/src/features/competitive-intelligence/boards/BoardTable.vue b/socioprophet-web/client-vue/src/features/competitive-intelligence/boards/BoardTable.vue index b7284729..18d19f57 100644 --- a/socioprophet-web/client-vue/src/features/competitive-intelligence/boards/BoardTable.vue +++ b/socioprophet-web/client-vue/src/features/competitive-intelligence/boards/BoardTable.vue @@ -79,12 +79,16 @@ >{{ rc.cell.basis === 'externally-certified' ? 'certified' : 'self' }} {{ rc.cell.evidence.label }} ↗ + {{ rc.cell.evidence.label }} (); @@ -196,6 +201,7 @@ function glyph(rank: BoardRank): string { .bt-badge--basis { color: var(--teal); border-color: rgba(45, 212, 191, 0.4); } .bt-evidence { display: inline-block; margin-top: 0.3rem; font-size: var(--fs-xs); color: var(--info); text-decoration: none; } .bt-evidence:hover { text-decoration: underline; } +.bt-evidence--unsafe { color: var(--text-3); cursor: default; } .bt-cellnote { display: block; margin-top: 0.3rem; font-size: var(--fs-xs); color: var(--text-3); line-height: 1.45; } .bt-defs { margin-top: 0.75rem; font-size: var(--fs-sm); } diff --git a/socioprophet-web/client-vue/src/utils/PROVENANCE.txt b/socioprophet-web/client-vue/src/utils/PROVENANCE.txt new file mode 100644 index 00000000..45ea4d38 --- /dev/null +++ b/socioprophet-web/client-vue/src/utils/PROVENANCE.txt @@ -0,0 +1,13 @@ +source-repo: SourceOS-Linux/sourceos-spec +source-path: estate-safety-kit/js/urlSafe.ts +source-commit: 297c4e38befc4a04935cd8cd80bd66874888deb0 +vendored-path: socioprophet-web/client-vue/src/utils/urlSafe.ts +vendored-at: 2026-08-04 + +# Re-pinned to the actual squash-merge commit on sourceos-spec's main (PR #276 merged). +# Verified byte-identical against this commit's copy before re-pinning: +# sha256 9493df8ba2d957798233181f262c30a2fefc718c184200222e119709168703a1 both sides. +# +# Same source as socioprophet-web/app-vue/src/services/PROVENANCE.txt — app-vue +# and client-vue each vendor an independent copy of the same canonical file +# (SocioProphet/socioprophet#550 / #551, #276's reason for existing in the first place). diff --git a/socioprophet-web/client-vue/src/utils/urlSafe.test.ts b/socioprophet-web/client-vue/src/utils/urlSafe.test.ts new file mode 100644 index 00000000..2a768adc --- /dev/null +++ b/socioprophet-web/client-vue/src/utils/urlSafe.test.ts @@ -0,0 +1,39 @@ +/** + * Pins the BoardTable evidence-link guard (client-vue mirror of app-vue's #477 fix). + * A deny case that leaks through here re-opens the click-XSS surface on any evidence + * link once the source producer stops being fully trusted. + */ +import { describe, it, expect } from 'vitest'; +import { isSafeHttp } from './urlSafe'; + +describe('isSafeHttp — client-vue evidence-link guard', () => { + it.each([ + 'https://github.com/SocioProphet/prophet-platform', + 'http://example.com/', + 'HTTPS://Example.com', + ])('allows %s', (u) => { + expect(isSafeHttp(u)).toBe(true); + }); + + it.each([ + 'javascript:alert(1)', + 'data:text/html,', + 'vbscript:x', + 'file:///etc/passwd', + '\tjavascript:alert(1)', + '', + '//evil.example/path', + ])('rejects %s', (u) => { + expect(isSafeHttp(u)).toBe(false); + }); + + it('rejects non-string values', () => { + expect(isSafeHttp(null)).toBe(false); + expect(isSafeHttp(undefined)).toBe(false); + expect(isSafeHttp({ href: 'https://x' })).toBe(false); + }); + + it('extras cannot re-enable javascript:', () => { + expect(isSafeHttp('javascript:x', ['mailto', 'javascript'])).toBe(false); + }); +}); diff --git a/socioprophet-web/client-vue/src/utils/urlSafe.ts b/socioprophet-web/client-vue/src/utils/urlSafe.ts new file mode 100644 index 00000000..fc648d60 --- /dev/null +++ b/socioprophet-web/client-vue/src/utils/urlSafe.ts @@ -0,0 +1,57 @@ +/** + * Scheme allow-list for URLs whose source is not the app rendering them. + * + * ESTATE SAFETY KIT — canonical source (SourceOS-Linux/sourceos-spec's estate-safety-kit/). + * See estate-safety-kit/PROVENANCE.md in that repo for what this closes, + * which repos hit the defect independently, and the vendoring contract every consumer + * must follow (verbatim copy + PROVENANCE.txt + `tools/check_vendored_safety_kit.py`). + * Do not edit a vendored copy directly — edit this file, re-vendor, done. + * + * Search results, ontology hits, mail links, ledger provenance, evidence-board links — + * anything that lands in a `:href` from an upstream the app does not control (SearXNG + * federates to arbitrary engines; mail arrives from anywhere; an evidence receipt may + * cite any URI; a BFF's JSON is still an upstream even when it is estate-internal) must + * NOT render as a live `` unless the scheme is one a click cannot execute in-origin. + * Vue does not sanitise `:href`, and `rel="noopener"` blocks only the tab reference — + * the JS in a `javascript:` link runs before a tab exists. + * + * Fail closed to plain text for everything else (data:, vbscript:, javascript:, file:, + * ftp:, blob:, about:, custom schemes, protocol-relative `//`). If a caller genuinely + * wants mailto: or tel:, opt in via the second argument. That argument is INTERSECTED + * with a fixed safe-extras set — a caller CANNOT re-enable `javascript:` / `data:` / + * `vbscript:` by passing them in. + * + * Independently written twice before this file existed: `socioprophet-web/app-vue/src/ + * services/url-safe.ts` (SocioProphet/socioprophet#477, the Search.vue click-XSS) and + * `socioprophet-web/client-vue/src/utils/urlSafe.ts` (SocioProphet/socioprophet#550, the + * BoardTable evidence-link hardening) — copy-pasted rather than shared because the two + * Vue packages do not share a workspace. Same logic both times; this is that logic, once. + */ + +// The extras a caller may opt into. Everything not in this set is silently ignored +// when passed in `extra`, so `isSafeHttp(x, ['javascript'])` still returns false for +// a `javascript:` URL — the deny-list on executable schemes is ABSOLUTE. +const SAFE_EXTRAS: ReadonlySet = new Set(['mailto', 'tel']); + +export function isSafeHttp(url: unknown, extra: readonly string[] = []): boolean { + if (typeof url !== 'string') return false; + // Leading whitespace or control characters (0x00-0x20) historically let some + // browsers strip them and re-parse a URL as its trailing scheme — a payload of + // `"\tjavascript:..."` was executable in older Chromium and remains inconsistently + // handled. If the caller wanted a URL it does not start with whitespace. + if (/^[\s\x00-\x20]/.test(url)) return false; + const m = /^([a-z][a-z0-9+.-]*):/i.exec(url); + if (!m) return false; + const s = m[1].toLowerCase(); + if (s === 'http' || s === 'https') return true; + // Normalise the extras callers pass — lowercase + trim — so a callsite passing + // `['Mailto']` or `['MAILTO ']` is not mysteriously refused. The SAFE_EXTRAS filter + // is what keeps this from becoming an XSS vector: only schemes in the fixed + // safe-extras set can be enabled, regardless of what the caller passed in. + for (const raw of extra) { + if (typeof raw !== 'string') continue; + const norm = raw.trim().toLowerCase(); + if (norm === s && SAFE_EXTRAS.has(norm)) return true; + } + return false; +} diff --git a/socioprophet-web/client-vue/vite.config.ts b/socioprophet-web/client-vue/vite.config.ts index 80c57a56..e9589aef 100644 --- a/socioprophet-web/client-vue/vite.config.ts +++ b/socioprophet-web/client-vue/vite.config.ts @@ -90,7 +90,14 @@ export default defineConfig(({ mode }) => { environment: 'happy-dom', globals: true, setupFiles: ['src/__tests__/setup.ts'], - include: ['src/__tests__/**/*.test.ts'], + // Was `['src/__tests__/**/*.test.ts', 'src/utils/**/*.test.ts']` — a glob added + // narrowly for src/utils/urlSafe.test.ts (#550) that left 6 other real, + // pre-existing test files silently unrun by `npm test`/CI: src/config/mesh.test.ts, + // src/features/contract-surfaces/contractSurfaces.test.ts, and 4 files under + // src/runtime-adapters/. Widened to the whole tree so "a test file exists" and + // "a test file executes" can't diverge again — the exact declared-but-unenforced + // shape this session has been closing everywhere else. + include: ['src/**/*.test.ts'], }, }; });