release: WRNexusJS 0.7.0
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
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:"];
|
||||
|
||||
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 = /^(?:\.{0,2}\/|\/|\?|#)/.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);
|
||||
if (/^(?:\.{0,2}\/|\/|\?|#)/.test(raw)) return raw;
|
||||
return url.toString();
|
||||
} catch {
|
||||
return "about:blank";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user