From eefb2ea5d28467fc2500f1a1569c518d3eb0b2ac Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Tue, 13 Oct 2026 15:52:34 +0100 Subject: [PATCH] Fix security vulnerabilities --- dist/data/app-data.d.ts | 1 + dist/data/app-data.js | 1 + .../bundler/strip-server-side-logic.d.ts | 6 -- .../bundler/strip-server-side-logic.js | 92 ------------------- dist/functions/cache/grab-cache-names.d.ts | 10 +- dist/functions/cache/grab-cache-names.js | 9 +- dist/functions/cache/trim-all-cache.js | 10 +- dist/functions/cache/trim-cache-key.d.ts | 5 +- dist/functions/cache/trim-cache-key.js | 19 ++-- dist/functions/cache/write-cache.js | 14 ++- dist/functions/process.d.ts | 1 - dist/functions/process.js | 46 ---------- dist/functions/server/bunext-req-handler.js | 13 ++- .../server/handle-bunext-public-assets.js | 2 +- dist/functions/server/handle-files.js | 2 +- dist/functions/server/handle-public.js | 2 +- dist/functions/server/handle-routes.js | 90 +++++++++--------- dist/functions/server/server-post-build-fn.js | 10 +- .../server/web-pages/generate-web-html.js | 59 ++++++++---- .../generate-web-page-get-cache-page.js | 13 +++ ...web-page-response-from-component-return.js | 10 +- .../server/web-pages/grab-page-component.js | 13 ++- .../web-pages/grab-page-error-component.js | 7 +- .../server/web-pages/grab-page-server-res.js | 9 +- .../grab-tsx-string-module-deprecated.d.ts | 5 - .../grab-tsx-string-module-deprecated.js | 73 --------------- .../server/web-pages/grab-web-meta-html.d.ts | 2 +- dist/presets/components/head.d.ts | 2 +- dist/presets/not-found.d.ts | 2 +- dist/presets/server-error.d.ts | 2 +- dist/types/index.d.ts | 18 +++- dist/utils/grab-constants.js | 2 +- dist/utils/grab-dir-names.js | 3 +- dist/utils/grab-route-params.d.ts | 10 +- dist/utils/grab-route-params.js | 32 ++++++- dist/utils/is-safe-path.js | 27 +++++- dist/utils/read-request-body.d.ts | 15 +++ dist/utils/read-request-body.js | 42 +++++++++ dist/utils/serialize-for-script.d.ts | 8 ++ dist/utils/serialize-for-script.js | 16 ++++ package.json | 2 +- src/__tests__/e2e/e2e.test.ts | 35 +------ .../server/bunext-req-handler.test.ts | 20 ++-- ...grab-page-bundled-react-component.test.tsx | 10 +- .../server/grab-page-component.test.tsx | 6 +- src/utils/read-request-body.ts | 2 +- 46 files changed, 375 insertions(+), 403 deletions(-) delete mode 100644 dist/functions/bundler/strip-server-side-logic.d.ts delete mode 100644 dist/functions/bundler/strip-server-side-logic.js delete mode 100644 dist/functions/process.d.ts delete mode 100644 dist/functions/process.js delete mode 100644 dist/functions/server/web-pages/grab-tsx-string-module-deprecated.d.ts delete mode 100644 dist/functions/server/web-pages/grab-tsx-string-module-deprecated.js create mode 100644 dist/utils/read-request-body.d.ts create mode 100644 dist/utils/read-request-body.js create mode 100644 dist/utils/serialize-for-script.d.ts create mode 100644 dist/utils/serialize-for-script.js diff --git a/dist/data/app-data.d.ts b/dist/data/app-data.d.ts index e909922..aecab62 100644 --- a/dist/data/app-data.d.ts +++ b/dist/data/app-data.d.ts @@ -1,5 +1,6 @@ export declare const AppData: { readonly DefaultCacheExpiryTimeSeconds: number; + readonly DefaultMaxCacheEntries: 1000; readonly DefaultCronInterval: 30000; readonly BunextStaticFilesCacheExpiry: number; readonly ClientHMRPath: "__bunext_client_hmr__"; diff --git a/dist/data/app-data.js b/dist/data/app-data.js index 3baa5d0..45052c0 100644 --- a/dist/data/app-data.js +++ b/dist/data/app-data.js @@ -1,5 +1,6 @@ export const AppData = { DefaultCacheExpiryTimeSeconds: 60 * 60, + DefaultMaxCacheEntries: 1000, DefaultCronInterval: 30000, BunextStaticFilesCacheExpiry: 60 * 60 * 24 * 7, ClientHMRPath: "__bunext_client_hmr__", diff --git a/dist/functions/bundler/strip-server-side-logic.d.ts b/dist/functions/bundler/strip-server-side-logic.d.ts deleted file mode 100644 index a82ba00..0000000 --- a/dist/functions/bundler/strip-server-side-logic.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -type Params = { - txt_code: string; - file_path: string; -}; -export default function stripServerSideLogic({ txt_code, file_path }: Params): string; -export {}; diff --git a/dist/functions/bundler/strip-server-side-logic.js b/dist/functions/bundler/strip-server-side-logic.js deleted file mode 100644 index 2280cf6..0000000 --- a/dist/functions/bundler/strip-server-side-logic.js +++ /dev/null @@ -1,92 +0,0 @@ -import path from "path"; -import ts from "typescript"; -export default function stripServerSideLogic({ txt_code, file_path }) { - const file_dir_name = path.dirname(file_path); - // 1. Initial Parse of the source - const sourceFile = ts.createSourceFile("file.tsx", txt_code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); - const printer = ts.createPrinter(); - /** - * PASS 1: Remove 'server' export and resolve absolute paths - */ - const stripTransformer = (context) => (rootNode) => { - const visitor = (node) => { - // Remove 'export const server' - if (ts.isVariableStatement(node) && - node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) { - if (node.declarationList.declarations.some((d) => ts.isIdentifier(d.name) && - d.name.text === "server")) { - return undefined; - } - } - // Convert relative imports to absolute - if (ts.isImportDeclaration(node)) { - const specifier = node.moduleSpecifier; - if (ts.isStringLiteral(specifier) && - specifier.text.startsWith(".")) { - const absolutePath = path.resolve(file_dir_name, specifier.text); - return ts.factory.updateImportDeclaration(node, node.modifiers, node.importClause, ts.factory.createStringLiteral(absolutePath), node.attributes); - } - } - return ts.visitEachChild(node, visitor, context); - }; - return ts.visitNode(rootNode, visitor); - }; - const step1Result = ts.transform(sourceFile, [stripTransformer]); - const intermediateCode = printer.printFile(step1Result.transformed[0]); - // 2. Re-parse to get a "Clean Slate" AST - const cleanSource = ts.createSourceFile("clean.tsx", intermediateCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); - /** - * PASS 2: Collect all used Identifiers and Prune Imports - */ - const usedIdentifiers = new Set(); - function walkAndFindUsages(node) { - if (ts.isIdentifier(node)) { - // We only care about identifiers that AREN'T the names in the import statements themselves - const parent = node.parent; - const isImportName = ts.isImportSpecifier(parent) || - ts.isImportClause(parent) || - ts.isNamespaceImport(parent); - if (!isImportName) { - usedIdentifiers.add(node.text); - } - } - ts.forEachChild(node, walkAndFindUsages); - } - walkAndFindUsages(cleanSource); - const cleanupTransformer = (context) => (rootNode) => { - const visitor = (node) => { - if (ts.isImportDeclaration(node)) { - const clause = node.importClause; - if (!clause) - return node; - // 1. Clean Named Imports: { A, B } - if (clause.namedBindings && - ts.isNamedImports(clause.namedBindings)) { - const activeElements = clause.namedBindings.elements.filter((el) => usedIdentifiers.has(el.name.text)); - // If no named imports are used and there is no default import, nix the whole line - if (activeElements.length === 0 && !clause.name) - return undefined; - // If we have some named imports left, update the node - return ts.factory.updateImportDeclaration(node, node.modifiers, ts.factory.updateImportClause(clause, clause.isTypeOnly, - // Only keep default import if it's used - clause.name && - usedIdentifiers.has(clause.name.text) - ? clause.name - : undefined, ts.factory.createNamedImports(activeElements)), node.moduleSpecifier, node.attributes); - } - // 2. Clean Default Imports: import X from '...' - if (clause.name && !usedIdentifiers.has(clause.name.text)) { - // If there are no named bindings attached, nix the whole line - if (!clause.namedBindings) - return undefined; - // Otherwise, just strip the default name and keep the named bindings - return ts.factory.updateImportDeclaration(node, node.modifiers, ts.factory.updateImportClause(clause, clause.isTypeOnly, undefined, clause.namedBindings), node.moduleSpecifier, node.attributes); - } - } - return ts.visitEachChild(node, visitor, context); - }; - return ts.visitNode(rootNode, visitor); - }; - const finalResult = ts.transform(cleanSource, [cleanupTransformer]); - return printer.printFile(finalResult.transformed[0]); -} diff --git a/dist/functions/cache/grab-cache-names.d.ts b/dist/functions/cache/grab-cache-names.d.ts index c181c68..2e6c3cc 100644 --- a/dist/functions/cache/grab-cache-names.d.ts +++ b/dist/functions/cache/grab-cache-names.d.ts @@ -1,8 +1,14 @@ type Params = { - key: string; + key?: string; + /** + * Hashed key, as found in cache file names. Used when the original + * key is no longer known (e.g. when trimming from a directory listing). + */ + cache_id?: string; paradigm?: "html" | "json"; }; -export default function grabCacheNames({ key, paradigm }: Params): { +export default function grabCacheNames({ key, cache_id, paradigm, }: Params): { + cache_id: string; cache_name: string; cache_meta_name: string; }; diff --git a/dist/functions/cache/grab-cache-names.js b/dist/functions/cache/grab-cache-names.js index f4f8ee5..92182b8 100644 --- a/dist/functions/cache/grab-cache-names.js +++ b/dist/functions/cache/grab-cache-names.js @@ -1,6 +1,9 @@ -export default function grabCacheNames({ key, paradigm = "html" }) { - const parsed_key = encodeURIComponent(key); +export default function grabCacheNames({ key, cache_id, paradigm = "html", }) { + // Hashing keeps file names short and fixed-length no matter how long + // the request path or query string is. + const parsed_key = cache_id || + new Bun.CryptoHasher("sha256").update(key || "").digest("hex"); const cache_name = `${parsed_key}.res.${paradigm}`; const cache_meta_name = `${parsed_key}.meta.json`; - return { cache_name, cache_meta_name }; + return { cache_id: parsed_key, cache_name, cache_meta_name }; } diff --git a/dist/functions/cache/trim-all-cache.js b/dist/functions/cache/trim-all-cache.js index 45132c6..186c5cb 100644 --- a/dist/functions/cache/trim-all-cache.js +++ b/dist/functions/cache/trim-all-cache.js @@ -9,14 +9,8 @@ export default async function trimAllCache() { const cached_item = cached_items[i]; if (!cached_item.endsWith(`.meta.json`)) continue; - const cache_key = decodeURIComponent(cached_item.replace(/\.meta\.json/, "")); - const trim_key = await trimCacheKey({ - key: cache_key, - }); - if (trim_key.success) { - cached_items.splice(i, 1); - i--; - } + const cache_id = cached_item.replace(/\.meta\.json$/, ""); + await trimCacheKey({ cache_id }); } } catch (error) { diff --git a/dist/functions/cache/trim-cache-key.d.ts b/dist/functions/cache/trim-cache-key.d.ts index 54b6b89..b7b9d1c 100644 --- a/dist/functions/cache/trim-cache-key.d.ts +++ b/dist/functions/cache/trim-cache-key.d.ts @@ -1,6 +1,7 @@ import type { APIResponseObject } from "../../types"; type Params = { - key: string; + key?: string; + cache_id?: string; }; -export default function trimCacheKey({ key, }: Params): Promise; +export default function trimCacheKey({ key, cache_id, }: Params): Promise; export {}; diff --git a/dist/functions/cache/trim-cache-key.js b/dist/functions/cache/trim-cache-key.js index 86f6b89..171e8a9 100644 --- a/dist/functions/cache/trim-cache-key.js +++ b/dist/functions/cache/trim-cache-key.js @@ -1,21 +1,24 @@ -import { readFileSync, unlinkSync } from "fs"; +import { readFileSync, rmSync } from "fs"; import grabDirNames from "../../utils/grab-dir-names"; import grabCacheNames from "./grab-cache-names"; import path from "path"; import { AppData } from "../../data/app-data"; -export default async function trimCacheKey({ key, }) { +export default async function trimCacheKey({ key, cache_id, }) { try { const { BUNEXT_CACHE_DIR } = grabDirNames(); - const { cache_name, cache_meta_name } = grabCacheNames({ - key, - }); + const { cache_meta_name } = grabCacheNames({ key, cache_id }); const config = global.BUNEXT_CONFIG; const default_expiry_time_seconds = config.default_cache_expiry || AppData["DefaultCacheExpiryTimeSeconds"]; const default_expiry_time_milliseconds = default_expiry_time_seconds * 1000; - const cache_content_path = path.join(BUNEXT_CACHE_DIR, cache_name); const cache_meta_path = path.join(BUNEXT_CACHE_DIR, cache_meta_name); const cache_meta = JSON.parse(readFileSync(cache_meta_path, "utf-8")); + const { cache_name } = grabCacheNames({ + key, + cache_id, + paradigm: cache_meta.paradigm, + }); + const cache_content_path = path.join(BUNEXT_CACHE_DIR, cache_name); const expiry_milliseconds = cache_meta.expiry_seconds ? cache_meta.expiry_seconds * 1000 : default_expiry_time_milliseconds; @@ -25,8 +28,8 @@ export default async function trimCacheKey({ key, }) { msg: `Cache has not expired yet`, }; } - unlinkSync(cache_content_path); - unlinkSync(cache_meta_path); + rmSync(cache_content_path, { force: true }); + rmSync(cache_meta_path, { force: true }); return { success: true, }; diff --git a/dist/functions/cache/write-cache.js b/dist/functions/cache/write-cache.js index e892ee3..8836a2a 100644 --- a/dist/functions/cache/write-cache.js +++ b/dist/functions/cache/write-cache.js @@ -1,7 +1,8 @@ -import { existsSync, writeFileSync } from "fs"; +import { existsSync, readdirSync, writeFileSync } from "fs"; import grabDirNames from "../../utils/grab-dir-names"; import grabCacheNames from "./grab-cache-names"; import path from "path"; +import { AppData } from "../../data/app-data"; export default async function writeCache({ key, value, paradigm = "html", expiry_seconds, }) { try { const { BUNEXT_CACHE_DIR } = grabDirNames(); @@ -16,7 +17,16 @@ export default async function writeCache({ key, value, paradigm = "html", expiry msg: `Cache entry already exists`, }; } - writeFileSync(path.join(target_path), value); + const max_entries = global.BUNEXT_CONFIG?.max_cache_entries || + AppData["DefaultMaxCacheEntries"]; + const entries_count = readdirSync(BUNEXT_CACHE_DIR).filter((f) => f.endsWith(".meta.json")).length; + if (entries_count >= max_entries) { + return { + success: false, + msg: `Cache entries limit (${max_entries}) reached`, + }; + } + writeFileSync(target_path, value); const cache_file_meta = { date_created: Date.now(), paradigm, diff --git a/dist/functions/process.d.ts b/dist/functions/process.d.ts deleted file mode 100644 index cb0ff5c..0000000 --- a/dist/functions/process.d.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/dist/functions/process.js b/dist/functions/process.js deleted file mode 100644 index ddf4cf4..0000000 --- a/dist/functions/process.js +++ /dev/null @@ -1,46 +0,0 @@ -import { spawn } from "bun"; -// Only the "supervisor" respawns. The child sets this env var so it won't respawn itself. -const IS_CHILD = process.env.__RESPAWN_CHILD === "1"; -let shuttingDown = false; -async function cleanup() { - // Put real cleanup here: close DB handles, servers, file descriptors, timers, etc. - // Must be awaitable — do NOT rely on process.on("exit") for this. -} -function respawn(code) { - const child = spawn({ - cmd: [process.execPath, ...process.argv.slice(1)], - stdio: ["inherit", "inherit", "inherit"], - env: { ...process.env, __RESPAWN_CHILD: "1" }, - // Detach so the child survives independently and gets its own process group. - // Without this, killing the parent's group can take the child with it. - }); - // Let the child live on its own. - child.unref?.(); -} -async function shutdown(code) { - if (shuttingDown) - return; - shuttingDown = true; - try { - await cleanup(); - } - catch (e) { - console.error("cleanup failed:", e); - } - // Only the supervisor respawns, and only on abnormal exit. - if (!IS_CHILD && code !== 0) { - respawn(code); - } - process.exit(code); -} -// Catch the things that actually fire *before* exit, where async works. -process.on("SIGINT", () => shutdown(130)); -process.on("SIGTERM", () => shutdown(143)); -process.on("uncaughtException", (err) => { - console.error(err); - shutdown(1); -}); -process.on("unhandledRejection", (err) => { - console.error(err); - shutdown(1); -}); diff --git a/dist/functions/server/bunext-req-handler.js b/dist/functions/server/bunext-req-handler.js index 134ec6e..d6d0bde 100644 --- a/dist/functions/server/bunext-req-handler.js +++ b/dist/functions/server/bunext-req-handler.js @@ -9,15 +9,16 @@ import checkExcludedPatterns from "../../utils/check-excluded-patterns"; import { AppData } from "../../data/app-data"; import { log } from "../../utils/log"; import fullRebuild from "./full-rebuild"; +import grabRouter from "../../utils/grab-router"; const HMR_RETRY_COOLDOWN_MS = 5000; let lastHmrRetryTime = 0; export default async function bunextRequestHandler({ req: initial_req, server, }) { const is_dev = isDevelopment(); let req = initial_req.clone(); try { - const url = new URL(req.url); + let url = new URL(req.url); if (checkExcludedPatterns({ path: url.pathname })) { - return Response.json({ success: false, msg: `Invalid Path` }); + return Response.json({ success: false, msg: `Invalid Path` }, { status: 404 }); } let response = undefined; if (global.BUNEXT_CONSTANTS.config?.middleware) { @@ -30,6 +31,7 @@ export default async function bunextRequestHandler({ req: initial_req, server, } } if (middleware_res instanceof Request) { req = middleware_res; + url = new URL(req.url); } } if (is_dev && url.pathname == AppData["BunextHMRRetryRoute"]) { @@ -53,8 +55,13 @@ export default async function bunextRequestHandler({ req: initial_req, server, } else if (url.pathname.startsWith("/public/")) { response = await handlePublic({ req }); } - else if (url.pathname.match(/\..*$/)) { + else if (url.pathname.match(/\.[^/]+$/)) { response = await handleFiles({ req }); + // Paths like `/users/john.doe` look like files but can be pages. + if (response.status == 404 && + grabRouter()?.match(url.pathname)?.filePath) { + response = await handleWebPages({ req }); + } } else { response = await handleWebPages({ req }); diff --git a/dist/functions/server/handle-bunext-public-assets.js b/dist/functions/server/handle-bunext-public-assets.js index d3361e7..e167bea 100644 --- a/dist/functions/server/handle-bunext-public-assets.js +++ b/dist/functions/server/handle-bunext-public-assets.js @@ -8,7 +8,7 @@ export default async function ({ req }) { try { const is_dev = isDevelopment(); const url = new URL(req.url); - const file_path = path.join(BUNEXT_PUBLIC_DIR, url.pathname.replace(/\/\.bunext\/public\//, "")); + const file_path = path.join(BUNEXT_PUBLIC_DIR, decodeURIComponent(url.pathname).replace(/\/\.bunext\/public\//, "")); if (!isSafePath({ filePath: file_path, allowedDir: BUNEXT_PUBLIC_DIR })) { return new Response("Forbidden", { status: 403 }); } diff --git a/dist/functions/server/handle-files.js b/dist/functions/server/handle-files.js index ac6182b..b2920c3 100644 --- a/dist/functions/server/handle-files.js +++ b/dist/functions/server/handle-files.js @@ -8,7 +8,7 @@ export default async function ({ req }) { try { const is_dev = isDevelopment(); const url = new URL(req.url); - const file_path = path.join(PUBLIC_DIR, url.pathname); + const file_path = path.join(PUBLIC_DIR, decodeURIComponent(url.pathname)); if (!isSafePath({ filePath: file_path, allowedDir: PUBLIC_DIR })) { return new Response("Forbidden", { status: 403 }); } diff --git a/dist/functions/server/handle-public.js b/dist/functions/server/handle-public.js index 1debd3e..0e52d96 100644 --- a/dist/functions/server/handle-public.js +++ b/dist/functions/server/handle-public.js @@ -8,7 +8,7 @@ export default async function ({ req }) { try { const is_dev = isDevelopment(); const url = new URL(req.url); - const file_path = path.join(PUBLIC_DIR, url.pathname.replace(/^\/public/, "")); + const file_path = path.join(PUBLIC_DIR, decodeURIComponent(url.pathname).replace(/^\/public/, "")); if (!isSafePath({ filePath: file_path, allowedDir: PUBLIC_DIR })) { return new Response("Forbidden", { status: 403 }); } diff --git a/dist/functions/server/handle-routes.js b/dist/functions/server/handle-routes.js index 4853a63..42b0198 100644 --- a/dist/functions/server/handle-routes.js +++ b/dist/functions/server/handle-routes.js @@ -7,6 +7,7 @@ import _ from "lodash"; import path from "path"; import grabDirNames from "../../utils/grab-dir-names"; import grabRelativePath from "../../utils/grab-relative-path"; +import { RequestBodyTooLargeError } from "../../utils/read-request-body"; const { ROOT_DIR } = grabDirNames(); export default async function ({ req }) { try { @@ -27,10 +28,6 @@ export default async function ({ req }) { }, }); } - const routeParams = await grabRouteParams({ - req, - query: match.query, - }); let module; const now = Date.now(); const relative_match_path = grabRelativePath(match.filePath); @@ -50,54 +47,37 @@ export default async function ({ req }) { ? config.max_request_body_mb * MBInBytes : ServerDefaultRequestBodyLimitBytes; const contentLength = req.headers.get("content-length"); - if (contentLength) { - const size = parseInt(contentLength, 10); - if (size > maxBodyBytes) { - return Response.json({ - success: false, - msg: "Request Body Too Large!", - }, { - status: 413, - headers: { - "Content-Type": "application/json", - }, - }); - } + if (contentLength && parseInt(contentLength, 10) > maxBodyBytes) { + return bodyTooLargeResponse(); } - else if (req.method !== "GET" && req.method !== "HEAD") { - const body = await req.arrayBuffer(); - if (body.byteLength > maxBodyBytes) { - return Response.json({ - success: false, - msg: "Request Body Too Large!", - }, { - status: 413, - headers: { - "Content-Type": "application/json", - }, - }); - } - try { - routeParams.body = JSON.parse(new TextDecoder().decode(body) || "{}"); - } - catch (error) { - log.error(`Invalid JSON body for ${url.pathname}: ${error?.message}`); - return Response.json({ - success: false, - msg: "Invalid JSON body", - }, { - status: 400, - headers: { - "Content-Type": "application/json", - }, - }); + let routeParams; + try { + routeParams = await grabRouteParams({ + req, + query: match.query, + max_body_bytes: maxBodyBytes, + }); + } + catch (error) { + if (error instanceof RequestBodyTooLargeError) { + return bodyTooLargeResponse(); } + throw error; } const target_module = (module["default"] || module["handler"]); + if (typeof target_module !== "function") { + log.error(`API Route ${url.pathname} has no default or \`handler\` export`); + return Response.json({ + success: false, + msg: is_dev + ? `Route ${url.pathname} has no default or \`handler\` export` + : "Internal Server Error", + }, { status: 500 }); + } let res; try { - res = await target_module?.({ + res = await target_module({ ...routeParams, }); } @@ -105,7 +85,7 @@ export default async function ({ req }) { log.error(`API Route Error (${url.pathname}): ${error?.message}\n${error?.stack || ""}`); return Response.json({ success: false, - msg: `Route handler error: ${error?.message || "Internal Server Error"}`, + msg: routeErrorMessage(error), }, { status: 500, headers: { @@ -138,7 +118,7 @@ export default async function ({ req }) { log.error(`API Route Error (${url.pathname}): ${error?.message}\n${error?.stack || ""}`); return Response.json({ success: false, - msg: `Route handler error: ${error?.message || "Internal Server Error"}`, + msg: routeErrorMessage(error), }, { status: 500, headers: { @@ -147,3 +127,19 @@ export default async function ({ req }) { }); } } +function bodyTooLargeResponse() { + return Response.json({ + success: false, + msg: "Request Body Too Large!", + }, { status: 413 }); +} +/** + * Error details can leak internals, so they are only exposed in + * development. They are always logged server-side. + */ +function routeErrorMessage(error) { + if (!isDevelopment()) { + return "Internal Server Error"; + } + return `Route handler error: ${error?.message || "Internal Server Error"}`; +} diff --git a/dist/functions/server/server-post-build-fn.js b/dist/functions/server/server-post-build-fn.js index e610633..e505ea0 100644 --- a/dist/functions/server/server-post-build-fn.js +++ b/dist/functions/server/server-post-build-fn.js @@ -40,9 +40,9 @@ export default async function serverPostBuildFn(params) { } continue; } - const mock_req = target_artifact.req_url - ? new Request(target_artifact.req_url, {}) - : new Request(controller.page_url); + // Re-render with this tab's own URL and cookie, never another + // client's request. + const mock_req = new Request(controller.page_url); if (controller.page_cookie) { mock_req.headers.set("cookie", controller.page_cookie); } @@ -59,7 +59,9 @@ export default async function serverPostBuildFn(params) { } const { serverRes } = page_component || {}; const final_artifact = { - ..._.omit(controller, ["controller"]), + // `page_cookie` holds the raw Cookie header (incl. HttpOnly + // cookies), so it must never be sent back to page JS. + ..._.omit(controller, ["controller", "page_cookie", "page_url"]), target_map: target_artifact, }; if (!target_artifact) { diff --git a/dist/functions/server/web-pages/generate-web-html.js b/dist/functions/server/web-pages/generate-web-html.js index b5d6139..638da89 100644 --- a/dist/functions/server/web-pages/generate-web-html.js +++ b/dist/functions/server/web-pages/generate-web-html.js @@ -1,6 +1,6 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime"; import grabContants from "../../../utils/grab-constants"; -import EJSON from "../../../utils/ejson"; +import serializeForScript from "../../../utils/serialize-for-script"; import isDevelopment from "../../../utils/is-development"; import grabWebPageHydrationScript from "./grab-web-page-hydration-script"; import grabWebMetaHTML from "./grab-web-meta-html"; @@ -19,7 +19,11 @@ export default async function genWebHTML({ component: Main, pageProps, bundledMa if (!Main) { throw new Error(`Main Component not found!`); } - const serializedProps = (EJSON.stringify(pageProps || {}) || "{}").replace(/<\//g, "<\\/"); + let serializedProps = "{}"; + try { + serializedProps = serializeForScript(pageProps || {}); + } + catch (error) { } const page_hydration_script = await grabWebPageHydrationScript(); const root_meta = root_module?.meta ? typeof root_module.meta == "function" && routeParams @@ -52,9 +56,9 @@ export default async function genWebHTML({ component: Main, pageProps, bundledMa }, }; let final_component = (_jsxs("html", { ...html_props, children: [_jsxs("head", { children: [_jsx("meta", { charSet: "utf-8", "data-bunext-head": true }), _jsx("meta", { name: "viewport", content: "width=device-width, initial-scale=1.0", "data-bunext-head": true }), final_meta ? grabWebMetaHTML({ meta: final_meta }) : null, bundledMap?.css_path ? (_jsx("link", { rel: "stylesheet", href: `/${bundledMap.css_path}`, "data-bunext-head": true })) : null, _jsx("script", { dangerouslySetInnerHTML: { - __html: `window.${ClientWindowPagePropsName} = ${serializedProps};\nwindow.process = ${JSON.stringify(client_process)}`, + __html: `window.${ClientWindowPagePropsName} = ${serializedProps};\nwindow.process = ${serializeForScript(client_process)}`, }, "data-bunext-head": true }), RootHead ? (_jsx(RootHead, { serverRes: pageProps, ctx: routeParams })) : null, Head ? _jsx(Head, { serverRes: pageProps, ctx: routeParams }) : null, bundledMap?.path ? (_jsxs(_Fragment, { children: [_jsx("script", { type: "importmap", dangerouslySetInnerHTML: { - __html: JSON.stringify(global.BUNEXT_REACT_IMPORTS_MAP), + __html: serializeForScript(global.BUNEXT_REACT_IMPORTS_MAP), }, defer: true, "data-bunext-head": true }), _jsx("script", { src: `/${bundledMap.path}`, type: "module", id: AppData["BunextClientHydrationScriptID"], defer: true, "data-bunext-head": true })] })) : null, is_dev ? (_jsx("script", { defer: true, dangerouslySetInnerHTML: { __html: page_hydration_script, }, "data-bunext-head": true })) : null] }), _jsx("body", { children: _jsx("div", { id: ClientRootElementIDName, suppressHydrationWarning: !dev, children: _jsx(Main, { ...pageProps }) }) })] })); @@ -66,18 +70,7 @@ export default async function genWebHTML({ component: Main, pageProps, bundledMa // }, // }); // const htmlBody = await new Response(stream).text(); - const originalConsole = { - log: console.log, - warn: console.warn, - error: console.error, - info: console.info, - debug: console.debug, - }; - console.log = () => { }; - console.warn = () => { }; - console.error = () => { }; - console.info = () => { }; - console.debug = () => { }; + silenceConsole(); let htmlBody; let render_error; let has_render_error = false; @@ -90,13 +83,13 @@ export default async function genWebHTML({ component: Main, pageProps, bundledMa has_render_error = true; render_error = error; } - originalConsole.error(error); + original_console.error(error); }, }); htmlBody = await new Response(stream).text(); } finally { - Object.assign(console, originalConsole); + restoreConsole(); } // A component crash during SSR must surface as a proper error (handled // upstream as a 500 page). React only reports these to `onError`; without @@ -107,3 +100,33 @@ export default async function genWebHTML({ component: Main, pageProps, bundledMa html += htmlBody; return html; } +let original_console = console; +let silenced_renders = 0; +/** + * Renders can overlap (each one awaits the stream), so the console is + * swapped only by the first active render and restored by the last one. + * Otherwise a render could save another render's no-ops as the "original" + * console and silence the server for good. + */ +function silenceConsole() { + if (silenced_renders++ > 0) + return; + original_console = { + log: console.log, + warn: console.warn, + error: console.error, + info: console.info, + debug: console.debug, + }; + console.log = () => { }; + console.warn = () => { }; + console.error = () => { }; + console.info = () => { }; + console.debug = () => { }; +} +function restoreConsole() { + if (--silenced_renders > 0) + return; + silenced_renders = 0; + Object.assign(console, original_console); +} diff --git a/dist/functions/server/web-pages/generate-web-page-get-cache-page.js b/dist/functions/server/web-pages/generate-web-page-get-cache-page.js index d8a1eeb..0b0abaa 100644 --- a/dist/functions/server/web-pages/generate-web-page-get-cache-page.js +++ b/dist/functions/server/web-pages/generate-web-page-get-cache-page.js @@ -7,6 +7,19 @@ export default async function generateWebPageGetCachePage({ module, routeParams, const expiry_seconds = config?.cacheExpiry || serverRes?.cache_expiry; if (cache_page && routeParams?.url) { try { + const status = serverRes?.response_options?.status; + if (status && (status < 200 || status >= 300)) { + return false; + } + const res_headers = new Headers(serverRes?.response_options?.headers); + if (res_headers.has("set-cookie")) { + return false; + } + const req_headers = routeParams.req.headers; + if (!global.BUNEXT_CONFIG?.cache_credentialed_requests && + (req_headers.has("cookie") || req_headers.has("authorization"))) { + return false; + } const is_cache = typeof cache_page == "boolean" ? cache_page : await cache_page({ ctx: routeParams, serverRes }); diff --git a/dist/functions/server/web-pages/generate-web-page-response-from-component-return.js b/dist/functions/server/web-pages/generate-web-page-response-from-component-return.js index d6253ac..cd1747e 100644 --- a/dist/functions/server/web-pages/generate-web-page-response-from-component-return.js +++ b/dist/functions/server/web-pages/generate-web-page-response-from-component-return.js @@ -5,6 +5,11 @@ import genWebHTML from "./generate-web-html"; import generateWebPageGetCachePage from "./generate-web-page-get-cache-page"; export default async function generateWebPageResponseFromComponentReturn({ component, module, bundledMap, routeParams, serverRes, debug, root_module, }) { const is_dev = isDevelopment(); + if (serverRes?.redirect?.destination) { + return Response.redirect(serverRes.redirect.destination, serverRes.redirect.permanent + ? 301 + : serverRes.redirect.status_code || 302); + } const html = await genWebHTML({ component, pageProps: serverRes, @@ -17,11 +22,6 @@ export default async function generateWebPageResponseFromComponentReturn({ compo if (debug) { log.info("html", html); } - if (serverRes?.redirect?.destination) { - return Response.redirect(serverRes.redirect.destination, serverRes.redirect.permanent - ? 301 - : serverRes.redirect.status_code || 302); - } const res_opts = { ...serverRes?.response_options, headers: { diff --git a/dist/functions/server/web-pages/grab-page-component.js b/dist/functions/server/web-pages/grab-page-component.js index 92c42cb..e836d16 100644 --- a/dist/functions/server/web-pages/grab-page-component.js +++ b/dist/functions/server/web-pages/grab-page-component.js @@ -11,6 +11,7 @@ import { existsSync } from "fs"; import grabDirNames from "../../../utils/grab-dir-names"; import grabRelativePath from "../../../utils/grab-relative-path"; import { logBuildErrorsOnce } from "../../../utils/build-error-log"; +import { RequestBodyTooLargeError } from "../../../utils/read-request-body"; const { BUNX_BUNDLER_ERROR_EXIT_FILE, ROOT_DIR } = grabDirNames(); class NotFoundError extends Error { status = 404; @@ -24,8 +25,10 @@ export default async function grabPageComponent(params) { const url = req?.url ? new URL(req.url) : undefined; const router = global.BUNEXT_ROUTER; const is_dev = isDevelopment(); - const forwarded_proto = req?.headers.get("x-forwarded-proto"); - if (url && forwarded_proto) { + const forwarded_proto = global.BUNEXT_CONFIG?.trust_proxy + ? req?.headers.get("x-forwarded-proto")?.split(",")[0]?.trim() + : undefined; + if (url && (forwarded_proto == "http" || forwarded_proto == "https")) { url.protocol = forwarded_proto; } let routeParams = undefined; @@ -78,9 +81,6 @@ export default async function grabPageComponent(params) { throw new Error(errMsg); } } - if (req && !is_hydration) { - global.BUNEXT_BUNDLER_CTX_MAP[relative_file_path].req_url = req.url; - } if (debug) { log.info(`bundledMap:`, bundledMap); } @@ -117,6 +117,9 @@ export default async function grabPageComponent(params) { }; } catch (error) { + if (error instanceof RequestBodyTooLargeError) { + return new Response("Request Body Too Large", { status: 413 }); + } const is404 = error instanceof NotFoundError || error?.name === "NotFoundError" || error?.status === 404; diff --git a/dist/functions/server/web-pages/grab-page-error-component.js b/dist/functions/server/web-pages/grab-page-error-component.js index 7815cc6..050de78 100644 --- a/dist/functions/server/web-pages/grab-page-error-component.js +++ b/dist/functions/server/web-pages/grab-page-error-component.js @@ -13,6 +13,9 @@ export default async function grabPageErrorComponent({ error, routeParams, is404 const presetComponent = is404 ? BUNX_ROOT_404_PRESET_COMPONENT : BUNX_ROOT_500_PRESET_COMPONENT; + // 404 messages only contain the requested path. Other error messages can + // leak internals, so they are only shown in development. + const error_message = is404 || is_dev ? error?.message : "Internal Server Error"; const default_server_res = { response_options: { status: is404 ? 404 : 500, @@ -24,7 +27,7 @@ export default async function grabPageErrorComponent({ error, routeParams, is404 const default_module = await import(presetComponent); const Component = default_module.default; const default_jsx = () => { - return _jsx(Component, { children: _jsx("span", { children: error.message }) }); + return _jsx(Component, { children: _jsx("span", { children: error_message }) }); }; return { component: default_jsx, @@ -65,7 +68,7 @@ export default async function grabPageErrorComponent({ error, routeParams, is404 alignItems: "center", justifyContent: "center", flexDirection: "column", - }, children: [_jsx("h1", { children: is404 ? "404 Not Found" : "500 Internal Server Error" }), _jsx("span", { children: error.message })] })); + }, children: [_jsx("h1", { children: is404 ? "404 Not Found" : "500 Internal Server Error" }), _jsx("span", { children: error_message })] })); return { component: DefaultNotFound, routeParams, diff --git a/dist/functions/server/web-pages/grab-page-server-res.js b/dist/functions/server/web-pages/grab-page-server-res.js index 7b0b5e4..5eb0933 100644 --- a/dist/functions/server/web-pages/grab-page-server-res.js +++ b/dist/functions/server/web-pages/grab-page-server-res.js @@ -1,5 +1,6 @@ import _ from "lodash"; import { log } from "../../../utils/log"; +import isDevelopment from "../../../utils/is-development"; export default async function grabPageServerRes({ url, query, routeParams, server_function, props, }) { const default_props = { url: url @@ -14,9 +15,7 @@ export default async function grabPageServerRes({ url, query, routeParams, serve "searchParams", "hash", "href", - "password", "protocol", - "username", ]), } : null, @@ -36,8 +35,12 @@ export default async function grabPageServerRes({ url, query, routeParams, serve } catch (error) { log.error(`Page ${url?.pathname} Server Error => ${error.message}\n`, error); + // Error details can leak internals, so only expose them in + // development. They are always logged above. return _.merge(default_props, { - error: error.message, + error: isDevelopment() + ? error.message + : "Internal Server Error", }); } } diff --git a/dist/functions/server/web-pages/grab-tsx-string-module-deprecated.d.ts b/dist/functions/server/web-pages/grab-tsx-string-module-deprecated.d.ts deleted file mode 100644 index 8741320..0000000 --- a/dist/functions/server/web-pages/grab-tsx-string-module-deprecated.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -type Params = { - tsx: string; -}; -export default function grabTsxStringModule({ tsx, }: Params): Promise; -export {}; diff --git a/dist/functions/server/web-pages/grab-tsx-string-module-deprecated.js b/dist/functions/server/web-pages/grab-tsx-string-module-deprecated.js deleted file mode 100644 index b6cda41..0000000 --- a/dist/functions/server/web-pages/grab-tsx-string-module-deprecated.js +++ /dev/null @@ -1,73 +0,0 @@ -import isDevelopment from "../../../utils/is-development"; -import * as esbuild from "esbuild"; -export default async function grabTsxStringModule({ tsx, }) { - const dev = isDevelopment(); - const now = Date.now(); - const final_tsx = dev ? tsx + `\n// v_${now}` : tsx; - const result = await esbuild.transform(final_tsx, { - loader: "tsx", - format: "esm", - jsx: "automatic", - minify: !dev, - }); - const blob = new Blob([result.code], { type: "text/javascript" }); - const url = URL.createObjectURL(blob); - const mod = await import(url); - URL.revokeObjectURL(url); - return mod; -} -// export default async function grabTsxStringModule({ -// tsx, -// }: Params): Promise { -// const dev = isDevelopment(); -// const now = Date.now(); -// const { BUNX_CWD_MODULE_CACHE_DIR } = grabDirNames(); -// const target_cache_file_path = path.join( -// BUNX_CWD_MODULE_CACHE_DIR, -// `server-render-${now}.js`, -// ); -// await esbuild.build({ -// stdin: { -// contents: dev ? tsx + `\n// v_${now}` : tsx, -// resolveDir: process.cwd(), -// loader: "tsx", -// }, -// bundle: true, -// format: "esm", -// target: "es2020", -// platform: "node", -// external: [ -// "react", -// "react-dom", -// "react/jsx-runtime", -// "react/jsx-dev-runtime", -// ], -// minify: !dev, -// define: { -// "process.env.NODE_ENV": JSON.stringify( -// dev ? "development" : "production", -// ), -// }, -// jsx: "automatic", -// outfile: target_cache_file_path, -// plugins: [tailwindEsbuildPlugin], -// }); -// Loader.registry.delete(target_cache_file_path); -// const mod = await import(`${target_cache_file_path}?t=${now}`); -// return mod as T; -// } -// if (!dev) { -// const now = Date.now(); -// const final_tsx = dev ? tsx + `\n// v_${now}` : tsx; -// const result = await esbuild.transform(final_tsx, { -// loader: "tsx", -// format: "esm", -// jsx: "automatic", -// minify: !dev, -// }); -// const blob = new Blob([result.code], { type: "text/javascript" }); -// const url = URL.createObjectURL(blob); -// const mod = await import(url); -// URL.revokeObjectURL(url); -// return mod as T; -// } diff --git a/dist/functions/server/web-pages/grab-web-meta-html.d.ts b/dist/functions/server/web-pages/grab-web-meta-html.d.ts index e82ddea..94861af 100644 --- a/dist/functions/server/web-pages/grab-web-meta-html.d.ts +++ b/dist/functions/server/web-pages/grab-web-meta-html.d.ts @@ -2,5 +2,5 @@ import type { BunextPageModuleMeta } from "../../../types"; type Params = { meta: BunextPageModuleMeta; }; -export default function grabWebMetaHTML({ meta }: Params): import("react/jsx-runtime").JSX.Element; +export default function grabWebMetaHTML({ meta }: Params): import("react").JSX.Element; export {}; diff --git a/dist/presets/components/head.d.ts b/dist/presets/components/head.d.ts index f2f837e..b028382 100644 --- a/dist/presets/components/head.d.ts +++ b/dist/presets/components/head.d.ts @@ -1,4 +1,4 @@ import type { DetailedHTMLProps, HTMLAttributes, PropsWithChildren } from "react"; type Props = PropsWithChildren, HTMLHeadElement>>; -export default function Head({ children, ...props }: Props): import("react/jsx-runtime").JSX.Element; +export default function Head({ children, ...props }: Props): import("react").JSX.Element; export {}; diff --git a/dist/presets/not-found.d.ts b/dist/presets/not-found.d.ts index 3e255d7..280f802 100644 --- a/dist/presets/not-found.d.ts +++ b/dist/presets/not-found.d.ts @@ -1,2 +1,2 @@ import type { PropsWithChildren } from "react"; -export default function DefaultNotFoundPage({ children }: PropsWithChildren): import("react/jsx-runtime").JSX.Element; +export default function DefaultNotFoundPage({ children }: PropsWithChildren): import("react").JSX.Element; diff --git a/dist/presets/server-error.d.ts b/dist/presets/server-error.d.ts index 1397a23..ee53ef9 100644 --- a/dist/presets/server-error.d.ts +++ b/dist/presets/server-error.d.ts @@ -1,2 +1,2 @@ import type { PropsWithChildren } from "react"; -export default function DefaultServerErrorPage({ children, }: PropsWithChildren): import("react/jsx-runtime").JSX.Element; +export default function DefaultServerErrorPage({ children, }: PropsWithChildren): import("react").JSX.Element; diff --git a/dist/types/index.d.ts b/dist/types/index.d.ts index e6ab379..b7d7b30 100644 --- a/dist/types/index.d.ts +++ b/dist/types/index.d.ts @@ -46,6 +46,23 @@ export type BunextConfig = { development?: boolean; middleware?: (params: BunextConfigMiddlewareParams) => Promise | Response | Request | undefined; default_cache_expiry?: number; + /** + * Maximum number of cached pages kept on disk. New pages are not + * cached once this is reached. Defaults to 1000. + */ + max_cache_entries?: number; + /** + * By default, pages are not written to the cache when the request + * carries a `Cookie` or `Authorization` header, so one user's page is + * never served to everyone. Set this to `true` only if your cached + * pages never render per-user content. + */ + cache_credentialed_requests?: boolean; + /** + * Trust the `X-Forwarded-Proto` header (set this when running behind a + * reverse proxy that sets it). Defaults to `false`. + */ + trust_proxy?: boolean; websocket?: WebSocketHandler; server_options?: Omit, "fetch" | "routes">; /** @@ -316,7 +333,6 @@ export type BundlerCTXMap = { url_path: string; file_name: string; css_path?: string; - req_url?: string; }; export type GlobalHMRControllerObject = { controller: ReadableStreamDefaultController; diff --git a/dist/utils/grab-constants.js b/dist/utils/grab-constants.js index 4bae63f..6e518f7 100644 --- a/dist/utils/grab-constants.js +++ b/dist/utils/grab-constants.js @@ -7,7 +7,7 @@ export default function grabConstants() { const ServerDefaultRequestBodyLimitBytes = MB_IN_BYTES * 10; const MaxBundlerRebuilds = 5; const RouteIgnorePatterns = [/\/\(/]; - if (config.pages_exclude_patterns) { + if (config?.pages_exclude_patterns) { RouteIgnorePatterns.push(...config.pages_exclude_patterns); } return { diff --git a/dist/utils/grab-dir-names.js b/dist/utils/grab-dir-names.js index f2b0832..1908ab4 100644 --- a/dist/utils/grab-dir-names.js +++ b/dist/utils/grab-dir-names.js @@ -19,7 +19,8 @@ export default function grabDirNames() { const BUNEXT_PUBLIC_DIR = path.join(BUNX_CWD_DIR, "public"); const HYDRATION_DST_DIR = path.join(BUNEXT_PUBLIC_DIR, "pages"); const BUNEXT_VENDOR_DIR = path.join(BUNEXT_PUBLIC_DIR, "vendor"); - const BUNEXT_CACHE_DIR = path.join(BUNEXT_PUBLIC_DIR, "cache"); + // Kept outside BUNEXT_PUBLIC_DIR: that directory is served over HTTP. + const BUNEXT_CACHE_DIR = path.join(BUNX_CWD_DIR, "cache"); const HYDRATION_DST_DIR_MAP_JSON_FILE_NAME = "map.json"; const HYDRATION_DST_DIR_MAP_JSON_FILE = path.join(HYDRATION_DST_DIR, HYDRATION_DST_DIR_MAP_JSON_FILE_NAME); const BUNX_ROOT_DIR = path.resolve(__dirname, "../../"); diff --git a/dist/utils/grab-route-params.d.ts b/dist/utils/grab-route-params.d.ts index 210274c..75d2d9e 100644 --- a/dist/utils/grab-route-params.d.ts +++ b/dist/utils/grab-route-params.d.ts @@ -2,6 +2,14 @@ import type { BunxRouteParams } from "../types"; type Params = { req: Request; query?: any; + /** + * Maximum request body size. Defaults to + * `ServerDefaultRequestBodyLimitBytes`. + */ + max_body_bytes?: number; }; -export default function grabRouteParams({ req, query: passed_query, }: Params): Promise; +/** + * Throws `RequestBodyTooLargeError` if the body exceeds `max_body_bytes`. + */ +export default function grabRouteParams({ req: initial_req, query: passed_query, max_body_bytes, }: Params): Promise; export {}; diff --git a/dist/utils/grab-route-params.js b/dist/utils/grab-route-params.js index 92c7513..f57213b 100644 --- a/dist/utils/grab-route-params.js +++ b/dist/utils/grab-route-params.js @@ -1,11 +1,35 @@ import _ from "lodash"; import deserializeQuery from "./deserialize-query"; -export default async function grabRouteParams({ req, query: passed_query, }) { - const url = new URL(req.url); +import grabConstants from "./grab-constants"; +import readRequestBody from "./read-request-body"; +/** + * Throws `RequestBodyTooLargeError` if the body exceeds `max_body_bytes`. + */ +export default async function grabRouteParams({ req: initial_req, query: passed_query, max_body_bytes, }) { + const url = new URL(initial_req.url); const query = deserializeQuery(Object.fromEntries(url.searchParams)); - const body = await (async () => { + const raw_body = await readRequestBody({ + req: initial_req, + max_bytes: max_body_bytes || grabConstants().ServerDefaultRequestBodyLimitBytes, + }); + // The original body stream is consumed above, so hand handlers a fresh + // request over the buffered bytes. This keeps `req.text()`, + // `req.formData()`, etc. usable (e.g. for webhook signatures/uploads). + const req = raw_body + ? new Request(initial_req.url, { + method: initial_req.method, + headers: initial_req.headers, + body: raw_body, + signal: initial_req.signal, + }) + : initial_req; + const body = (() => { + if (initial_req.method == "GET") + return undefined; + if (!raw_body) + return {}; try { - return req.method == "GET" ? undefined : await req.json(); + return JSON.parse(new TextDecoder().decode(raw_body) || "{}"); } catch (error) { return {}; diff --git a/dist/utils/is-safe-path.js b/dist/utils/is-safe-path.js index 729607f..df79dd9 100644 --- a/dist/utils/is-safe-path.js +++ b/dist/utils/is-safe-path.js @@ -1,15 +1,36 @@ import { realpathSync } from "fs"; import path from "path"; export default function isSafePath({ filePath, allowedDir, }) { + const allowed = path.resolve(allowedDir); const resolved = path.resolve(filePath); - if (!resolved.startsWith(allowedDir + path.sep) && resolved !== allowedDir) { + if (!isInside(resolved, allowed)) { return false; } try { - const real = realpathSync(resolved); - return (real.startsWith(allowedDir + path.sep) || real === allowedDir); + return isInside(realpathSync(resolved), resolveRealDir(allowed)); } catch { return false; } } +function isInside(target, dir) { + return target === dir || target.startsWith(dir + path.sep); +} +const REAL_DIRS = new Map(); +/** + * Resolves symlinks in the allowed directory itself (e.g. a project under a + * symlinked path), so real file paths inside it still match. + */ +function resolveRealDir(dir) { + const cached = REAL_DIRS.get(dir); + if (cached) + return cached; + try { + const real = realpathSync(dir); + REAL_DIRS.set(dir, real); + return real; + } + catch { + return dir; + } +} diff --git a/dist/utils/read-request-body.d.ts b/dist/utils/read-request-body.d.ts new file mode 100644 index 0000000..f99107d --- /dev/null +++ b/dist/utils/read-request-body.d.ts @@ -0,0 +1,15 @@ +export declare class RequestBodyTooLargeError extends Error { + status: number; + constructor(max_bytes: number); +} +type Params = { + req: Request; + max_bytes: number; +}; +/** + * Reads the whole request body, aborting as soon as it grows past + * `max_bytes` so oversized (or chunked, length-less) bodies are never fully + * buffered. Returns `undefined` when the request has no body. + */ +export default function readRequestBody({ req, max_bytes, }: Params): Promise | undefined>; +export {}; diff --git a/dist/utils/read-request-body.js b/dist/utils/read-request-body.js new file mode 100644 index 0000000..9c8acef --- /dev/null +++ b/dist/utils/read-request-body.js @@ -0,0 +1,42 @@ +export class RequestBodyTooLargeError extends Error { + status = 413; + constructor(max_bytes) { + super(`Request body exceeds ${max_bytes} bytes`); + this.name = "RequestBodyTooLargeError"; + } +} +/** + * Reads the whole request body, aborting as soon as it grows past + * `max_bytes` so oversized (or chunked, length-less) bodies are never fully + * buffered. Returns `undefined` when the request has no body. + */ +export default async function readRequestBody({ req, max_bytes, }) { + if (req.method == "GET" || req.method == "HEAD" || !req.body) { + return undefined; + } + const content_length = Number(req.headers.get("content-length")); + if (content_length > max_bytes) { + throw new RequestBodyTooLargeError(max_bytes); + } + const reader = req.body.getReader(); + const chunks = []; + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) + break; + total += value.byteLength; + if (total > max_bytes) { + await reader.cancel(); + throw new RequestBodyTooLargeError(max_bytes); + } + chunks.push(value); + } + const body = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return body; +} diff --git a/dist/utils/serialize-for-script.d.ts b/dist/utils/serialize-for-script.d.ts new file mode 100644 index 0000000..690acb3 --- /dev/null +++ b/dist/utils/serialize-for-script.d.ts @@ -0,0 +1,8 @@ +/** + * # Serialize a value as JSON that is safe to embed in an inline ``), open an HTML comment (`