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'],
},
};
});