safe-url
Guards for user-controllable navigation targets: two pure functions, safeExternalUrl / safeInternalPath (auto-imported). Before assigning any user-controllable value (URL query / backend pass-through field / location.hash) to location.href / location.assign / window.open / navigateTo, it must go through a guard — this is the official implementation of the standards client-security hard rule "user-controllable navigation targets must pass a scheme whitelist", so projects no longer hand-copy the same regex.
The two guards are complementary; pick by "where the target should go":
| Scenario | Use | Why |
|---|---|---|
External-link interstitial page (?url= countdown redirect), backend pass-through external jump fields | safeExternalUrl | The target is an off-site http(s) absolute URL by design |
Post-login ?redirect= return, back-to-referrer | safeInternalPath | The target must stay on-site; absolute external links get blocked too |
Both are pure functions with no IO — callable in SSR and CSR alike.
API
| Function | Signature | Description |
|---|---|---|
safeExternalUrl | (raw: unknown, fallback = '/') => string | Only allows http:// / https:// absolute URLs (case-insensitive, checked after trim); everything else falls back to fallback |
safeInternalPath | (raw: unknown, fallback = '/') => string | Only allows on-site absolute paths starting with a single /; everything else falls back to fallback |
safeExternalUrl
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
raw | unknown | — | The navigation target to validate (usually from route.query, type unknown — could be an array or undefined) |
fallback | string | '/' | The fallback address when validation fails |
Return Value
string — the trimmed original value when validation passes; fallback otherwise.
Validation Rules
Passes only when the trimmed value matches ^https?:// case-insensitively; everything below falls back to fallback:
- Dangerous schemes:
javascript:/data:/vbscript:etc. (assigning them tolocation.hrefexecutes them → DOM XSS) - Protocol-relative URLs (
//evil.com), relative paths - Empty values, non-strings (e.g. query arrays)
Examples
// Example 1: external-link interstitial page (?url= is fully user-controllable)
const url = safeExternalUrl(route.query.url)
location.href = url
// Example 2: custom fallback address
window.open(safeExternalUrl(raw, '/404'))
// Example 3: backend pass-through jump fields must be guarded too
location.href = safeExternalUrl(res.jump_url)
// Invalid inputs always fall back (default: home '/')
safeExternalUrl('javascript:alert(1)') // → '/' (dangerous scheme)
safeExternalUrl('//evil.com') // → '/' (protocol-relative)
safeExternalUrl('foo/bar') // → '/' (relative path)
safeExternalUrl(undefined) // → '/' (empty / non-string)
safeExternalUrl(' HTTPS://example.com/a ') // → 'HTTPS://example.com/a' (trim + case-insensitive pass)
safeInternalPath
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
raw | unknown | — | The navigation target to validate (usually from route.query, type unknown) |
fallback | string | '/' | The fallback address when validation fails |
Return Value
string — the trimmed original value when validation passes; fallback otherwise.
Validation Rules
The trimmed value must start with a single / (regex /^\/(?![/\\])/) to pass, e.g. /mine, /movie/detail/1?tab=a; everything below falls back to fallback:
- Protocol-relative URLs (
//evil.com), backslash bypass (/\evil.com) → open redirect - Absolute external links (
https://evil.com), dangerous schemes - Relative / bare paths, empty values, non-strings (including query arrays)
Examples
// Example 1: post-login return (?redirect= is user-controllable and must stay on-site)
await navigateTo(safeInternalPath(route.query.redirect))
// Example 2: back to the referring page (referrer is user-controllable too), custom fallback to /mine
router.push(safeInternalPath(fromPath, '/mine'))
// Example 3: invalid inputs always fall back
safeInternalPath('//evil.com') // → '/' (protocol-relative)
safeInternalPath('/\\evil.com') // → '/' (backslash bypass)
safeInternalPath('https://evil.com') // → '/' (absolute external link — always blocked for on-site returns)
safeInternalPath(undefined) // → '/' (empty / non-string)
safeInternalPath('/movie/detail/1?tab=a') // → '/movie/detail/1?tab=a' (passes)
Attack Vectors
Targets of external-link interstitials, share returns, and post-login ?redirect= come 100% from parameters and are 100% attacker-constructible. Vectors the guards block:
| Attack vector | Malicious input example | Consequence without a guard | Guard behavior |
|---|---|---|---|
| Dangerous scheme | ?url=javascript:alert(1) | Assigning to location.href executes the script (DOM XSS); same for data: / vbscript: | Both functions fall back to fallback |
| Protocol-relative URL | ?url=//evil.com | Inherits the current protocol and jumps off-site (open redirect) | Both functions fall back to fallback |
| Backslash bypass | ?redirect=/\evil.com | Browsers normalize \ to /, making it equivalent to //evil.com — off-site jump | safeInternalPath falls back to fallback |
| Absolute external link in an on-site return | ?redirect=https://evil.com/phish | The user is taken to a phishing site after logging in | safeInternalPath falls back to fallback |
⚠️ Whitelisting is the right answer — don't patch with blacklists. Blacklist-style fixes like "prepend
https:when it starts with//" can't withstand encoding bypasses and new variants; both functions are whitelist checks (only explicitly legal shapes pass), everything else falls back.
Notes
- Both functions are auto-imported; you can also import explicitly:
import { safeExternalUrl, safeInternalPath } from '@swift-web/framework'. - Pure functions with no IO — callable in SSR and CSR alike (no
onMounted/IS_CLIENTguard needed). - The
fallbackvalue itself is not validated — pass a hard-coded safe constant (like'/'or'/404'), never another user-controllable value.