/** * Loopback trust fence for the notes API routes: socket address, Host header, * and browser same-origin markers. Notes may hold credentials and other * sensitive values, so the API must stay reachable only from the local * browser UI. X-Forwarded-For is never trusted. * @module dsh-web-notes/loopback */ import type { IncomingMessage } from 'node:http' /** IPv4 127/8 predicate (four decimal octets, first == 127). */ export function isIPv4Loopback(v4: string): boolean { const parts = v4.split('.') return parts.length === 4 && parts[0] === '127' && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255) } /** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */ export function isLoopbackAddress(address: string | undefined): boolean { if (address === undefined) return false const normalized = address.toLowerCase() if (normalized === '::1') return true if (normalized.startsWith('::ffff:')) return isIPv4Loopback(normalized.slice('::ffff:'.length)) return isIPv4Loopback(normalized) } /** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */ export function isLoopbackHostname(hostname: string): boolean { if (hostname === 'localhost' || hostname === '[::1]') return true return isIPv4Loopback(hostname) } /** * Request-level trust fence: a loopback socket address AND a loopback Host * header, plus browser same-origin markers. */ export function isLoopbackRequest(request: IncomingMessage): boolean { if (!isLoopbackAddress(request.socket.remoteAddress)) return false const host = request.headers.host if (typeof host !== 'string') return false let hostUrl: URL try { hostUrl = new URL('http://' + host) } catch { return false } if (!isLoopbackHostname(hostUrl.hostname)) return false if (request.headers['sec-fetch-site'] === 'cross-site') return false const origin = request.headers.origin if (origin === undefined) return true try { return new URL(origin).host === hostUrl.host } catch { return false } }