import { MaxFileSizeExceededError, MaxHeaderSizeExceededError, MaxPartsExceededError, MaxTotalSizeExceededError, MultipartParseError, parseMultipart, } from "@remix-run/multipart-parser" import { parse as parseContentType } from "content-type" import { TextDecoder } from "node:util" import * as z from "zod/mini" const JSON_VALUE_SCHEMA = z.json() const TEXT_APPLICATION_MEDIA_TYPES = new Set([ "application/javascript", "application/json-seq", "application/ndjson", "application/sql", "application/toml", "application/x-ndjson", "application/x-yaml", "application/yaml", ]) const XML_DECLARATION_ENCODING = /^<\?xml\s+[^>]*?\bencoding\s*=\s*(["'])([a-z][a-z\d._-]*)\1/i export type JsonValue = z.infer export type HttpRequestFormValue = File | string export type HttpRequestForm = Record< string, HttpRequestFormValue | HttpRequestFormValue[] > export type HttpRequestBody = HttpRequestForm | JsonValue | Uint8Array export type HttpRequestBodyType = "bytes" | "empty" | "form" | "json" | "text" export type DecodedHttpRequestBody = { body: HttpRequestBody bodyType: HttpRequestBodyType } /** An HTTP request body that can't be decoded as its declared media type. */ export class HttpRequestBodyDecodeError extends Error { /** * Creates a request error with its public HTTP status. * * @param message - Safe response message. * @param status - HTTP status returned by ingress. * @param options - Optional underlying error details. */ constructor( message: string, public readonly status: 400 | 413 | 415, options?: ErrorOptions, ) { super(message, options) this.name = "HttpRequestBodyDecodeError" } } /** * Decodes HTTP request bytes according to the declared media type. * * @param rawBody - Exact request body bytes. * @param contentType - Declared Content-Type header, when present. * @throws {HttpRequestBodyDecodeError} When a declared body can't be decoded. */ export function decodeHttpRequestBody( rawBody: Uint8Array, contentType?: string, ): DecodedHttpRequestBody { if (rawBody.byteLength === 0) { return { body: null, bodyType: "empty" } } const parsedContentType = contentType ? parseContentType(contentType) : undefined const mediaType = parsedContentType?.type.toLowerCase() ?? "" if (mediaType === "multipart/form-data") { return { body: decodeMultipartForm( rawBody, parsedContentType?.parameters.boundary, ), bodyType: "form", } } if (mediaType === "application/x-www-form-urlencoded") { const values = new Map() for (const [key, value] of new URLSearchParams(decodeText(rawBody))) { const existing = values.get(key) if (existing) existing.push(value) else values.set(key, [value]) } return { body: Object.fromEntries( [...values].map(([key, entries]): [string, string | string[]] => [ key, entries.length === 1 ? entries[0]! : entries, ]), ), bodyType: "form", } } if (mediaType === "application/json" || mediaType.endsWith("+json")) { try { return { body: JSON_VALUE_SCHEMA.parse(JSON.parse(decodeText(rawBody))), bodyType: "json", } } catch (cause) { throw new HttpRequestBodyDecodeError( "Request body is not valid JSON.", 400, { cause }, ) } } if (isXmlMediaType(mediaType)) { return { body: decodeText( rawBody, getXmlCharset(rawBody, parsedContentType?.parameters.charset), ), bodyType: "text", } } if ( mediaType.startsWith("text/") || TEXT_APPLICATION_MEDIA_TYPES.has(mediaType) || mediaType.endsWith("+yaml") ) { return { body: decodeText(rawBody, parsedContentType?.parameters.charset), bodyType: "text", } } return { body: rawBody, bodyType: "bytes" } } /** * Decodes a multipart form into fields and files grouped by field name. * * @param rawBody - Exact multipart request bytes. * @param boundary - Boundary declared by the multipart Content-Type. * @throws {HttpRequestBodyDecodeError} When the form is malformed or exceeds * parser limits. */ function decodeMultipartForm(rawBody: Uint8Array, boundary?: string) { if (!boundary) { throw new HttpRequestBodyDecodeError( "Multipart form Content-Type is missing its boundary.", 400, ) } try { const values = new Map() for (const part of parseMultipart(rawBody, { boundary, // Ingress already buffers the complete request. Preserve its existing // size behavior instead of imposing the parser's lower file defaults. maxFileSize: rawBody.byteLength, maxTotalSize: rawBody.byteLength, })) { if (!part.name) { throw new HttpRequestBodyDecodeError( "Multipart form part is missing its field name.", 400, ) } const partContentType = part.headers["content-type"] ? parseContentType(part.headers["content-type"]) : undefined const value = part.isFile ? new File([part.arrayBuffer], part.filename ?? "", { lastModified: 0, type: part.mediaType ?? "application/octet-stream", }) : decodeText(part.bytes, partContentType?.parameters.charset) const existing = values.get(part.name) if (existing) existing.push(value) else values.set(part.name, [value]) } return Object.fromEntries( [...values].map(([key, entries]) => [ key, entries.length === 1 ? entries[0]! : entries, ]), ) } catch (cause) { if (cause instanceof HttpRequestBodyDecodeError) throw cause if ( cause instanceof MaxFileSizeExceededError || cause instanceof MaxHeaderSizeExceededError || cause instanceof MaxPartsExceededError || cause instanceof MaxTotalSizeExceededError ) { throw new HttpRequestBodyDecodeError( "Multipart form exceeds the supported size or part count.", 413, { cause }, ) } if (cause instanceof MultipartParseError || cause instanceof TypeError) { throw new HttpRequestBodyDecodeError( "Request body is not valid multipart form data.", 400, { cause }, ) } throw cause } } /** * Decodes bytes with the declared character encoding, defaulting to UTF-8. * * @param rawBody - Text body bytes. * @param charset - WHATWG character encoding label. * @throws {HttpRequestBodyDecodeError} When the encoding is unsupported or the * bytes are invalid for it. */ function decodeText(rawBody: Uint8Array, charset = "utf-8") { try { return new TextDecoder(charset, { fatal: true }).decode(rawBody) } catch (cause) { if (cause instanceof RangeError) { throw new HttpRequestBodyDecodeError( `Request body uses unsupported character encoding ${charset}.`, 415, { cause }, ) } throw new HttpRequestBodyDecodeError( `Request body is not valid ${charset} text.`, 400, { cause }, ) } } /** * Selects XML encoding using BOM, MIME charset, then the XML declaration. * * @param rawBody - XML body bytes. * @param declaredCharset - MIME charset parameter, when present. */ function getXmlCharset(rawBody: Uint8Array, declaredCharset?: string) { const bomCharset = detectXmlBom(rawBody) if (bomCharset) return bomCharset if (declaredCharset) return declaredCharset const inferredCharset = detectXmlBytePattern(rawBody) ?? "utf-8" try { return ( XML_DECLARATION_ENCODING.exec( new TextDecoder(inferredCharset).decode(rawBody.subarray(0, 1024)), )?.[2] ?? inferredCharset ) } catch { return inferredCharset } } /** * Detects a Unicode byte order mark. * * @param rawBody - XML body bytes. */ function detectXmlBom(rawBody: Uint8Array) { if (startsWithBytes(rawBody, [0x00, 0x00, 0xfe, 0xff])) return "utf-32be" if (startsWithBytes(rawBody, [0xff, 0xfe, 0x00, 0x00])) return "utf-32le" if (startsWithBytes(rawBody, [0xef, 0xbb, 0xbf])) return "utf-8" if (startsWithBytes(rawBody, [0xfe, 0xff])) return "utf-16be" if (startsWithBytes(rawBody, [0xff, 0xfe])) return "utf-16le" } /** * Infers XML encodings whose opening declaration has a distinctive byte form. * * @param rawBody - XML body bytes. */ function detectXmlBytePattern(rawBody: Uint8Array) { if (startsWithBytes(rawBody, [0x00, 0x00, 0x00, 0x3c])) return "utf-32be" if (startsWithBytes(rawBody, [0x3c, 0x00, 0x00, 0x00])) return "utf-32le" if (startsWithBytes(rawBody, [0x00, 0x3c, 0x00, 0x3f])) return "utf-16be" if (startsWithBytes(rawBody, [0x3c, 0x00, 0x3f, 0x00])) return "utf-16le" if (startsWithBytes(rawBody, [0x4c, 0x6f, 0xa7, 0x94])) return "ibm037" } /** * Checks an exact byte prefix. * * @param rawBody - Complete body bytes. * @param prefix - Bytes expected at the start. */ function startsWithBytes(rawBody: Uint8Array, prefix: number[]) { return prefix.every((byte, index) => rawBody[index] === byte) } /** * Recognizes XML media types that require XML encoding precedence. * * @param mediaType - Normalized media type. */ function isXmlMediaType(mediaType: string) { return ( mediaType === "application/xml" || mediaType === "application/xml-dtd" || mediaType === "application/xml-external-parsed-entity" || mediaType === "text/xml" || mediaType === "text/xml-external-parsed-entity" || mediaType.endsWith("+xml") ) }