Skip to content
File

Blob: src/worker/services/url.ts

typescript82 lines
1// HTTP URL validation for client metadata and launcher links:
2// `https:` is allowed for any host; `http:` is allowed only for loopback
3// hosts (`localhost`, RFC 6761 `.localhost` names, `127.0.0.0/8`,
4// `::1`). OAuth redirect URIs additionally follow Better Auth's stricter
5// application-type policy; HTTP redirects require an exact loopback host.
6//
7// Used for OAuth client_uri validation (admin client create) and the
8// launcher's display URL. `javascript:`, `data:`, `file:` are rejected
9// out of hand.
10const normalizeLoopbackHost = (hostname: string): string => {
11 let host = hostname.trim().toLowerCase();
12 if (host.startsWith("[")) {
13 const end = host.indexOf("]");
14 if (end !== -1) {
15 host = host.slice(1, end);
16 }
17 } else {
18 const firstColon = host.indexOf(":");
19 if (firstColon !== -1 && host.indexOf(":", firstColon + 1) === -1) {
20 host = host.slice(0, firstColon);
21 }
22 }
23 return host.replace(/\.+$/, "");
24};
25 
26const isLoopbackIpv4Host = (host: string): boolean => {
27 const octets = host.split(".");
28 if (octets.length !== 4) return false;
29 const numbers = octets.map((octet) => {
30 if (!/^\d{1,3}$/.test(octet)) return Number.NaN;
31 return Number(octet);
32 });
33 return numbers[0] === 127 && numbers.every((n) => Number.isInteger(n) && n >= 0 && n <= 255);
34};
35 
36export const isLoopbackHost = (hostname: string): boolean => {
37 const host = normalizeLoopbackHost(hostname);
38 return host === "localhost" || host.endsWith(".localhost") || host === "::1" || isLoopbackIpv4Host(host);
39};
40 
41export const isSafeHttpUrl = (value: string): boolean => {
42 let url: URL;
43 try {
44 url = new URL(value);
45 } catch {
46 return false;
47 }
48 if (url.protocol === "https:") return true;
49 if (url.protocol === "http:") return isLoopbackHost(url.hostname);
50 return false;
51};
52 
53// Strip trailing slashes from a request pathname so an exact-match
54// route check matches both `/foo` and `/foo/`. Better Auth's router
55// internally normalizes trailing slashes (`normalizePathname`), so a
56// Hono-level exact-match block on `/api/auth/<path>` would otherwise
57// miss the trailing-slash variant and let it fall through to the
58// catchall, which Better Auth would then dispatch to the same plugin
59// endpoint. Falls back to the original path when the input is just
60// slashes so the comparison key is never the empty string.
61export const stripTrailingSlash = (path: string): string => path.replace(/\/+$/, "") || path;
62 
63// Local-path validator for callback URLs forwarded to Better Auth from
64// tessera's social sign-in wrapper. Mirrors the client-side
65// `normalizeLocalPath` semantics so a non-React caller cannot smuggle an
66// absolute or protocol-relative URL through the worker boundary. Pure
67// and origin-independent: a placeholder origin is used to detect any
68// URL parse that escapes the local path namespace.
69export const isSafeLocalPath = (value: string): boolean => {
70 if (typeof value !== "string") return false;
71 if (value.length === 0) return false;
72 if (!value.startsWith("/") || value.startsWith("//") || value.includes("\\")) return false;
73 const placeholder = "https://placeholder.invalid";
74 let parsed: URL;
75 try {
76 parsed = new URL(value, placeholder);
77 } catch {
78 return false;
79 }
80 return parsed.origin === placeholder;
81};