|
| 1 | +// Legacy-iframe URL validation (#665). |
| 2 | +// |
| 3 | +// The form-spec endpoint can return `{renderer: "legacy-iframe", legacy_url}` |
| 4 | +// to embed the legacy admin change/add page inside the SPA shell (#659). That |
| 5 | +// URL flows into both an `<iframe src>` and an `<a href target=_blank>`, so it |
| 6 | +// reaches two navigational sinks. The SPA *should* be able to trust the API, |
| 7 | +// but every other navigational sink in this codebase validates before using a |
| 8 | +// server-supplied URL (`action-redirect.ts` parses + origin/mount-checks the |
| 9 | +// action redirect; `views.py` percent-encodes `next`). This is the one |
| 10 | +// server-emitted URL that previously reached `src`/`href` unchecked — a |
| 11 | +// compromised, buggy, or request-influenced backend could emit a |
| 12 | +// `javascript:` URL (executes from the anchor on click) or an off-origin |
| 13 | +// `http://attacker/` URL (renders attacker content inside the authenticated |
| 14 | +// admin chrome — a high-fidelity phishing surface). We close that with the |
| 15 | +// same parse-and-validate discipline used elsewhere. |
| 16 | +// |
| 17 | +// The legacy admin lives on the SAME origin as the SPA (under its own admin |
| 18 | +// prefix, NOT the SPA mount), so the rule is: parse against the current |
| 19 | +// origin, require an `http:`/`https:` scheme, and require the parsed origin to |
| 20 | +// equal the current origin. Anything else (a non-http(s) scheme like |
| 21 | +// `javascript:`/`data:`/`blob:`, or a cross-origin target) is rejected and the |
| 22 | +// caller renders an inert error card instead of framing/linking it. |
| 23 | + |
| 24 | +export interface ValidateLegacyUrlArgs { |
| 25 | + url: string; |
| 26 | + /** Current page origin. Defaults to `window.location.origin`; the test |
| 27 | + * injects a known value (jsdom's origin is fixed). */ |
| 28 | + currentOrigin?: string; |
| 29 | +} |
| 30 | + |
| 31 | +/** |
| 32 | + * Return a safe, same-origin http(s) URL string, or `null` when the URL is |
| 33 | + * unparseable, uses a non-http(s) scheme, or points off-origin. |
| 34 | + * |
| 35 | + * Returning the *re-serialised* parsed URL (not the raw input) means the value |
| 36 | + * handed to `src`/`href` is exactly what passed validation — no room for a |
| 37 | + * parser-differential between the check and the sink. |
| 38 | + */ |
| 39 | +export function safeLegacyUrl(args: ValidateLegacyUrlArgs): string | null { |
| 40 | + const origin = args.currentOrigin ?? window.location.origin; |
| 41 | + let parsed: URL; |
| 42 | + try { |
| 43 | + parsed = new URL(args.url, origin); |
| 44 | + } catch { |
| 45 | + return null; |
| 46 | + } |
| 47 | + // Reject `javascript:` / `data:` / `blob:` / `mailto:` etc. outright — only |
| 48 | + // real navigable web schemes are allowed in an iframe/anchor here. |
| 49 | + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null; |
| 50 | + // The legacy admin is same-origin; an off-origin target is never legitimate. |
| 51 | + if (parsed.origin !== origin) return null; |
| 52 | + return parsed.href; |
| 53 | +} |
0 commit comments