From 4aa0484848b71c4187b9e699106aaf888447aa73 Mon Sep 17 00:00:00 2001 From: Michael Heller Date: Mon, 3 Aug 2026 19:17:23 -0400 Subject: [PATCH 1/4] fix(competitive-intel): scheme-allowlist BoardTable evidence links (XSS defense-in-depth) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of #545 flagged: BoardTable.vue bound `:href` from `cell(...).evidence!.href` (competitiveBoardsApi JSON) with no scheme allowlist. `rel="noopener noreferrer"` is present but doesn't help — the JS in a `javascript:` href runs before a tab exists. Today's source is estate-internal (dashboard-bff/catalog-gateway JSON or bundled fixtures with hardcoded https://github.com/SocioProphet URLs), so this is defense-in-depth rather than an active exploit path. Same class of fix as #477 (Search.vue) — ported the helper to client-vue as `utils/urlSafe.ts` since app-vue's `services/url-safe.ts` isn't shared across the two packages. Unsafe evidence hrefs now render as plain text (label only, no link) instead of a live anchor. 10 test cases pin the allow/deny boundary. --- .../boards/BoardTable.vue | 8 +++- .../client-vue/src/utils/urlSafe.test.ts | 39 +++++++++++++++++++ .../client-vue/src/utils/urlSafe.ts | 35 +++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 socioprophet-web/client-vue/src/utils/urlSafe.test.ts create mode 100644 socioprophet-web/client-vue/src/utils/urlSafe.ts 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/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..f2481bf1 --- /dev/null +++ b/socioprophet-web/client-vue/src/utils/urlSafe.ts @@ -0,0 +1,35 @@ +/** + * Scheme allow-list for URLs whose source is not this app. + * + * Mirrors `app-vue/src/services/url-safe.ts` (same defect class, same fix — see + * SocioProphet/socioprophet#477). Any `:href` bound from JSON an upstream service + * returned is untrusted by construction: Vue does not sanitise `:href`, and + * `rel="noopener"` blocks only the tab reference, not a `javascript:` payload, + * which runs before a tab exists. Today's callers (dashboard-bff / catalog-gateway + * evidence links) are estate-internal and effectively trusted, but the guard costs + * nothing and closes the door before any producer becomes less trusted. + * + * Fail closed to plain text for everything else (data:, vbscript:, javascript:, + * file:, ftp:, custom schemes). A caller wanting mailto:/tel: opts in via the + * second argument; SAFE_EXTRAS makes that opt-in incapable of re-enabling an + * executable scheme regardless of what the caller passes. + */ + +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 / control chars (0x00-0x20): some browsers have historically + // stripped these before the scheme parse, so "\tjavascript:..." was executable. + 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; + 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; +} From 84f60108bf7f3bd516c272143ab59a8a13a86d6b Mon Sep 17 00:00:00 2001 From: Michael Heller Date: Mon, 3 Aug 2026 19:58:37 -0400 Subject: [PATCH 2/4] chore(client-vue): vendor urlSafe.ts from estate-safety-kit; fix its test glob MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This file was added a few commits ago on this branch as a copy-paste of app-vue's url-safe.ts (same defect class, same fix) because the two Vue packages don't share a workspace. That duplication is exactly what SourceOS-Linux/sourceos-spec#276 exists to close: repoints this file to be a byte-identical vendored copy of estate-safety-kit/js/urlSafe.ts, with PROVENANCE.txt recording the source commit. app-vue's copy is being converted the same way on a separate PR (SocioProphet/socioprophet's chore/vendor-estate-safety-kit branch), since it already existed on master. No logic change — the vendored file is behavior-identical to what was already here (comments only differ). Also: vite.config.ts's vitest `include` was `src/__tests__/**/*.test.ts` only, so `npm test` never actually ran src/utils/urlSafe.test.ts, added earlier on this branch — the 12 tests pinning the allow/deny boundary existed but were never executed. Added a second glob scoped to src/utils/**/*.test.ts to fix that (deliberately not widened to src/**/*.test.ts — several other pre-existing test files elsewhere in this package are also out-of-glob with unverified pass/fail state; that is a separate, broader cleanup, flagged out-of-band rather than folded in here). Full suite after both changes: 124 files / 739 tests pass, including the 12 in urlSafe.test.ts. --- .../client-vue/src/utils/PROVENANCE.txt | 16 ++++++ .../client-vue/src/utils/urlSafe.ts | 50 +++++++++++++------ socioprophet-web/client-vue/vite.config.ts | 8 ++- 3 files changed, 59 insertions(+), 15 deletions(-) create mode 100644 socioprophet-web/client-vue/src/utils/PROVENANCE.txt 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..e0a76f9d --- /dev/null +++ b/socioprophet-web/client-vue/src/utils/PROVENANCE.txt @@ -0,0 +1,16 @@ +source-repo: SourceOS-Linux/sourceos-spec +source-path: estate-safety-kit/js/urlSafe.ts +source-commit: 78f5cf69d231ef5d2c9bd6df45bdf500e7a97ced +vendored-path: socioprophet-web/client-vue/src/utils/urlSafe.ts +vendored-at: 2026-08-03 + +# PROVISIONAL PIN: source-commit above is the head of sourceos-spec PR +# https://github.com/SourceOS-Linux/sourceos-spec/pull/276 (feat/estate-safety-kit), +# not yet merged to main. GitHub serves raw content for any pushed commit, so +# tools/check_vendored_safety_kit.py resolves this pin correctly today, but the +# branch could be deleted after merge — re-pin source-commit to the actual merge +# commit on sourceos-spec's main once #276 lands, then re-run the checker. +# +# 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 / #276's reason for existing in the first place). diff --git a/socioprophet-web/client-vue/src/utils/urlSafe.ts b/socioprophet-web/client-vue/src/utils/urlSafe.ts index f2481bf1..fc648d60 100644 --- a/socioprophet-web/client-vue/src/utils/urlSafe.ts +++ b/socioprophet-web/client-vue/src/utils/urlSafe.ts @@ -1,31 +1,53 @@ /** - * Scheme allow-list for URLs whose source is not this app. + * Scheme allow-list for URLs whose source is not the app rendering them. * - * Mirrors `app-vue/src/services/url-safe.ts` (same defect class, same fix — see - * SocioProphet/socioprophet#477). Any `:href` bound from JSON an upstream service - * returned is untrusted by construction: Vue does not sanitise `:href`, and - * `rel="noopener"` blocks only the tab reference, not a `javascript:` payload, - * which runs before a tab exists. Today's callers (dashboard-bff / catalog-gateway - * evidence links) are estate-internal and effectively trusted, but the guard costs - * nothing and closes the door before any producer becomes less trusted. + * 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. * - * Fail closed to plain text for everything else (data:, vbscript:, javascript:, - * file:, ftp:, custom schemes). A caller wanting mailto:/tel: opts in via the - * second argument; SAFE_EXTRAS makes that opt-in incapable of re-enabling an - * executable scheme regardless of what the caller passes. + * 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 / control chars (0x00-0x20): some browsers have historically - // stripped these before the scheme parse, so "\tjavascript:..." was executable. + // 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(); diff --git a/socioprophet-web/client-vue/vite.config.ts b/socioprophet-web/client-vue/vite.config.ts index 80c57a56..d78f3ef0 100644 --- a/socioprophet-web/client-vue/vite.config.ts +++ b/socioprophet-web/client-vue/vite.config.ts @@ -90,7 +90,13 @@ export default defineConfig(({ mode }) => { environment: 'happy-dom', globals: true, setupFiles: ['src/__tests__/setup.ts'], - include: ['src/__tests__/**/*.test.ts'], + // src/utils/urlSafe.test.ts (#550) landed outside src/__tests__/ and was + // therefore never actually run by `npm test` — added explicitly rather than + // widening to `src/**/*.test.ts`, which would also newly pick up several other + // pre-existing out-of-glob test files (src/config/mesh.test.ts, + // src/features/contract-surfaces/, src/runtime-adapters/*) whose current + // pass/fail state hasn't been verified here; that's a separate cleanup. + include: ['src/__tests__/**/*.test.ts', 'src/utils/**/*.test.ts'], }, }; }); From 701396c931c9f36618d21cd6d8a747c6a0293124 Mon Sep 17 00:00:00 2001 From: Michael Heller Date: Mon, 3 Aug 2026 20:00:46 -0400 Subject: [PATCH 3/4] =?UTF-8?q?fix(client-vue):=20widen=20vitest=20include?= =?UTF-8?q?=20=E2=80=94=206=20real=20test=20files=20were=20never=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The estate-safety-kit vendoring commit narrowly widened test.include to `['src/__tests__/**/*.test.ts', 'src/utils/**/*.test.ts']` to pick up urlSafe.test.ts specifically, and explicitly punted a broader fix: 6 other pre-existing, real (non-stub) test files sat outside BOTH globs — src/config/mesh.test.ts, src/features/contract-surfaces/contractSurfaces .test.ts, and 4 files under src/runtime-adapters/ (knowledgeGraphClient, ontologyClient, rescuedPlatformFeatures, routeRuntimeFeatures). `npm test`/CI never executed them. Same declared-but-unenforced shape as everything else this session has been closing — a test file existing is not the same as a test file running. Widened `include` to `src/**/*.test.ts`, replacing both narrower globs. Verified rather than assumed clean: - Full suite: 130 test files / 783 tests, all pass. - The 6 previously-orphaned files run in isolation: 6/6 files, 44/44 tests, all pass — nothing was actually stale or broken, they were just never executed. No test-content fixes were needed. - `vue-tsc --noEmit`: clean. The noisy ECONNREFUSED/AbortError console output during the full run is expected — live-adapter fallback tests intentionally hit an unreachable localhost:3000 to exercise the degraded path; vitest's own pass/fail accounting (783/783) is unaffected by it. --- socioprophet-web/client-vue/vite.config.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/socioprophet-web/client-vue/vite.config.ts b/socioprophet-web/client-vue/vite.config.ts index d78f3ef0..e9589aef 100644 --- a/socioprophet-web/client-vue/vite.config.ts +++ b/socioprophet-web/client-vue/vite.config.ts @@ -90,13 +90,14 @@ export default defineConfig(({ mode }) => { environment: 'happy-dom', globals: true, setupFiles: ['src/__tests__/setup.ts'], - // src/utils/urlSafe.test.ts (#550) landed outside src/__tests__/ and was - // therefore never actually run by `npm test` — added explicitly rather than - // widening to `src/**/*.test.ts`, which would also newly pick up several other - // pre-existing out-of-glob test files (src/config/mesh.test.ts, - // src/features/contract-surfaces/, src/runtime-adapters/*) whose current - // pass/fail state hasn't been verified here; that's a separate cleanup. - include: ['src/__tests__/**/*.test.ts', 'src/utils/**/*.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'], }, }; }); From 0e15424bf567603e7ad6299c146e01a652224b37 Mon Sep 17 00:00:00 2001 From: Michael Heller Date: Mon, 3 Aug 2026 20:09:52 -0400 Subject: [PATCH 4/4] chore(client-vue): re-pin PROVENANCE.txt to the real sourceos-spec merge commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same fix as socioprophet#551 — sourceos-spec#276 merged (squash) as 297c4e38, the provisional branch-head pin is now dead. Verified byte-identical before re-pinning. --- .../client-vue/src/utils/PROVENANCE.txt | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/socioprophet-web/client-vue/src/utils/PROVENANCE.txt b/socioprophet-web/client-vue/src/utils/PROVENANCE.txt index e0a76f9d..45ea4d38 100644 --- a/socioprophet-web/client-vue/src/utils/PROVENANCE.txt +++ b/socioprophet-web/client-vue/src/utils/PROVENANCE.txt @@ -1,16 +1,13 @@ source-repo: SourceOS-Linux/sourceos-spec source-path: estate-safety-kit/js/urlSafe.ts -source-commit: 78f5cf69d231ef5d2c9bd6df45bdf500e7a97ced +source-commit: 297c4e38befc4a04935cd8cd80bd66874888deb0 vendored-path: socioprophet-web/client-vue/src/utils/urlSafe.ts -vendored-at: 2026-08-03 +vendored-at: 2026-08-04 -# PROVISIONAL PIN: source-commit above is the head of sourceos-spec PR -# https://github.com/SourceOS-Linux/sourceos-spec/pull/276 (feat/estate-safety-kit), -# not yet merged to main. GitHub serves raw content for any pushed commit, so -# tools/check_vendored_safety_kit.py resolves this pin correctly today, but the -# branch could be deleted after merge — re-pin source-commit to the actual merge -# commit on sourceos-spec's main once #276 lands, then re-run the checker. +# 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 / #276's reason for existing in the first place). +# (SocioProphet/socioprophet#550 / #551, #276's reason for existing in the first place).