// Canonical URL and HTML-attribute safety helpers shared across server,
// React compatibility, and reactive DOM render paths.
/** Returns true for HTML attributes that require explicit unsafe-HTML opt-in handling. */
export function isDangerousHtmlAttribute(name: string): boolean {
return name.toLowerCase() === "srcdoc";
}
/** Reads an own string data property without invoking the payload's getter. */
export function readDangerousHtmlOptIn(value: unknown): string | undefined {
if (typeof value !== "object" || value === null) return undefined;
try {
const descriptor = Object.getOwnPropertyDescriptor(value, "__html");
return descriptor !== undefined && "value" in descriptor && typeof descriptor.value === "string"
? descriptor.value
: undefined;
} catch {
return undefined;
}
}
/** Returns whether a value carries an own string raw HTML data property. */
export function isDangerousHtmlOptIn(value: unknown): value is { __html: string } {
return readDangerousHtmlOptIn(value) !== undefined;
}
/** Returns true when an attribute name normally carries a single URL value. */
export function isUrlAttribute(name: string): boolean {
return /^(href|src|action|formaction|xlink:href|ping|poster|background|manifest|data|codebase)$/.test(
name.toLowerCase(),
);
}
/** Returns true when an attribute name carries a srcset-style URL list. */
export function isSrcsetAttribute(name: string): boolean {
const attributeName = name.toLowerCase();
return attributeName === "srcset" || attributeName === "imagesrcset";
}
/** Checks whether an HTML URL-bearing attribute value uses a blocked scheme. */
export function isUnsafeUrlAttribute(name: string, value: string): boolean {
const attributeName = name.toLowerCase();
if (isUrlAttribute(attributeName)) {
return isUnsafeUrlValueForName(attributeName, value);
}
if (isSrcsetAttribute(attributeName)) {
const canonical = canonicalizeUrlForSchemeCheck(value);
for (const candidate of canonical.split(",")) {
const url = candidate.trim().split(/\s+/)[0] ?? "";
if (url === "") continue;
if (isUnsafeUrlValueForName("src", url)) return true;
}
return false;
}
return false;
}
/** Returns the original URL attribute value when it is safe, otherwise undefined. */
export function safeUrlAttributeValue(name: string, value: string): string | undefined {
return isUnsafeUrlAttribute(name, value) ? undefined : value;
}
/** Checks whether a meta refresh content value redirects to an unsafe URL. */
export function isUnsafeMetaRefreshContent(httpEquiv: string, content: string): boolean {
if (httpEquiv.toLowerCase() !== "refresh") return false;
const match = /^[^;]*;\s*url\s*=\s*([\s\S]+)$/iu.exec(content);
if (match === null || match[1] === undefined) return false;
return isUnsafeUrlValueForName("href", stripSurroundingQuotes(match[1].trim()));
}
function stripSurroundingQuotes(value: string): string {
if (value.length < 2) return value;
const quote = value[0];
if ((quote === '"' || quote === "'") && value[value.length - 1] === quote) {
return value.slice(1, -1).trim();
}
return value;
}
function canonicalizeUrlForSchemeCheck(value: string): string {
let start = 0;
while (start < value.length && value.charCodeAt(start) <= 0x20) {
start += 1;
}
return value.slice(start).replace(/[\t\r\n]/g, "");
}
function schemeOf(value: string): string | undefined {
const match = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(value);
if (match === null || match[1] === undefined) return undefined;
return match[1].toLowerCase();
}
function isUnsafeUrlValueForName(name: string, value: string): boolean {
const canonical = canonicalizeUrlForSchemeCheck(value);
const scheme = schemeOf(canonical);
if (scheme === undefined) return false;
if (!/^(javascript|data|vbscript|livescript|mhtml|file)$/.test(scheme)) return false;
if (scheme === "data" && (name === "src" || name === "poster")) {
if (/^data:image\/(?!svg\+xml\s*(?:[;,]|$))/i.test(canonical)) return false;
}
return true;
}