113 lines
4.5 KiB
TypeScript
113 lines
4.5 KiB
TypeScript
import type { IncomingMessage } from "node:http";
|
|
import { Readable } from "node:stream";
|
|
|
|
export const MAX_WEB_REQUEST_BODY_BYTES = 100 * 1024 * 1024;
|
|
export const DEFAULT_WEB_REQUEST_BODY_BYTES = 2 * 1024 * 1024;
|
|
|
|
const FILE_CHUNK_REQUEST_BODY_BYTES = 5 * 1024 * 1024;
|
|
const PROFILE_IMAGE_REQUEST_BODY_BYTES = 8 * 1024 * 1024;
|
|
const HR_DOCUMENT_REQUEST_BODY_BYTES = 26 * 1024 * 1024;
|
|
export const PUBLIC_FORM_REQUEST_BODY_BYTES = 30 * 1024 * 1024;
|
|
export const OAUTH_REGISTRATION_REQUEST_BODY_BYTES = 64 * 1024;
|
|
export const EMAIL_WEBHOOK_REQUEST_BODY_BYTES = 256 * 1024;
|
|
export const PUBLIC_AUTH_FORM_REQUEST_BODY_BYTES = 64 * 1024;
|
|
export const PUBLIC_BOOKING_REQUEST_BODY_BYTES = 64 * 1024;
|
|
export const PORTAL_UPLOAD_MULTIPART_OVERHEAD_BYTES = 1024 * 1024;
|
|
export const PORTAL_UPLOAD_REQUEST_BODY_BYTES = MAX_WEB_REQUEST_BODY_BYTES + PORTAL_UPLOAD_MULTIPART_OVERHEAD_BYTES;
|
|
|
|
/**
|
|
* Keep ordinary mutations small while granting explicit, narrow exceptions to
|
|
* routes that intentionally receive binary data. The 100 MiB value remains an
|
|
* absolute ceiling, not the default for every form or JSON action.
|
|
*/
|
|
export function resolveWebRequestBodyLimit(requestUrl: string | undefined, runtimeSurface: "core" | "portal" = "core"): number {
|
|
let pathname: string;
|
|
try {
|
|
pathname = new URL(requestUrl ?? "/", "http://internal").pathname;
|
|
} catch {
|
|
return DEFAULT_WEB_REQUEST_BODY_BYTES;
|
|
}
|
|
|
|
if (/^\/[^/]+\/files\/upload\/[^/]+\/chunk\/\d+\/?$/.test(pathname)) {
|
|
return FILE_CHUNK_REQUEST_BODY_BYTES;
|
|
}
|
|
if (
|
|
runtimeSurface === "portal" &&
|
|
/^(?:\/[^/]+)?\/projects\/[A-Za-z0-9_-]{1,128}\/upload\/?$/u.test(pathname)
|
|
) {
|
|
// The portal runtime receives the standard-host path with a tenant slug
|
|
// and the custom-host path without one. One bounded MiB of multipart
|
|
// overhead keeps a legitimate 100 MiB file below the transport ceiling;
|
|
// application validation still enforces the exact per-file limit.
|
|
return PORTAL_UPLOAD_REQUEST_BODY_BYTES;
|
|
}
|
|
if (/^\/api\/embed\/forms\/[^/]+\/submit\/?$/.test(pathname)) {
|
|
// Keep the transport ceiling aligned with the route's multipart limit.
|
|
// A larger server allowance lets chunked requests consume memory in
|
|
// request.formData() before the route-level Content-Length check applies.
|
|
return PUBLIC_FORM_REQUEST_BODY_BYTES;
|
|
}
|
|
if (/^\/oauth\/(?:register|token|revoke)\/?$/.test(pathname)) {
|
|
return OAUTH_REGISTRATION_REQUEST_BODY_BYTES;
|
|
}
|
|
if (/^\/webhooks\/email\/(?:google|microsoft)\/?$/.test(pathname)) {
|
|
return EMAIL_WEBHOOK_REQUEST_BODY_BYTES;
|
|
}
|
|
if (/^\/(?:register|signup)\/?$/.test(pathname)) {
|
|
return PUBLIC_AUTH_FORM_REQUEST_BODY_BYTES;
|
|
}
|
|
if (/^\/b\/[^/]+\/[^/]+(?:\/embed)?\/?$/.test(pathname)) {
|
|
return PUBLIC_BOOKING_REQUEST_BODY_BYTES;
|
|
}
|
|
if (/^\/[^/]+\/emails\/new\/?$/.test(pathname)) {
|
|
return MAX_WEB_REQUEST_BODY_BYTES;
|
|
}
|
|
if (/^\/[^/]+\/hr\/my-documents\/?$/.test(pathname)) {
|
|
return HR_DOCUMENT_REQUEST_BODY_BYTES;
|
|
}
|
|
if (/^(?:\/admin)?\/settings\/general\/?$/.test(pathname)) {
|
|
return MAX_WEB_REQUEST_BODY_BYTES;
|
|
}
|
|
if (/^(?:\/admin)?\/settings\/profile\/?$/.test(pathname) || /^\/[^/]+\/settings\/(?:profile|account)\/?$/.test(pathname)) {
|
|
return PROFILE_IMAGE_REQUEST_BODY_BYTES;
|
|
}
|
|
|
|
return DEFAULT_WEB_REQUEST_BODY_BYTES;
|
|
}
|
|
|
|
export class RequestBodyTooLargeError extends Error {
|
|
readonly code = "WEB_REQUEST_BODY_TOO_LARGE";
|
|
|
|
constructor(readonly limitBytes: number) {
|
|
super(`Web request body exceeds the ${limitBytes}-byte limit.`);
|
|
this.name = "RequestBodyTooLargeError";
|
|
}
|
|
}
|
|
|
|
export function parseRequestContentLength(value: string | undefined): number | null {
|
|
if (value === undefined || !/^\d+$/.test(value.trim())) {
|
|
return null;
|
|
}
|
|
const parsed = Number(value);
|
|
return Number.isSafeInteger(parsed) ? parsed : null;
|
|
}
|
|
|
|
export function createLimitedRequestBody(request: IncomingMessage, limitBytes = MAX_WEB_REQUEST_BODY_BYTES): Readable {
|
|
return Readable.from(limitRequestBody(request, limitBytes));
|
|
}
|
|
|
|
async function* limitRequestBody(request: IncomingMessage, limitBytes: number): AsyncGenerator<Buffer | string> {
|
|
let receivedBytes = 0;
|
|
for await (const chunk of request) {
|
|
receivedBytes += Buffer.isBuffer(chunk) ? chunk.byteLength : Buffer.byteLength(String(chunk));
|
|
if (receivedBytes > limitBytes) {
|
|
throw new RequestBodyTooLargeError(limitBytes);
|
|
}
|
|
yield chunk as Buffer | string;
|
|
}
|
|
}
|
|
|
|
export function isRequestBodyTooLargeError(error: unknown): error is RequestBodyTooLargeError {
|
|
return error instanceof RequestBodyTooLargeError;
|
|
}
|