Audit of 0.8.4 found the repo's own gates green, so these came from manual
review; each is covered by a new regression test.
security/fetch.ts
- safeFetch re-attached Authorization/Cookie on a same-origin redirect that
followed a cross-origin hop (a -> b -> b), handing credentials to the second
host. Compare against the origin the caller trusted, not the previous hop.
- The private-network guard resolved the host, approved it, then let fetch
resolve again, so a low-TTL record could answer public for the check and
private for the connection. Pin the connection to the validated address,
preserving Host and TLS serverName. Opt out with pinDns: false.
- 0:0:0:0:0:ffff:127.0.0.1, ::ffff:7f00:1 and fec0::1 were not treated as
private. Add uncompressed IPv4-mapped forms, site-local IPv6, 198.18/15
and 192.0.0/24.
security/url.ts
- sanitizeUrl returned "//evil.com" verbatim via the relative-path fast path,
bypassing the host checks it had just run; in an href that navigates
cross-origin. Resolve protocol-relative input instead.
dev-server/gateway.ts
- Malformed base64 in an Authorization header threw out of checkAuth on an
unauthenticated path. Fail closed.
- split(":", 2) truncated passwords at the first colon, so a password
containing ":" could never authenticate.
- The credential compare short-circuited on length mismatch, leaking length
by timing. Extracted as verifyBasicAuth so it is testable.
authz/index.ts
- Namespace wildcards only matched the first segment, so "post:comment:*"
did not grant "post:comment:delete". Match at every depth.
uploader/operations.ts
- Validate transcoder dimensions and bitrate rather than trusting the declared
type, and reject ".." path segments.
package.json
- The brace-expansion override pinned 5.0.8, which is inside the advisory
range >=4.0.0 <5.0.9. Bump to 5.0.9; bun audit is now clean.
Verified: check:production passes (typecheck, lint, 1033 tests, format,
ASVS, public-API baseline, editor checks).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
115 lines
3.6 KiB
TypeScript
115 lines
3.6 KiB
TypeScript
import { SecurityError } from "./errors.ts";
|
|
|
|
export interface SafeUrlPolicy {
|
|
base?: string | URL;
|
|
allowRelative?: boolean;
|
|
allowedProtocols?: string[];
|
|
allowedHosts?: string[];
|
|
blockedHosts?: string[];
|
|
allowCredentials?: boolean;
|
|
allowDataImages?: boolean;
|
|
}
|
|
|
|
const DEFAULT_PROTOCOLS = ["http:", "https:"];
|
|
|
|
const RELATIVE_PREFIX = /^(?:\.{0,2}\/|\/|\?|#)/;
|
|
|
|
/**
|
|
* `//evil.com` (and the `/\evil.com` spelling browsers normalise to it) reads
|
|
* like a same-site path but navigates cross-origin. It must never be handed
|
|
* back verbatim, or the host checks above are bypassed entirely.
|
|
*/
|
|
function isProtocolRelative(raw: string): boolean {
|
|
return /^[/\\]{2}/.test(raw);
|
|
}
|
|
|
|
function hasAsciiControlOrSpace(value: string): boolean {
|
|
for (const character of value) {
|
|
const code = character.charCodeAt(0);
|
|
if (code <= 0x20 || code === 0x7f) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function hostnameMatches(hostname: string, rule: string): boolean {
|
|
const normalized = rule.toLowerCase().replace(/\.$/, "");
|
|
const host = hostname.toLowerCase().replace(/\.$/, "");
|
|
if (normalized.startsWith("*.")) {
|
|
const suffix = normalized.slice(1);
|
|
return host.endsWith(suffix) && host.length > suffix.length;
|
|
}
|
|
return host === normalized;
|
|
}
|
|
|
|
export function validateUrl(value: string | URL, policy: SafeUrlPolicy = {}): URL {
|
|
const raw = String(value);
|
|
if (!raw || hasAsciiControlOrSpace(raw)) {
|
|
throw new SecurityError(
|
|
"WRN-SEC-URL-CONTROL",
|
|
"URL contains whitespace or control characters.",
|
|
);
|
|
}
|
|
|
|
const isRelative = RELATIVE_PREFIX.test(raw);
|
|
if (isRelative && policy.allowRelative === false) {
|
|
throw new SecurityError("WRN-SEC-URL-RELATIVE", "Relative URLs are not allowed.");
|
|
}
|
|
|
|
let url: URL;
|
|
try {
|
|
url = new URL(raw, policy.base ?? "http://wrnexus.invalid");
|
|
} catch (error) {
|
|
throw new SecurityError("WRN-SEC-URL-INVALID", "Invalid URL.", 400, { cause: error });
|
|
}
|
|
|
|
if (url.protocol === "data:") {
|
|
if (policy.allowDataImages && /^data:image\/(?:png|gif|jpeg|webp|avif);/i.test(raw)) return url;
|
|
throw new SecurityError("WRN-SEC-URL-DATA", "Data URLs are not allowed by this policy.");
|
|
}
|
|
|
|
const protocols = policy.allowedProtocols ?? DEFAULT_PROTOCOLS;
|
|
if (!protocols.includes(url.protocol)) {
|
|
throw new SecurityError(
|
|
"WRN-SEC-URL-PROTOCOL",
|
|
`URL protocol '${url.protocol}' is not allowed.`,
|
|
);
|
|
}
|
|
if (!policy.allowCredentials && (url.username || url.password)) {
|
|
throw new SecurityError("WRN-SEC-URL-CREDENTIALS", "Credentials in URLs are not allowed.");
|
|
}
|
|
|
|
if (policy.blockedHosts?.some((rule) => hostnameMatches(url.hostname, rule))) {
|
|
throw new SecurityError("WRN-SEC-URL-BLOCKED-HOST", `Host '${url.hostname}' is blocked.`);
|
|
}
|
|
if (
|
|
policy.allowedHosts?.length &&
|
|
!policy.allowedHosts.some((rule) => hostnameMatches(url.hostname, rule))
|
|
) {
|
|
throw new SecurityError("WRN-SEC-URL-HOST", `Host '${url.hostname}' is not allowlisted.`);
|
|
}
|
|
|
|
return url;
|
|
}
|
|
|
|
export function isSafeUrl(value: string | URL, policy: SafeUrlPolicy = {}): boolean {
|
|
try {
|
|
validateUrl(value, policy);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function sanitizeUrl(value: unknown, policy: SafeUrlPolicy = {}): string {
|
|
try {
|
|
const raw = String(value ?? "");
|
|
const url = validateUrl(raw, policy);
|
|
// Genuinely relative input round-trips unchanged; protocol-relative input
|
|
// is resolved so the returned string carries the host the policy approved.
|
|
if (RELATIVE_PREFIX.test(raw) && !isProtocolRelative(raw)) return raw;
|
|
return url.toString();
|
|
} catch {
|
|
return "about:blank";
|
|
}
|
|
}
|