import type { IncomingMessage, IncomingHttpHeaders } from "node:http"; import { mkdirSync, realpathSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import type { Tina4Request, UploadedFile } from "./types.js"; import { resolveClientIp } from "./trustedProxy.js"; /** * Wrap Node's `IncomingHttpHeaders` in a Proxy so mixed-case lookups * (`req.headers["Content-Type"]`) work alongside the canonical lowercase * form Node already provides. Parity with PY-10-03 (Python ships a * `CaseInsensitiveDict` for the same reason). * * The raw object is returned as-is by `Object.keys` / iteration — only * string property reads/`in` checks are normalised. */ export function makeCaseInsensitiveHeaders( raw: IncomingHttpHeaders, ): IncomingHttpHeaders { return new Proxy(raw, { get(target, prop, receiver) { if (typeof prop === "string") { const lower = prop.toLowerCase(); if (lower in target) return (target as Record)[lower]; return Reflect.get(target, prop, receiver); } return Reflect.get(target, prop, receiver); }, has(target, prop) { if (typeof prop === "string") { return prop.toLowerCase() in target || Reflect.has(target, prop); } return Reflect.has(target, prop); }, }) as IncomingHttpHeaders; } /** Strips `readonly` so construction code can write once; callers outside * this function still see the real (readonly) `Tina4Request` shape * (REQ-IMMUTABILITY-DIVERGE, 3.13.99). */ type Writable = { -readonly [K in keyof T]: T[K] }; export function createRequest(req: IncomingMessage): Tina4Request { const tReq = req as Tina4Request; // Construction-only mutable view of the same object — the core wire-derived // fields (path/queryString/url/ip/remoteIp/cookies/contentType/query) are // `readonly` on Tina4Request itself; this alias is how THIS function is // still allowed to set them exactly once, the same way PHP's constructor // writes its own `readonly` properties. const w = tReq as Writable; // Wrap `req.headers` so mixed-case lookups work — Node's underlying object // is already lower-cased, this just lets readers use any casing they like. (tReq as unknown as { headers: IncomingHttpHeaders }).headers = makeCaseInsensitiveHeaders(req.headers); // Resolve scheme + host honouring proxy headers — parity with PHP/Python/Ruby. const xfProto = req.headers["x-forwarded-proto"]; const proto = (Array.isArray(xfProto) ? xfProto[0] : xfProto) ?? ((req.socket as { encrypted?: boolean })?.encrypted ? "https" : "http"); const xfHost = req.headers["x-forwarded-host"]; const host = (Array.isArray(xfHost) ? xfHost[0] : xfHost) ?? (req.headers.host ?? "localhost"); // Parse the request-target into path + query. The WHATWG `URL` parser THROWS // `ERR_INVALID_URL` on malformed targets like `//`, `///`, and `/\` — and this // runs BEFORE the dispatch try/catch, with the uncaughtException net only wired // under TINA4_DEBUG, so an unguarded throw crashes the worker in production // (unauthenticated remote DoS — scanners send `//` routinely). Guard it: on a // parse failure, derive the path/query straight from the raw target so routing // proceeds to a normal 404 instead of taking the process down. (#33) const query: Record = {}; let path: string; let queryString: string; let fullUrl: string; try { const url = new URL(req.url ?? "/", `${proto}://${host}`); for (const [key, value] of url.searchParams) { query[key] = value; } path = url.pathname; queryString = url.search.replace(/^\?/, ""); fullUrl = url.toString(); } catch { const rawTarget = req.url ?? "/"; const questionIdx = rawTarget.indexOf("?"); path = questionIdx >= 0 ? rawTarget.slice(0, questionIdx) : rawTarget; queryString = questionIdx >= 0 ? rawTarget.slice(questionIdx + 1) : ""; try { for (const [key, value] of new URLSearchParams(queryString)) { query[key] = value; } } catch { /* unparseable query — leave query empty, still route the path */ } fullUrl = `${proto}://${host}${rawTarget}`; } tReq.params = {}; w.query = query; // Path, query string, and full URL — same shape across all four frameworks. // `path` is the URL path only; `queryString` is the raw query without "?". // `url` is overridden from Node's IncomingMessage.url (path+query) to the // full absolute URL — parity with PHP/Python/Ruby. w.path = path; w.queryString = queryString; w.url = fullUrl; tReq.body = undefined; tReq.files = {}; w.contentType = (req.headers["content-type"] ?? "") as string; // Parse cookies from Cookie header const cookieHeader = (req.headers.cookie ?? "") as string; const cookies: Record = {}; if (cookieHeader) { for (const pair of cookieHeader.split(";")) { const [k, ...v] = pair.trim().split("="); if (k) cookies[k.trim()] = v.join("=").trim(); } } w.cookies = cookies; // Raw socket peer — NEVER honours a forwarding header, so it can be trusted // for security decisions. Resolved BEFORE .ip: the peer decides whether the // forwarding headers may be believed at all (TINA4_TRUSTED_PROXIES, ADR-0019). w.remoteIp = req.socket?.remoteAddress ?? ""; w.ip = resolveClientIp(req.headers, tReq.remoteIp) || "127.0.0.1"; // Add convenience methods tReq.header = function (name: string): string | undefined { const val = req.headers[name.toLowerCase()]; if (Array.isArray(val)) return val[0]; return val; }; tReq.bearerToken = function (): string | null { const auth = tReq.header("authorization") ?? ""; if (auth.toLowerCase().startsWith("bearer ")) { return auth.slice(7); } return null; }; tReq.param = function (key: string, defaultValue?: string | number): string | number | undefined { return tReq.params[key] ?? tReq.query[key] ?? defaultValue; }; tReq.parseBody = function (): Promise { return parseBody(tReq); }; return tReq; } /** Maximum upload size in bytes (default 10 MB). Override via TINA4_MAX_UPLOAD_SIZE env var. */ const TINA4_MAX_UPLOAD_SIZE = parseInt(process.env.TINA4_MAX_UPLOAD_SIZE ?? "10485760", 10); export class PayloadTooLargeError extends Error { public statusCode = 413; constructor(actual: number, limit: number) { super(`Request body (${actual} bytes) exceeds TINA4_MAX_UPLOAD_SIZE (${limit} bytes)`); this.name = "PayloadTooLargeError"; } } async function parseBody(req: Tina4Request): Promise { const method = req.method?.toUpperCase(); if (method === "GET" || method === "HEAD" || method === "OPTIONS") return; // Check content-length header against upload size limit before reading body const declaredLength = parseInt(req.headers["content-length"] ?? "0", 10); if (declaredLength > TINA4_MAX_UPLOAD_SIZE) { throw new PayloadTooLargeError(declaredLength, TINA4_MAX_UPLOAD_SIZE); } const contentType = req.headers["content-type"] ?? ""; const chunks: Buffer[] = []; await new Promise((resolve, reject) => { // A RUNNING cap, checked per chunk. // // The content-length check above only sees what the client DECLARES. A // chunked request declares nothing, so declaredLength is 0 and it sails // through. Without this counter the body is buffered in full and only // then measured, which means the limit cannot prevent the thing it exists // to prevent. Measured against a 1MB limit: a 40MB chunked POST with no // content-length was accepted whole and grew the server's RSS by exactly // 40.0MB before it was refused. // // Same defect as PHP's unbounded read buffer, and the same fix: stop at // the limit instead of measuring the damage afterwards. let received = 0; let refused = false; req.on("data", (chunk: Buffer) => { if (refused) return; received += chunk.length; if (received > TINA4_MAX_UPLOAD_SIZE) { refused = true; chunks.length = 0; // drop what we have; the request is dead reject(new PayloadTooLargeError(received, TINA4_MAX_UPLOAD_SIZE)); return; } chunks.push(chunk); }); req.on("end", () => { if (!refused) resolve(); }); req.on("error", reject); }); const raw = Buffer.concat(chunks); if (raw.length === 0) return; if (contentType.includes("multipart/form-data")) { const boundary = extractBoundary(contentType); if (boundary) { const { fields, files } = parseMultipart(raw, boundary); req.body = fields; req.files = files; } else { req.body = raw.toString("utf-8"); } } else if (contentType.includes("application/json")) { const str = raw.toString("utf-8"); try { req.body = JSON.parse(str); } catch { req.body = str; } } else if (contentType.includes("application/x-www-form-urlencoded")) { const str = raw.toString("utf-8"); const params = new URLSearchParams(str); const obj: Record = {}; for (const [key, value] of params) { obj[key] = value; } req.body = obj; } else { req.body = raw.toString("utf-8"); } } /** * Extract the boundary string from a multipart content-type header. */ function extractBoundary(contentType: string): string | null { const match = contentType.match(/boundary=(?:"([^"]+)"|([^\s;]+))/); return match ? (match[1] ?? match[2]) : null; } /** * Parse multipart/form-data body into fields and files. * Zero-dependency implementation. */ export function parseMultipart( body: Buffer, boundary: string, ): { fields: Record; files: Record } { const fields: Record = {}; const files: Record = {}; const delimiter = Buffer.from(`--${boundary}`); const closeDelimiter = Buffer.from(`--${boundary}--`); const crlf = Buffer.from("\r\n"); const doubleCrlf = Buffer.from("\r\n\r\n"); let offset = 0; // Find first delimiter const firstIdx = bufferIndexOf(body, delimiter, offset); if (firstIdx === -1) return { fields, files }; offset = firstIdx + delimiter.length; // Skip CRLF after delimiter if (body[offset] === 0x0d && body[offset + 1] === 0x0a) { offset += 2; } while (offset < body.length) { // Find the end of headers (double CRLF) const headersEnd = bufferIndexOf(body, doubleCrlf, offset); if (headersEnd === -1) break; const headersStr = body.subarray(offset, headersEnd).toString("utf-8"); offset = headersEnd + doubleCrlf.length; // Find next delimiter const nextDelimIdx = bufferIndexOf(body, delimiter, offset); if (nextDelimIdx === -1) break; // Content data is between current offset and nextDelimIdx - CRLF const contentEnd = nextDelimIdx - crlf.length; const content = body.subarray(offset, contentEnd); // Parse headers const disposition = parseDisposition(headersStr); const partContentType = parsePartContentType(headersStr); if (disposition.filename) { // File upload — standardised format: filename, type, content (raw bytes), size const file: UploadedFile = { fieldName: disposition.name, filename: disposition.filename, type: partContentType ?? "application/octet-stream", content: Buffer.from(content), size: content.length, }; // Dict keyed by field name — multiple files under same name become array if (files[disposition.name]) { const existing = files[disposition.name]; files[disposition.name] = Array.isArray(existing) ? [...existing, file] : [existing, file]; } else { files[disposition.name] = file; } } else if (disposition.name) { // Regular field fields[disposition.name] = content.toString("utf-8"); } // Move past the delimiter offset = nextDelimIdx + delimiter.length; // Check for close delimiter if (body[offset] === 0x2d && body[offset + 1] === 0x2d) { // "--" means end of multipart break; } // Skip CRLF after delimiter if (body[offset] === 0x0d && body[offset + 1] === 0x0a) { offset += 2; } } return { fields, files }; } /** * Persist an uploaded file's content inside `targetDir` under a SAFE name. * * The client-supplied filename is untrusted. Directory components are stripped * (so `../../evil` or `/etc/passwd` becomes `evil` / `passwd`), a NUL byte or an * unusable name (`''`/`.`/`..`) is refused, and the resolved path is confined to * `targetDir` (realpath containment) so an upload can never write outside it. * * @param file an uploaded-file descriptor (`req.files[name]`) carrying `content`. * @param targetDir the directory to write into (created if missing). * @param filename an explicit name to use instead of the client filename. * @returns the absolute path written. * @throws when the derived name is unsafe or would escape `targetDir`. */ export function saveUpload(file: UploadedFile, targetDir: string, filename?: string): string { const raw = String(filename ?? file.filename ?? ""); if (raw.includes("\0")) throw new Error("upload filename contains a null byte"); // Reduce to a single path segment, handling BOTH separators so a Windows // "..\\..\\evil" cannot smuggle a directory part past a POSIX basename. const base = raw.replace(/\\/g, "/").split("/").pop() ?? ""; if (base === "" || base === "." || base === "..") { throw new Error(`upload filename is not a usable name: ${JSON.stringify(raw)}`); } mkdirSync(targetDir, { recursive: true }); const dest = join(targetDir, base); // Defence in depth: the resolved parent of the destination must be exactly // the resolved target dir (guards a pre-existing symlink at target/base). const realDir = realpathSync(targetDir); const realParent = realpathSync(dirname(dest)); if (realParent !== realDir) { throw new Error(`refusing to write outside ${targetDir}: ${JSON.stringify(raw)}`); } writeFileSync(dest, file.content); return dest; } function bufferIndexOf(haystack: Buffer, needle: Buffer, offset: number): number { for (let i = offset; i <= haystack.length - needle.length; i++) { let found = true; for (let j = 0; j < needle.length; j++) { if (haystack[i + j] !== needle[j]) { found = false; break; } } if (found) return i; } return -1; } function parseDisposition(headers: string): { name: string; filename?: string } { const nameMatch = headers.match(/name="([^"]+)"/); const filenameMatch = headers.match(/filename="([^"]+)"/); return { name: nameMatch?.[1] ?? "", filename: filenameMatch?.[1], }; } function parsePartContentType(headers: string): string | null { const match = headers.match(/Content-Type:\s*(.+?)(?:\r?\n|$)/i); return match?.[1]?.trim() ?? null; }