/** * link-validation.ts, the gate a link from an untrusted surface must pass * BEFORE anything opens it. * * Owner's framing: headers can be spoofed, so any domain we are about to click * has to be validated first. This is that validation, and it is written as a * gate rather than a helper: the only way a link out of a page or a mailbox * should reach a navigation is through `validateLinkTarget`, and the only way * a redirect chain should be walked is through `followValidatedRedirects`. * * Every rule below exists because a naive check fails to a specific real * attack, and each refusal names which: * * - **userinfo**. `https://accounts.google.com@evil.example/verify` parses * with host `evil.example` and reads as Google to a human. Hard reject, not * a warning, because the whole attack is that it looks fine. * - **scheme**. `javascript:` and `data:` execute; `http:` is * interceptable and a downgrade. Only `https:` is opened. * - **homograph**. `аccounts.google.com` with a Cyrillic `а` is a different * host that renders identically. Mixed script in a label is refused rather * than scored for similarity, a similarity threshold is a number an * attacker can sit just underneath. * - **registrable domain**. `google.com.evil.example`, `google-verify.example` * and `accounts-google.example` all pass at least one substring or * `endsWith` check. Comparison is on eTLD+1 (see public-suffix.ts). * - **redirects**. A link that lands on the right host and then 302s away is * the same attack one hop later, so EVERY hop is validated by these same * rules and any hop leaving the authorized domain refuses the chain. * - **shorteners**. By construction their host is not the service, so they can * never satisfy an exact registrable-domain match. Named as a refusal reason * rather than left to fail incidentally, because "why did this fail" matters * when a person has to finish the job by hand. * * Refusal is loud and carries both domains, because a refused verification link * is often something the owner must complete themselves. */ /** Hop ceiling for a redirect chain. Beyond this the chain is refused. */ export declare const MAX_REDIRECT_HOPS = 5; export type LinkRefusalReason = 'not-https' | 'contains-userinfo' | 'ip-literal-host' | 'non-standard-port' | 'malformed-url' | 'mixed-script-host' | 'known-redirector' | 'domain-mismatch' | 'redirect-left-domain' | 'too-many-redirects'; export interface LinkAccepted { readonly ok: true; /** The normalized, safe-to-open URL. */ readonly url: string; readonly host: string; readonly registrableDomain: string; } export interface LinkRefused { readonly ok: false; readonly reason: LinkRefusalReason; /** Plain-language explanation naming both domains where relevant. */ readonly message: string; /** The domain the link actually resolves to, when one could be determined. */ readonly actualDomain: string | null; /** The domain the caller authorized. */ readonly expectedDomain: string; } export type LinkValidation = LinkAccepted | LinkRefused; /** Normalize a host for comparison: lowercase, no trailing dot, NFKC. */ export declare function normalizeHost(host: string): string; /** * Validate one link against the domain a caller authorized. * * `authorizedDomain` is the domain the agent is actually transacting with, * the service it signed up at, or the service it is logging in to. Comparison * is on the registrable domain of both, so a subdomain of the authorized * domain passes and a lookalike does not. */ export declare function validateLinkTarget(rawUrl: string, authorizedDomain: string): LinkValidation; /** A single HEAD/GET that reports only what redirect following needs. */ export interface RedirectProbe { (url: string): Promise<{ readonly status: number; readonly location: string | null; }>; } export interface RedirectChainResult { readonly ok: boolean; /** Every url visited, in order, all of them validated. */ readonly chain: readonly string[]; /** The final validated url, when the whole chain stayed in-domain. */ readonly finalUrl: string | null; readonly refusal: LinkRefused | null; } /** * Walk a redirect chain, validating EVERY hop by the same rules. * * The starting url is validated first, so a caller cannot skip the gate by * entering here. A hop that leaves the authorized registrable domain refuses * the whole chain rather than the hop, because the caller's question is "may I * open this link", and the honest answer for a link that ends up elsewhere is * no. */ export declare function followValidatedRedirects(rawUrl: string, authorizedDomain: string, probe: RedirectProbe, maxHops?: number): Promise; //# sourceMappingURL=link-validation.d.ts.map