export type HeaderValue = string | string[] | null | undefined; export type HeaderMap = Record; const DAY_SECONDS = 86_400; const STRONG_HSTS_SECONDS = 31_536_000; export type HeaderControlId = "hsts" | "csp" | "nosniff" | "referrer-policy" | "frame-protection"; export type HeaderControlStatus = "strong" | "good" | "weak" | "missing" | "invalid" | "unavailable"; export type HeaderControlPosture = { id: HeaderControlId; status: HeaderControlStatus; reason: string; }; export type HeaderAggregate = { evidenceAvailable: boolean; healthy: number; attention: number; unavailable: number; total: number; label: string; }; function firstHeader(value: HeaderValue) { if (Array.isArray(value)) return value.join(", "); return typeof value === "string" ? value : null; } function directiveMap(value: string) { const directives = new Map(); const duplicate = new Set(); for (const raw of value.split(";")) { const part = raw.trim(); if (!part) continue; const [name, ...rawValue] = part.split("="); const normalizedName = name.trim().toLowerCase(); if (!/^[a-z][a-z0-9-]*$/.test(normalizedName)) return { directives, duplicate, invalid: true }; if (directives.has(normalizedName)) { duplicate.add(normalizedName); continue; } directives.set(normalizedName, rawValue.length ? rawValue.join("=").trim() : null); } return { directives, duplicate, invalid: false }; } export type HstsPosture = { status: "missing" | "invalid" | "disabled" | "weak" | "strong"; maxAge: number | null; includeSubDomains: boolean; preload: boolean; reason: string; }; export function parseHsts(value: HeaderValue): HstsPosture { const raw = firstHeader(value); if (!raw) return { status: "missing", maxAge: null, includeSubDomains: false, preload: false, reason: "Header is missing." }; const parsed = directiveMap(raw); const maxAgeRaw = parsed.directives.get("max-age"); const maxAge = maxAgeRaw !== null && maxAgeRaw !== undefined && /^\d+$/.test(maxAgeRaw) ? Number(maxAgeRaw) : null; const invalid = parsed.invalid || parsed.duplicate.has("max-age") || maxAge === null || !Number.isSafeInteger(maxAge) || (parsed.directives.has("includesubdomains") && parsed.directives.get("includesubdomains") !== null) || (parsed.directives.has("preload") && parsed.directives.get("preload") !== null); const includeSubDomains = parsed.directives.has("includesubdomains"); const preload = parsed.directives.has("preload"); if (invalid) return { status: "invalid", maxAge, includeSubDomains, preload, reason: "max-age must be a single non-negative integer." }; if (maxAge === 0) return { status: "disabled", maxAge, includeSubDomains, preload, reason: "max-age=0 disables HSTS." }; if (maxAge < DAY_SECONDS) return { status: "weak", maxAge, includeSubDomains, preload, reason: "The policy expires in less than one day." }; if (maxAge < STRONG_HSTS_SECONDS) return { status: "weak", maxAge, includeSubDomains, preload, reason: "The policy is shorter than the recommended one-year duration." }; return { status: "strong", maxAge, includeSubDomains, preload, reason: "Long-lived HSTS policy observed." }; } export type CspPosture = { status: "missing" | "invalid" | "weak" | "strong"; directives: Record; duplicates: string[]; wildcard: boolean; unsafeEval: boolean; unsafeInline: boolean; frameAncestors: string[]; reason: string; }; const HIGH_RISK_DIRECTIVES = new Set(["default-src", "script-src", "script-src-elem", "object-src", "base-uri"]); const BROAD_SOURCES = new Set(["*", "http:", "https:", "http://*", "https://*"]); function sourceIsBroad(source: string, directive: string) { if (BROAD_SOURCES.has(source)) return true; return HIGH_RISK_DIRECTIVES.has(directive) && (source === "data:" || source === "blob:"); } /** * Parses one CSP policy. Browser CSP keeps the first occurrence of a duplicate * directive, so later duplicates are retained only as configuration evidence. */ export function parseCsp(value: HeaderValue): CspPosture { const raw = firstHeader(value); if (!raw) return { status: "missing", directives: {}, duplicates: [], wildcard: false, unsafeEval: false, unsafeInline: false, frameAncestors: [], reason: "Header is missing." }; const directives: Record = {}; const duplicates = new Set(); let malformed = false; for (const rawDirective of raw.split(";")) { const tokens = rawDirective.trim().split(/\s+/).filter(Boolean); if (!tokens.length) continue; if (!/^[a-z][a-z0-9-]*$/i.test(tokens[0])) { malformed = true; continue; } const name = tokens.shift()!.toLowerCase(); if (name in directives) { duplicates.add(name); continue; } directives[name] = tokens.map((token) => token.toLowerCase()); } const effectiveScript = directives["script-src"] ?? directives["default-src"] ?? []; const effectiveScriptElement = directives["script-src-elem"] ?? effectiveScript; const unsafeEval = effectiveScript.includes("'unsafe-eval'") || effectiveScriptElement.includes("'unsafe-eval'"); const unsafeInline = effectiveScript.includes("'unsafe-inline'") || effectiveScriptElement.includes("'unsafe-inline'"); const broadDirectives = Object.entries(directives).filter(([name, sources]) => sources.some((source) => sourceIsBroad(source, name)), ); const wildcard = broadDirectives.length > 0; const frameAncestors = directives["frame-ancestors"] ?? []; const missingObjectSrc = !directives["object-src"]?.includes("'none'"); const missingBaseUri = !directives["base-uri"]?.includes("'self'") && !directives["base-uri"]?.includes("'none'"); const weak = wildcard || unsafeEval || unsafeInline || missingObjectSrc || missingBaseUri; const reason = malformed ? "The policy contains malformed directives." : unsafeEval ? "The effective script policy permits unsafe-eval." : unsafeInline ? "The effective script policy permits unsafe-inline." : wildcard ? "A high-impact directive permits broad sources." : missingObjectSrc ? "object-src 'none' is missing." : missingBaseUri ? "base-uri 'self' or 'none' is missing." : "Restrictive policy observed."; return { status: malformed ? "invalid" : weak ? "weak" : "strong", directives, duplicates: [...duplicates], wildcard, unsafeEval, unsafeInline, frameAncestors, reason }; } export type ReferrerPolicyPosture = HeaderControlPosture & { effective: string | null }; const STRONG_REFERRER_POLICIES = new Set(["no-referrer", "same-origin"]); const GOOD_REFERRER_POLICIES = new Set(["strict-origin", "strict-origin-when-cross-origin"]); const WEAK_REFERRER_POLICIES = new Set(["origin", "origin-when-cross-origin", "no-referrer-when-downgrade", "unsafe-url"]); export function parseReferrerPolicy(value: HeaderValue): ReferrerPolicyPosture { const raw = firstHeader(value); if (!raw?.trim()) return { id: "referrer-policy", status: "missing", effective: null, reason: "Header is missing." }; const policies = raw.split(",").map((policy) => policy.trim().toLowerCase()).filter(Boolean); const recognized = policies.filter((policy) => STRONG_REFERRER_POLICIES.has(policy) || GOOD_REFERRER_POLICIES.has(policy) || WEAK_REFERRER_POLICIES.has(policy)); const effective = recognized.at(-1) ?? null; if (!effective) return { id: "referrer-policy", status: "invalid", effective: null, reason: "No supported Referrer-Policy value was observed." }; if (STRONG_REFERRER_POLICIES.has(effective)) return { id: "referrer-policy", status: "strong", effective, reason: "The effective policy minimizes referrer disclosure." }; if (GOOD_REFERRER_POLICIES.has(effective)) return { id: "referrer-policy", status: "good", effective, reason: "The effective policy limits cross-origin referrer detail." }; return { id: "referrer-policy", status: "weak", effective, reason: effective === "unsafe-url" ? "The effective policy may disclose full URLs cross-origin." : "The effective policy permits more referrer detail than strict-origin policies." }; } /** Backwards-compatible concise status for rule callers. */ export function referrerPolicyStatus(value: HeaderValue) { return parseReferrerPolicy(value).status; } export type FrameProtectionPosture = HeaderControlPosture & { source: "csp" | "x-frame-options" | "none" }; function frameAncestorsPosture(csp: CspPosture): FrameProtectionPosture | null { if (!("frame-ancestors" in csp.directives)) return null; const sources = csp.frameAncestors; if (!sources.length) return { id: "frame-protection", status: "invalid", source: "csp", reason: "frame-ancestors has no usable source expression." }; if (sources.includes("*")) return { id: "frame-protection", status: "weak", source: "csp", reason: "frame-ancestors permits every embedding origin." }; if (sources.some((source) => source === "http:" || source === "https:" || source === "data:" || source === "blob:" || source.includes("*"))) { return { id: "frame-protection", status: "weak", source: "csp", reason: "frame-ancestors permits a broad scheme or wildcard origin." }; } if (sources.includes("'none'")) return sources.length === 1 ? { id: "frame-protection", status: "strong", source: "csp", reason: "frame-ancestors 'none' blocks framing." } : { id: "frame-protection", status: "invalid", source: "csp", reason: "frame-ancestors 'none' cannot be combined with other sources." }; if (sources.includes("'self'")) return { id: "frame-protection", status: "strong", source: "csp", reason: "frame-ancestors restricts framing to the same origin." }; const explicitOrigins = sources.every((source) => /^https?:\/\/[a-z0-9.-]+(?::\d+)?$/i.test(source)); return explicitOrigins ? { id: "frame-protection", status: "good", source: "csp", reason: "frame-ancestors lists explicit embedding origins." } : { id: "frame-protection", status: "invalid", source: "csp", reason: "frame-ancestors contains an unsupported source expression." }; } export function parseFrameProtection(headers: HeaderMap): FrameProtectionPosture { const csp = parseCsp(headers["content-security-policy"]); const cspPosture = frameAncestorsPosture(csp); if (cspPosture) return cspPosture; const xFrame = firstHeader(headers["x-frame-options"])?.trim().toLowerCase(); if (xFrame === "deny") return { id: "frame-protection", status: "strong", source: "x-frame-options", reason: "X-Frame-Options: DENY blocks framing." }; if (xFrame === "sameorigin") return { id: "frame-protection", status: "good", source: "x-frame-options", reason: "X-Frame-Options restricts framing to the same origin." }; if (xFrame) return { id: "frame-protection", status: "invalid", source: "x-frame-options", reason: "X-Frame-Options has an unsupported value." }; return { id: "frame-protection", status: "missing", source: "none", reason: "No frame-ancestors policy or X-Frame-Options protection was observed." }; } /** Backwards-compatible concise status for rule callers. */ export function frameProtectionStatus(headers: HeaderMap) { return parseFrameProtection(headers).status; } function hstsControl(hsts: HstsPosture): HeaderControlPosture { const status: HeaderControlStatus = hsts.status === "disabled" ? "weak" : hsts.status; return { id: "hsts", status, reason: hsts.reason }; } function nosniffControl(value: HeaderValue): HeaderControlPosture { const raw = firstHeader(value); if (!raw?.trim()) return { id: "nosniff", status: "missing", reason: "Header is missing." }; return raw.trim().toLowerCase() === "nosniff" ? { id: "nosniff", status: "strong", reason: "The required nosniff value was observed." } : { id: "nosniff", status: "invalid", reason: "The header does not use the required nosniff value." }; } export function isNosniff(value: HeaderValue) { return nosniffControl(value).status === "strong"; } function cspControl(csp: CspPosture): HeaderControlPosture { return { id: "csp", status: csp.status, reason: csp.reason }; } export function summarizeHeaderControls(controls: HeaderControlPosture[]): HeaderAggregate { const healthy = controls.filter((control) => control.status === "strong" || control.status === "good").length; const unavailable = controls.filter((control) => control.status === "unavailable").length; const attention = controls.length - healthy - unavailable; const evidenceAvailable = unavailable !== controls.length; const label = !evidenceAvailable ? "Evidence unavailable" : unavailable ? `${healthy} / ${controls.length - unavailable} controls healthy ยท evidence incomplete` : attention ? `${attention} control${attention === 1 ? " needs" : "s need"} attention` : `${healthy} / ${controls.length} controls healthy`; return { evidenceAvailable, healthy, attention, unavailable, total: controls.length, label }; } export function unavailableHeaderPosture() { const controls: HeaderControlPosture[] = ["hsts", "csp", "nosniff", "referrer-policy", "frame-protection"].map((id) => ({ id: id as HeaderControlId, status: "unavailable", reason: "No terminal HTTPS response was available for evaluation.", })); return { controls, aggregate: summarizeHeaderControls(controls) }; } export function analyzeHeaders(headers: HeaderMap) { const hsts = parseHsts(headers["strict-transport-security"]); const csp = parseCsp(headers["content-security-policy"]); const nosniff = nosniffControl(headers["x-content-type-options"]); const referrerPolicy = parseReferrerPolicy(headers["referrer-policy"]); const frameProtection = parseFrameProtection(headers); const controls = [hstsControl(hsts), cspControl(csp), nosniff, referrerPolicy, frameProtection]; return { hsts, csp, nosniff, referrerPolicy, frameProtection, controls, aggregate: summarizeHeaderControls(controls) }; }