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
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,16 @@
>{{ rc.cell.basis === 'externally-certified' ? 'certified' : 'self' }}</span>
</span>
<a
v-if="rc.cell.evidence"
v-if="rc.cell.evidence && isSafeHttp(rc.cell.evidence.href)"
class="bt-evidence"
:href="rc.cell.evidence.href"
target="_blank"
rel="noopener noreferrer"
>{{ rc.cell.evidence.label }} ↗</a>
<span
v-else-if="rc.cell.evidence"
class="bt-evidence bt-evidence--unsafe"
>{{ rc.cell.evidence.label }}</span>
</template>
<span
v-if="rc.cell.note && expanded.has(row.feat.id)"
Expand Down Expand Up @@ -115,6 +119,7 @@ import { computed, ref } from 'vue';
import PCard from '../../../components/workbench/PCard.vue';
import type { BoardCell, BoardColumn, BoardRank, CategoryBoard, LitmusFeature } from '../../../api/competitiveBoardsApi';
import { RANK_ORDER, cellFor, tallyBoard } from './tally';
import { isSafeHttp } from '../../../utils/urlSafe';

const props = defineProps<{ board: CategoryBoard }>();

Expand Down Expand Up @@ -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); }
Expand Down
13 changes: 13 additions & 0 deletions socioprophet-web/client-vue/src/utils/PROVENANCE.txt
Original file line number Diff line number Diff line change
@@ -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).
39 changes: 39 additions & 0 deletions socioprophet-web/client-vue/src/utils/urlSafe.test.ts
Original file line number Diff line number Diff line change
@@ -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,<script>alert(1)</script>',
'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);
});
});
57 changes: 57 additions & 0 deletions socioprophet-web/client-vue/src/utils/urlSafe.ts
Original file line number Diff line number Diff line change
@@ -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 `<a>` 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<string> = 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;
}
9 changes: 8 additions & 1 deletion socioprophet-web/client-vue/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
};
});
Loading