550 lines
25 KiB
TypeScript
550 lines
25 KiB
TypeScript
/**
|
|
* href-xss smoke — guards against unsafe `href={...}` bindings
|
|
* where the bound expression is operator/peer-controlled and
|
|
* could be a `javascript:` / `data:` / `vbscript:` URL that
|
|
* executes in the user's session.
|
|
*
|
|
* Two attack classes this catches:
|
|
*
|
|
* 1. Operator-published `contact_url` and `origin` strings
|
|
* rendered as `<a href={x}>` without scheme validation.
|
|
* A malicious operator publishes
|
|
* `contact_url=javascript:fetch('/?'+document.cookie)`
|
|
* and every user clicking the link in the footer / instances
|
|
* list / operators directory runs that JavaScript.
|
|
*
|
|
* 2. Peer-supplied URLs (chat payloads with `address`, `txid`,
|
|
* `note` fields) interpolated into hrefs. Lower likelihood
|
|
* because chat hrefs go through `morphitExplorerTxUrl` /
|
|
* `externalExplorerUrl` which regex-validate, but new code
|
|
* sometimes bypasses those builders.
|
|
*
|
|
* Heuristic: any `href={EXPR}` whose EXPR isn't:
|
|
* - a string literal (`href="/foo"`)
|
|
* - an interpolation starting with a route literal (`href={`/${x}/y`}`)
|
|
* - a result of a known-safe builder/helper (morphit*Url, safe*,
|
|
* valid*, externalExplorerUrl, blurtWalletExplorerFallbackUrl)
|
|
* - one of the local nav config arrays (link.href, store.url —
|
|
* allowlisted by site)
|
|
*
|
|
* Allowlist for site-specific safe bindings (e.g., the global nav
|
|
* config that's local to the codebase, not operator-published) is
|
|
* declared as `ALLOWLIST_HREF_EXPR` below — keyed by file path and
|
|
* the EXACT trimmed expression text, not line number, so allowlist
|
|
* entries don't drift with formatting changes.
|
|
*/
|
|
|
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
const REPO_ROOT = path.resolve(import.meta.dirname, '../../..');
|
|
|
|
const SCAN_DIRS = [
|
|
path.join(REPO_ROOT, 'apps/web/src/routes'),
|
|
path.join(REPO_ROOT, 'apps/web/src/lib/components')
|
|
];
|
|
|
|
const EXCLUDE_PATH_PATTERNS: readonly RegExp[] = [/\/dev\//, /__tests__\//, /\.test\./];
|
|
|
|
/** Known-safe URL-builder/validator function names whose return
|
|
* value is verified safe by the helper itself (regex/scheme/etc).
|
|
* An `href={someBuilder(...)}` expression is OK if it starts with
|
|
* one of these names. */
|
|
const SAFE_BUILDER_NAMES = [
|
|
'morphitExplorerAccountUrl',
|
|
'morphitExplorerTxUrl',
|
|
'morphitExplorerBlockUrl',
|
|
'externalExplorerUrl',
|
|
'blurtWalletExplorerFallbackUrl',
|
|
'safeContactUrl',
|
|
'safeInstanceOrigin',
|
|
'safeBlurtImageUrl', // cp388 — TermsText builder; https + exact host img.blurt.blog + image-ext + no userinfo/odd-port, returns string|null
|
|
'validateContactUrl', // local /operators function returning string|null
|
|
'canonicalFor', // Head.svelte canonical builder
|
|
'shareUrl', // FaqSearch builder
|
|
'explorerLinkForTxid', // ChatMessage builder; calls morphitExplorerTxUrl/externalExplorerUrl internally
|
|
// Part 121 cp7 — per-locale link wrapper. `lp(path)` calls
|
|
// `localePath(path, currentLang)` from `$i18n/path`. The
|
|
// `path` argument is always a literal authored by us at the
|
|
// call site (never operator/peer-controlled) and
|
|
// `localePath()` itself returns a `/<lang>/...` path string,
|
|
// never reflecting attacker-controlled values into the href.
|
|
// Code at $i18n/path.ts has 22 smoke scenarios pinning the
|
|
// shape invariants.
|
|
'lp',
|
|
'localePath'
|
|
];
|
|
|
|
/** Per-file allowlist for href bindings the smoke can't prove safe
|
|
* via the SAFE_BUILDER_NAMES heuristic, but a human reviewer has
|
|
* confirmed are safe. Keyed by file path and matched against the
|
|
* EXACT trimmed expression text inside `href={...}` — line numbers
|
|
* intentionally NOT used because they drift on every formatting
|
|
* change (prettier minor-version line-wrap drift, comment edits,
|
|
* Part-74 validatedX lesson). Adding an entry here means the
|
|
* reviewer has confirmed that NO call site in <file> writes the
|
|
* given <expr> to a value an attacker controls. */
|
|
const ALLOWLIST_HREF_EXPR: ReadonlyMap<string, ReadonlySet<string>> = new Map([
|
|
[
|
|
'apps/web/src/routes/[lang]/chat/+page.svelte',
|
|
// cp446 — the inbox threads by (peer, order), so a card's href needs a
|
|
// `?order=` query string and can no longer be a bare `lp()` template the
|
|
// tracer understands. `threadHref` builds it from localePath (a
|
|
// SAFE_BUILDER that prefixes `/<lang>` to single-slash internal paths),
|
|
// percent-encodes the permlink, and REFUSES to return anything that is not
|
|
// root-relative — so no operator- or peer-controlled scheme is reachable.
|
|
new Set(['threadHref(convo)'])
|
|
],
|
|
[
|
|
'apps/web/src/lib/components/OrderCard.svelte',
|
|
// cp404 — detailHref/profileHref/messageHref are STRING PROPS whose
|
|
// values the parent pages (orderbook + account) build with localePath():
|
|
// lp(`/@${account}/${permlink}`), lp(`/@${account}`).
|
|
// localePath is a SAFE_BUILDER that only prefixes single-slash internal
|
|
// paths (external / `//` / any scheme pass through untouched — and these
|
|
// inputs always start with `/@`), and account/permlink are validated
|
|
// on-chain identifiers. The static tracer can't follow a prop value back
|
|
// to the parent's lp() call, but these are confirmed site-controlled
|
|
// internal routes — no operator/peer-controlled scheme is reachable.
|
|
new Set(['detailHref', 'messageHref', 'profileHref'])
|
|
],
|
|
[
|
|
'apps/web/src/lib/components/OrderPosterIdentity.svelte',
|
|
// cp406 — the identity row was extracted out of OrderCard into this
|
|
// shared component (used by OrderCard + the order-detail poster card).
|
|
// `profileHref` is the same STRING PROP as OrderCard's above: callers
|
|
// build it with localePath() — lp(`/@${account}`) — a SAFE_BUILDER that
|
|
// only prefixes single-slash internal paths, and account is a validated
|
|
// on-chain identifier. Same reviewer confirmation as OrderCard.
|
|
new Set(['profileHref'])
|
|
],
|
|
[
|
|
'apps/web/src/lib/components/TermsText.svelte',
|
|
// cp406 — TermsText renders a structured tree from parseTermsMarkdown().
|
|
// The only anchors it emits are `link` runs, and a `link` run's `href`
|
|
// (r.href) comes from one of two SAFE_BUILDERS baked into the parse tree
|
|
// in termsMarkdown.ts (both already in SAFE_BUILDER_NAMES): (1)
|
|
// safeBlurtImageUrl(seg.value) — https + exact host img.blurt.blog +
|
|
// image extension + no userinfo/odd port — for auto-linked Blurt images,
|
|
// and (2) safeContactUrl(m[2]) — scheme allowlist (https/http/mailto/
|
|
// matrix/xmpp/nostr), REFUSES javascript:/data:/vbscript:/file: — for
|
|
// explicit `[text](url)` links (cp414); an unsafe scheme is left as inert
|
|
// literal text, never a link. The static tracer can't follow the safe URL
|
|
// through the parse-tree run type, but it's confirmed safe — there is NO
|
|
// {@html} and no other href source. (Locked by blurt-image-link-safety-smoke.)
|
|
new Set(['r.href'])
|
|
],
|
|
[
|
|
'apps/web/src/routes/[lang]/explorer/account/[name=account]/+page.svelte',
|
|
// `txUrl ? lp(txUrl) : '#'` and `blockUrl ? lp(blockUrl) : '#'` —
|
|
// txUrl/blockUrl are `{@const}`s from morphitExplorerTxUrl(op.trxId) /
|
|
// morphitExplorerBlockUrl(op.block) (apps/web/src/lib/explorer/urls.ts).
|
|
// Both VALIDATE their input — trxId against /^[0-9a-fA-F]{40}$/
|
|
// (BLURT_TRXID_RE), block as a finite positive integer — and return a
|
|
// hardcoded INTERNAL path (`/explorer/tx/<hex>`, `/explorer/block/<n>`)
|
|
// or null. `lp()` is localePath() (a SAFE_BUILDER) which only prefixes
|
|
// single-slash internal paths; relative, external (`https://`) and
|
|
// protocol-relative (`//`) inputs pass through unchanged, so it can
|
|
// never synthesize a `javascript:` scheme. A null builder result
|
|
// falls back to the '#' literal. These are site-controlled URLs built
|
|
// from validated chain data — no operator/peer-controlled scheme is
|
|
// reachable. The static tracer can't follow morphitExplorer*Url →
|
|
// localePath through the {@const} + ternary, but a reviewer has
|
|
// confirmed safe.
|
|
new Set(["txUrl ? lp(txUrl) : '#'", "blockUrl ? lp(blockUrl) : '#'"])
|
|
],
|
|
[
|
|
'apps/web/src/routes/[lang]/explorer/block/[num=blocknum]/+page.svelte',
|
|
// `prevUrl ? lp(prevUrl) : '#'` and `txUrl ? lp(txUrl) : '#'` — same safe
|
|
// pattern as the account page above. prevUrl is a `{@const}` from
|
|
// morphitExplorerBlockUrl(blockNumber - 1) — validates a finite positive
|
|
// integer and returns the hardcoded internal `/explorer/block/<n>` or null
|
|
// (blockNumber 1 → arg 0 → null → '#', so no zero/negative link). txUrl is
|
|
// from morphitExplorerTxUrl(trxId) — validates BLURT_TRXID_RE, returns
|
|
// `/explorer/tx/<hex>` or null. Both wrapped in lp() (a SAFE_BUILDER that
|
|
// only prefixes single-slash internal paths) with a '#' fallback. Site-
|
|
// controlled internal paths built from validated chain data; no operator/
|
|
// peer-controlled scheme is reachable. (Added when the explorer-link lang-
|
|
// prefix fix wrapped these two `href`s in lp().)
|
|
new Set(["prevUrl ? lp(prevUrl) : '#'", "txUrl ? lp(txUrl) : '#'"])
|
|
],
|
|
[
|
|
'apps/web/src/routes/[lang]/explorer/tx/[id=trxid]/+page.svelte',
|
|
// `blockUrl ? lp(blockUrl) : '#'` — `{@const}` from
|
|
// morphitExplorerBlockUrl(tx.block_num) (validates a finite positive
|
|
// integer → hardcoded `/explorer/block/<n>` or null), wrapped in lp() with
|
|
// a '#' fallback. Same safe profile as the block/account entries above.
|
|
new Set(["blockUrl ? lp(blockUrl) : '#'"])
|
|
],
|
|
[
|
|
'apps/web/src/lib/components/RssFeedPicker.svelte',
|
|
// `href={urlFor(format)}` — urlFor returns
|
|
// `${location.origin}${base}.${EXT[format]}`. The origin is the
|
|
// browser's own origin (never operator/peer-controlled), `base`
|
|
// is a hardcoded/validated feed path supplied by the three call
|
|
// sites (e.g. '/rss/orderbook', or built from the route-matched
|
|
// asset enum / Blurt-account param), and EXT is xml|atom|json.
|
|
// No scheme injection is reachable — the scheme is always the
|
|
// current https origin. The <a> is only a graceful-fallback /
|
|
// middle-click target; the click handler copies to clipboard.
|
|
new Set(['urlFor(format)'])
|
|
],
|
|
[
|
|
'apps/web/src/routes/[lang]/+layout.svelte',
|
|
// `link.href` — from the `navLinks` array that maps over
|
|
// `[ { href: lp('/orderbook'), ... }, ... ]`. Each href
|
|
// is constructed via `lp()` (which calls localePath() —
|
|
// already in SAFE_BUILDER_NAMES above) so by the time the
|
|
// template reads `link.href`, the value is a locale-prefixed
|
|
// path string that the smoke can't trace back to lp() but
|
|
// a reviewer has confirmed safe. See Part 121 cp7 design
|
|
// doc.
|
|
new Set(['link.href'])
|
|
],
|
|
[
|
|
'apps/web/src/lib/components/ToastRegion.svelte',
|
|
// `toast.href` — validated at toast-creation time
|
|
// (apps/web/src/lib/stores/toast.ts: showToast scheme allowlist).
|
|
new Set(['toast.href'])
|
|
],
|
|
[
|
|
'apps/web/src/lib/components/Head.svelte',
|
|
// `canonical` and `alt.href` — computed locally from $page.url,
|
|
// never operator/peer-controlled. `canonical` is the result of
|
|
// `canonicalFor(resolvedPath)` (a SAFE_BUILDER already, but the
|
|
// smoke's call-detection only fires when the expression starts
|
|
// with `name(`; bare `canonical` looks like a plain identifier).
|
|
// `alt.href` is similar — `alt` is an element of
|
|
// `hreflangAlternates(resolvedPath)`'s output.
|
|
//
|
|
// cp114: `feed.href` allowlisted. The `feeds` prop on this
|
|
// component is only ever passed from site-controlled call sites
|
|
// (currently /[lang]/+page.svelte and /[lang]/orderbook/+page.svelte,
|
|
// both passing the literal `/rss/orderbook.xml` string). The prop
|
|
// is documented in the Head.svelte module-doc as taking only
|
|
// site-controlled feed metadata. Any future call site that passes
|
|
// operator-/peer-published URLs into `feeds` would need to wrap
|
|
// them through `safeContactUrl()` first; this allowlist entry
|
|
// covers only the current site-controlled usage.
|
|
new Set(['canonical', 'alt.href', 'feed.href'])
|
|
],
|
|
[
|
|
'apps/web/src/routes/[lang]/+layout.svelte',
|
|
// `link.href` — local navLinks config in +layout.svelte; not
|
|
// operator/peer-published.
|
|
new Set(['link.href'])
|
|
],
|
|
[
|
|
'apps/web/src/routes/[lang]/download/+page.svelte',
|
|
// `m.url` — local MIRRORS config in /download: hardcoded `https://`
|
|
// repo/host URLs (Forgejo, GitHub, Codeberg, …), site-controlled.
|
|
// cp201's PWA-only rework replaced the old STORES grid (`store.url`)
|
|
// with this source-mirrors grid.
|
|
new Set(['m.url'])
|
|
],
|
|
[
|
|
'apps/web/src/lib/components/WriteBlockedReadOnly.svelte',
|
|
// `deepLink` — computed locally by a $derived.by that hardcodes
|
|
// every branch to a `web+morphit://...` literal (the protocol
|
|
// handler registered in manifest.webmanifest). The only
|
|
// dynamic substitution is `peer` and `orderPermlink`, both
|
|
// passed in through component props from internal call sites
|
|
// (orderbook, chat, settings, etc.) — never operator-/peer-
|
|
// controlled raw URLs. Both are URL-encoded via
|
|
// encodeURIComponent before insertion. See the deepLink
|
|
// $derived block in WriteBlockedReadOnly.svelte for details
|
|
// and the registration in apps/web/static/manifest.webmanifest
|
|
// for the protocol handler scope.
|
|
new Set(['deepLink'])
|
|
],
|
|
[
|
|
'apps/web/src/lib/components/PrioritiesSection.svelte',
|
|
// `faqHref(p.faqKey)` — every faqKey comes from the hardcoded
|
|
// `PRIORITIES` constant inside the component (7 entries, no
|
|
// user/operator/peer input). `faqHref` itself just calls
|
|
// localePath() (a SAFE_BUILDER) and concatenates a '#' anchor.
|
|
// The smoke can't trace the chain through the wrapper, but a
|
|
// reviewer has confirmed safe. See PrioritiesSection.svelte
|
|
// module-doc + the PRIORITIES const declaration.
|
|
new Set(['faqHref(p.faqKey)'])
|
|
],
|
|
[
|
|
'apps/web/src/lib/components/ChatMessage.svelte',
|
|
// cp121: `trackingUrl` is one of two values, both site-controlled:
|
|
// 1. buildTrackingUrl(carrierEntry.trackingUrlTemplate, sh.tracking)
|
|
// — where carrierEntry.trackingUrlTemplate comes from the
|
|
// hardcoded CARRIERS const in apps/web/src/lib/shipping/carriers.ts.
|
|
// The carrier-registry-invariants smoke (cp120) enforces that
|
|
// every template starts with `https://` and contains a single
|
|
// `{tracking}` placeholder; buildTrackingUrl URL-encodes the
|
|
// tracking number before substituting. No operator/peer input
|
|
// reaches the URL template — only the carrier KEY (validated
|
|
// against the bundled registry via CARRIERS_LOOKUP).
|
|
// 2. sh.customTrackingUrl — peer-controlled via the chat
|
|
// payload BUT validated through isValidCustomTrackingUrl()
|
|
// in apps/web/src/lib/chat/payload.ts, which (a) enforces
|
|
// starts-with-https://, (b) round-trips through new URL()
|
|
// to confirm well-formed, and (c) rejects any scheme other
|
|
// than https: (this rejects javascript:, data:, etc. —
|
|
// covered by S-8 in shipping-payload-roundtrip-smoke).
|
|
// Both the canonical and custom paths are scheme-locked to https://.
|
|
// Anchor element also carries rel="noopener noreferrer" to suppress
|
|
// referrer leak + Spectre-style window.opener attacks.
|
|
//
|
|
// cp410: `verifyUrl` is a `{@const}` from
|
|
// blurtWalletExplorerFallbackUrl('tx', p.txid) — the opt-in
|
|
// "Verify on block explorer" link on a BLURT funds-sent pill (lets a
|
|
// seller self-verify a payment without trusting the operator's
|
|
// indexer). blurtWalletExplorerFallbackUrl (a SAFE_BUILDER already in
|
|
// SAFE_BUILDER_NAMES) hardcodes the https://blocks.blurtwallet.com base
|
|
// and, for kind==='tx', VALIDATES the txid against BLURT_TRXID_RE
|
|
// (/^[0-9a-fA-F]{40}$/) before interpolating a lowercased copy —
|
|
// anything else returns null, and the surrounding `{#if verifyUrl}`
|
|
// gate means the anchor only renders for a valid trxid. Site-controlled,
|
|
// scheme-locked https, no operator/peer scheme reachable. The static
|
|
// tracer can't follow the builder through the {@const}; reviewer
|
|
// confirmed. Anchor also carries target="_blank" +
|
|
// rel="noopener noreferrer" (suppresses window.opener + referrer leak).
|
|
new Set(['trackingUrl', 'verifyUrl'])
|
|
],
|
|
[
|
|
'apps/web/src/routes/[lang]/settings/security/2fa/+page.svelte',
|
|
// `app.officialUrl` — comes from RECOMMENDED_AUTHENTICATOR_APPS,
|
|
// a hardcoded TypeScript constant in
|
|
// `apps/web/src/lib/auth/recommendedAuthenticatorApps.ts` that
|
|
// only Morphit maintainers can edit (not operators, not peers,
|
|
// not users). Every entry is statically curated under the
|
|
// strict open-source-only policy documented in ADR-0043. The
|
|
// `2fa-recommended-apps-coverage-smoke` validates that every
|
|
// officialUrl in the list starts with `https://` and is not
|
|
// localhost, providing a second structural check. Anchor
|
|
// elements also carry rel="noopener noreferrer" to suppress
|
|
// referrer leak + Spectre-style window.opener attacks.
|
|
new Set(['app.officialUrl'])
|
|
],
|
|
[
|
|
'apps/web/src/lib/components/ExplorerLink.svelte',
|
|
// cp167: primaryUrl and altUrl are both elements of the
|
|
// `urls` prop, which callers populate exclusively from
|
|
// `externalExplorerUrls(asset, txid)` in apps/web/src/lib/
|
|
// explorer/urls.ts. That builder:
|
|
// (a) validates the txid against the asset's regex
|
|
// (BTC_TXID_RE, XMR_TXID_RE, …) — returns [] on fail
|
|
// (b) routes both the operator-supplied template AND the
|
|
// bundled defaults through isValidChatLinkTemplate
|
|
// which rejects anything not starting with https://
|
|
// (c) substitutes the regex-validated txid into the
|
|
// validated template via substituteTxidIntoTemplate
|
|
// Bundled defaults are hardcoded https:// templates in
|
|
// urlsCore.ts; operator overrides are zod-validated at
|
|
// indexer config load AND defense-in-depth re-validated
|
|
// here. No peer-controllable string ever reaches a raw
|
|
// href. Both <a> tags also carry rel="noopener noreferrer"
|
|
// and target="_blank".
|
|
new Set(['primaryUrl', 'altUrl'])
|
|
]
|
|
]);
|
|
|
|
let scenarios = 0;
|
|
let failures = 0;
|
|
|
|
function scenario(name: string, fn: () => void): void {
|
|
scenarios++;
|
|
try {
|
|
fn();
|
|
console.log(` ✓ ${name}`);
|
|
} catch (err) {
|
|
failures++;
|
|
console.log(` ✗ ${name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
}
|
|
|
|
function* walk(dir: string): Generator<string> {
|
|
for (const entry of readdirSync(dir)) {
|
|
const full = path.join(dir, entry);
|
|
const stat = statSync(full);
|
|
if (stat.isDirectory()) {
|
|
yield* walk(full);
|
|
} else if (stat.isFile() && full.endsWith('.svelte')) {
|
|
yield full;
|
|
}
|
|
}
|
|
}
|
|
|
|
interface Hit {
|
|
readonly file: string;
|
|
readonly line: number;
|
|
readonly text: string;
|
|
}
|
|
|
|
function detectUnsafeHref(absPath: string): readonly Hit[] {
|
|
const src = readFileSync(absPath, 'utf8');
|
|
const hits: Hit[] = [];
|
|
const newlinePositions: number[] = [-1];
|
|
for (let i = 0; i < src.length; i++) {
|
|
if (src[i] === '\n') newlinePositions.push(i);
|
|
}
|
|
function lineOf(offset: number): number {
|
|
let lo = 0,
|
|
hi = newlinePositions.length - 1;
|
|
while (lo < hi) {
|
|
const mid = (lo + hi + 1) >> 1;
|
|
if (newlinePositions[mid] < offset) lo = mid;
|
|
else hi = mid - 1;
|
|
}
|
|
return lo + 1;
|
|
}
|
|
|
|
// Match: `href={EXPR}` where EXPR may itself contain template-
|
|
// literal interpolations like `${foo}` (whose internal `}`
|
|
// would close the outer `{` if we used a naive regex).
|
|
//
|
|
// Walk character-by-character tracking brace depth from the
|
|
// `href={` opener; capture from the inner brace's open until
|
|
// its balanced close. `${` increments depth, `}` decrements.
|
|
// Backticks aren't relevant — only braces matter for balance.
|
|
function* hrefBindings(src: string): Iterable<{ index: number; expr: string }> {
|
|
const opener = /href=\{/g;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = opener.exec(src)) !== null) {
|
|
const innerStart = m.index + m[0].length;
|
|
let depth = 1;
|
|
let i = innerStart;
|
|
while (i < src.length && depth > 0) {
|
|
const ch = src[i];
|
|
const next = src[i + 1];
|
|
if (ch === '$' && next === '{') {
|
|
depth++;
|
|
i += 2;
|
|
continue;
|
|
}
|
|
if (ch === '{') {
|
|
depth++;
|
|
} else if (ch === '}') {
|
|
depth--;
|
|
if (depth === 0) break;
|
|
}
|
|
i++;
|
|
}
|
|
if (depth === 0) {
|
|
yield { index: m.index, expr: src.slice(innerStart, i) };
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const { index, expr: rawExpr } of hrefBindings(src)) {
|
|
const expr = rawExpr.trim();
|
|
|
|
// Skip `'#'`, `"#"`, '`#`'
|
|
if (expr === '"#"' || expr === "'#'" || expr === '`#`') continue;
|
|
|
|
// Skip string literals starting with `/` or `https://` or `http://`
|
|
// (route literal or absolute URL).
|
|
if (/^['"`]\//.test(expr)) continue;
|
|
if (/^['"`]https?:\/\//.test(expr)) continue;
|
|
|
|
// Skip template literals starting with `/` or `${...}/`
|
|
if (/^`\//.test(expr)) continue;
|
|
if (/^`https?:\/\//.test(expr)) continue;
|
|
// Template literals with leading interpolation that points to a
|
|
// known internal path: `${root}/foo` — too permissive to detect
|
|
// without parsing. Allowlist if needed.
|
|
|
|
// Skip known-safe builder calls. Either at the start of expr
|
|
// or as the leftmost identifier in a `?? '#'` / `|| '#'` chain.
|
|
const leftmostName = expr.match(/^([\w$]+)\s*\(/)?.[1];
|
|
if (leftmostName && SAFE_BUILDER_NAMES.includes(leftmostName)) continue;
|
|
|
|
// Skip if expr uses one of the safe-* helpers anywhere as the
|
|
// outer call (handles `safeContactUrl(x)` ?? null patterns).
|
|
// Match any safe* / *Url builder at the head of the expr.
|
|
if (/^\bsafe[A-Z]\w*\b/.test(expr)) continue;
|
|
if (/^\b\w+Url\(/.test(expr)) continue;
|
|
|
|
// Skip identifiers (no parens, no operators) whose name starts
|
|
// with `validated` — the project convention is that a
|
|
// `validatedXxx` identifier is the result of an upstream
|
|
// `validateXxxForRender()`-style validator that returns
|
|
// string-or-null. Same rationale as the safe* prefix
|
|
// recognition below for template literals: lets call sites
|
|
// stay readable without forcing a wrapping function call.
|
|
// Catches: `validatedNostrUrl`, `validatedStreamingUrl`,
|
|
// `validatedWebsiteUrl`, `validatedContactUrl`, etc. Part 74.
|
|
if (/^validated[A-Z]\w*$/.test(expr)) continue;
|
|
|
|
// Skip template literals whose leading interpolation is a
|
|
// pre-validated `safeXxx`-named identifier. Pattern:
|
|
// `${safeOther}/@account/permlink` — the safe* prefix is the
|
|
// project's convention for "this value already passed
|
|
// validation." Allowing this here avoids forcing the
|
|
// alternative pattern (compute the full URL up-front and
|
|
// pass through SAFE_BUILDER_NAMES) every time, which often
|
|
// makes the call site less readable. The smoke catches a
|
|
// real regression if a `${operatorOrigin}/...` (no safe-
|
|
// prefix) form ever lands.
|
|
// Part 70: extend to recognize this convention.
|
|
if (/^`\$\{\s*safe[A-Z]\w*\s*\}/.test(expr)) continue;
|
|
|
|
// Skip `BUILDER(...) ?? FALLBACK` and `BUILDER(...) || FALLBACK`
|
|
// patterns where BUILDER is one of the SAFE_BUILDER_NAMES and
|
|
// FALLBACK is a string literal (commonly `'#'`).
|
|
// Example: `href={explorerLinkForTxid(p.method, p.txid) ?? '#'}`.
|
|
// The builder returns string|null; null falls to the literal,
|
|
// which is safe.
|
|
const safeFallbackRe = new RegExp(
|
|
`^(${SAFE_BUILDER_NAMES.join('|')})\\s*\\([^)]*\\)\\s*(?:\\?\\?|\\|\\|)\\s*['"\`][^'"\`]*['"\`]\\s*$`
|
|
);
|
|
if (safeFallbackRe.test(expr)) continue;
|
|
|
|
// Skip ternaries where BOTH branches are string literals.
|
|
// Example: `href={isExternal ? 'https://...' : '/internal'}`.
|
|
// Both are static; safe.
|
|
const bothLiteralTernaryRe = /^[^?]+\?\s*['"`][^'"`]*['"`]\s*:\s*['"`][^'"`]*['"`]\s*$/;
|
|
if (bothLiteralTernaryRe.test(expr)) continue;
|
|
|
|
const lineNum = lineOf(index);
|
|
const relPath = path.relative(REPO_ROOT, absPath);
|
|
const allowedExprs = ALLOWLIST_HREF_EXPR.get(relPath);
|
|
if (allowedExprs && allowedExprs.has(expr)) continue;
|
|
|
|
const preview = `href={${expr}}`.slice(0, 120);
|
|
hits.push({ file: relPath, line: lineNum, text: preview });
|
|
}
|
|
return hits;
|
|
}
|
|
|
|
console.log('\n── href-xss smoke ────────────────────────────────────────\n');
|
|
|
|
scenario('apps/web/src/routes + lib/components: no operator-controlled raw href', () => {
|
|
const allHits: Hit[] = [];
|
|
for (const dir of SCAN_DIRS) {
|
|
for (const file of walk(dir)) {
|
|
const rel = path.relative(REPO_ROOT, file);
|
|
if (EXCLUDE_PATH_PATTERNS.some((rx) => rx.test(rel))) continue;
|
|
allHits.push(...detectUnsafeHref(file));
|
|
}
|
|
}
|
|
if (allHits.length > 0) {
|
|
const sample = allHits
|
|
.map((h) => `\n ${h.file}:${h.line}: ${JSON.stringify(h.text)}`)
|
|
.join('');
|
|
throw new Error(
|
|
`found ${allHits.length} potentially-unsafe href binding(s). ` +
|
|
'Wrap operator/peer-controlled URLs in `safeContactUrl()` or ' +
|
|
'`safeInstanceOrigin()` from `$lib/utils/safeContactUrl`. ' +
|
|
'For confirmed-safe site-controlled URLs, add an entry to ' +
|
|
'the `ALLOWLIST_HREF_EXPR` map in this smoke (keyed by file ' +
|
|
'path → set of exact expression strings, e.g. `link.href`). ' +
|
|
`Hits:${sample}`
|
|
);
|
|
}
|
|
});
|
|
|
|
console.log(`\n${'─'.repeat(54)}`);
|
|
if (failures === 0) {
|
|
console.log(`✓ all ${scenarios} scenarios passed`);
|
|
process.exit(0);
|
|
} else {
|
|
console.log(`✗ ${failures}/${scenarios} scenarios failed`);
|
|
process.exit(1);
|
|
}
|