From b702e26bf69b509395897d6e0a0fa5d907a74db9 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Sun, 19 Apr 2026 16:00:59 +0100 Subject: [PATCH] Security fixes pass #2 --- dist/commands/start/index.js | 12 +++++++ .../bundler/build-on-start-error-handler.js | 2 +- dist/functions/bunext-init.d.ts | 4 +-- dist/functions/bunext-init.js | 2 +- dist/functions/cache/trim-all-cache.js | 4 +++ dist/functions/server/bunext-req-handler.js | 17 ++++++++-- .../server/handle-bunext-public-assets.js | 3 +- dist/functions/server/handle-files.js | 9 +++-- dist/functions/server/handle-hmr.js | 25 ++++++++++---- dist/functions/server/handle-public.js | 6 +++- dist/functions/server/handle-routes.js | 22 ++++++++++-- dist/functions/server/server-post-build-fn.js | 4 +-- .../server/web-pages/generate-web-html.js | 23 ++++++++----- .../grab-page-combined-server-res.js | 2 +- .../server/web-pages/grab-page-component.js | 2 +- dist/types/index.d.ts | 2 +- dist/utils/deserialize-query.d.ts | 3 -- dist/utils/deserialize-query.js | 25 +++++++++++--- dist/utils/grab-dir-names.d.ts | 3 +- dist/utils/grab-dir-names.js | 2 ++ dist/utils/is-development.js | 8 ++--- dist/utils/is-safe-path.d.ts | 4 +++ dist/utils/is-safe-path.js | 15 ++++++++ src/commands/start/index.ts | 17 ++++++++++ .../bundler/build-on-start-error-handler.ts | 2 +- src/functions/bunext-init.ts | 4 +-- src/functions/cache/trim-all-cache.ts | 5 +++ src/functions/server/bunext-req-handler.ts | 19 +++++++++-- .../server/handle-bunext-public-assets.ts | 3 +- src/functions/server/handle-files.ts | 11 ++++-- src/functions/server/handle-hmr.ts | 34 +++++++++++++------ src/functions/server/handle-public.ts | 5 ++- src/functions/server/server-post-build-fn.ts | 4 +-- .../server/web-pages/generate-web-html.tsx | 21 +++++++----- .../grab-page-combined-server-res.ts | 2 +- .../server/web-pages/grab-page-component.tsx | 2 +- src/types/index.ts | 2 +- src/utils/grab-dir-names.ts | 34 ++++++++++++++++++- src/utils/is-development.ts | 10 ++---- src/utils/is-safe-path.ts | 24 +++++++++++++ 40 files changed, 305 insertions(+), 93 deletions(-) create mode 100644 dist/utils/is-safe-path.d.ts create mode 100644 dist/utils/is-safe-path.js create mode 100644 src/utils/is-safe-path.ts diff --git a/dist/commands/start/index.js b/dist/commands/start/index.js index 7ec81c3..fe06fbb 100644 --- a/dist/commands/start/index.js +++ b/dist/commands/start/index.js @@ -1,6 +1,9 @@ import { Command } from "commander"; import path from "path"; import writeErrorFile from "../../functions/write-error-file"; +let retries = 0; +let timeout; +const MAX_RETRIES = 5; export default function () { return new Command("start") .description("Start production server") @@ -9,6 +12,11 @@ export default function () { }); } async function start() { + clearTimeout(timeout); + if (retries >= MAX_RETRIES) { + console.error(`Production server crashed ${MAX_RETRIES} times. Exiting.`); + process.exit(1); + } const dev_spawn_file = path.resolve(__dirname, "prod-spawn.ts"); const spawn_options = { cmd: ["bun", dev_spawn_file], @@ -22,6 +30,10 @@ async function start() { }, }; let dev_process = Bun.spawn(spawn_options); + retries++; + timeout = setTimeout(() => { + retries = 0; + }, 10000); const exited = await dev_process.exited; if (exited) { return await start(); diff --git a/dist/functions/bundler/build-on-start-error-handler.js b/dist/functions/bundler/build-on-start-error-handler.js index 89c49aa..4d9aeac 100644 --- a/dist/functions/bundler/build-on-start-error-handler.js +++ b/dist/functions/bundler/build-on-start-error-handler.js @@ -4,7 +4,7 @@ export default async function buildOnstartErrorHandler(params) { global.BUNDLER_CTX_DISPOSED = true; global.RECOMPILING = false; global.IS_SERVER_COMPONENT = false; - Promise.all([ + await Promise.all([ global.SSR_BUNDLER_CTX?.dispose(), global.BUNDLER_CTX?.dispose(), ]); diff --git a/dist/functions/bunext-init.d.ts b/dist/functions/bunext-init.d.ts index 6df4520..9b5b11d 100644 --- a/dist/functions/bunext-init.d.ts +++ b/dist/functions/bunext-init.d.ts @@ -1,6 +1,6 @@ import type { BundlerCTXMap, BunextConfig, GlobalHMRControllerObject, PageFiles } from "../types"; import type { FileSystemRouter, Server } from "bun"; -import grabDirNames from "../utils/grab-dir-names"; +import { type DirNames } from "../utils/grab-dir-names"; import { type FSWatcher } from "fs"; import type { BuildContext } from "esbuild"; import grabConstants from "../utils/grab-constants"; @@ -31,7 +31,7 @@ declare global { var SKIPPED_BROWSER_MODULES: Set; var BUNDLER_CTX: BuildContext | undefined; var SSR_BUNDLER_CTX: BuildContext | undefined; - var DIR_NAMES: ReturnType; + var DIR_NAMES: DirNames; var REACT_IMPORTS_MAP: { imports: Record; }; diff --git a/dist/functions/bunext-init.js b/dist/functions/bunext-init.js index dee0c8f..ea94ad1 100644 --- a/dist/functions/bunext-init.js +++ b/dist/functions/bunext-init.js @@ -1,4 +1,4 @@ -import grabDirNames from "../utils/grab-dir-names"; +import grabDirNames, {} from "../utils/grab-dir-names"; import {} from "fs"; import init from "./init"; import isDevelopment from "../utils/is-development"; diff --git a/dist/functions/cache/trim-all-cache.js b/dist/functions/cache/trim-all-cache.js index a600f99..45132c6 100644 --- a/dist/functions/cache/trim-all-cache.js +++ b/dist/functions/cache/trim-all-cache.js @@ -13,6 +13,10 @@ export default async function trimAllCache() { const trim_key = await trimCacheKey({ key: cache_key, }); + if (trim_key.success) { + cached_items.splice(i, 1); + i--; + } } } catch (error) { diff --git a/dist/functions/server/bunext-req-handler.js b/dist/functions/server/bunext-req-handler.js index 8d59aef..b0cbb0b 100644 --- a/dist/functions/server/bunext-req-handler.js +++ b/dist/functions/server/bunext-req-handler.js @@ -8,6 +8,8 @@ import handleBunextPublicAssets from "./handle-bunext-public-assets"; import checkExcludedPatterns from "../../utils/check-excluded-patterns"; import { AppData } from "../../data/app-data"; import fullRebuild from "./full-rebuild"; +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(); @@ -30,6 +32,11 @@ export default async function bunextRequestHandler({ req: initial_req, server, } } } if (is_dev && url.pathname == AppData["BunextHMRRetryRoute"]) { + const now = Date.now(); + if (now - lastHmrRetryTime < HMR_RETRY_COOLDOWN_MS) { + return new Response("Too Many Requests", { status: 429 }); + } + lastHmrRetryTime = now; await fullRebuild({ msg: `HMR Retry Rebuild ...` }); return new Response("Modules Rebuilt"); } @@ -60,8 +67,12 @@ export default async function bunextRequestHandler({ req: initial_req, server, } return response; } catch (error) { - return new Response(`Server Error: ${error.message}`, { - status: 500, - }); + if (is_dev) { + return new Response(`Server Error: ${error.message}`, { + status: 500, + }); + } + console.error(`Server Error: ${error.message}`, error); + return new Response("Internal Server Error", { status: 500 }); } } diff --git a/dist/functions/server/handle-bunext-public-assets.js b/dist/functions/server/handle-bunext-public-assets.js index 8b2b348..d3361e7 100644 --- a/dist/functions/server/handle-bunext-public-assets.js +++ b/dist/functions/server/handle-bunext-public-assets.js @@ -2,13 +2,14 @@ import grabDirNames from "../../utils/grab-dir-names"; import path from "path"; import isDevelopment from "../../utils/is-development"; import { readFileResponse } from "./handle-public"; +import isSafePath from "../../utils/is-safe-path"; const { BUNEXT_PUBLIC_DIR } = grabDirNames(); 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\//, "")); - if (!file_path.startsWith(BUNEXT_PUBLIC_DIR + path.sep)) { + if (!isSafePath({ filePath: file_path, allowedDir: BUNEXT_PUBLIC_DIR })) { return new Response("Forbidden", { status: 403 }); } return readFileResponse({ diff --git a/dist/functions/server/handle-files.js b/dist/functions/server/handle-files.js index 579a7b9..ac6182b 100644 --- a/dist/functions/server/handle-files.js +++ b/dist/functions/server/handle-files.js @@ -2,13 +2,14 @@ import grabDirNames from "../../utils/grab-dir-names"; import path from "path"; import isDevelopment from "../../utils/is-development"; import { existsSync } from "fs"; +import isSafePath from "../../utils/is-safe-path"; const { PUBLIC_DIR } = grabDirNames(); 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); - if (!file_path.startsWith(PUBLIC_DIR + path.sep)) { + if (!isSafePath({ filePath: file_path, allowedDir: PUBLIC_DIR })) { return new Response("Forbidden", { status: 403 }); } if (!existsSync(file_path)) { @@ -17,7 +18,11 @@ export default async function ({ req }) { }); } const file = Bun.file(file_path); - return new Response(file); + const headers = new Headers(); + if (!is_dev) { + headers.set("Cache-Control", "public, max-age=3600"); + } + return new Response(file, { headers }); } catch (error) { return new Response(`File Not Found`, { diff --git a/dist/functions/server/handle-hmr.js b/dist/functions/server/handle-hmr.js index 86cabd7..612908d 100644 --- a/dist/functions/server/handle-hmr.js +++ b/dist/functions/server/handle-hmr.js @@ -1,5 +1,21 @@ +function removeController(controller) { + const idx = global.HMR_CONTROLLERS.findIndex((c) => c.controller == controller); + if (typeof idx == "number" && idx >= 0) { + global.HMR_CONTROLLERS.splice(idx, 1); + } +} export default async function ({ req }) { - const referer_url = new URL(req.headers.get("referer") || ""); + const referer = req.headers.get("referer"); + if (!referer) { + return new Response("Missing Referer Header", { status: 400 }); + } + let referer_url; + try { + referer_url = new URL(referer); + } + catch { + return new Response("Invalid Referer Header", { status: 400 }); + } const match = global.ROUTER.match(referer_url.pathname); const target_map = match?.filePath ? global.BUNDLER_CTX_MAP?.[match.filePath] @@ -20,16 +36,13 @@ export default async function ({ req }) { } catch { clearInterval(heartbeat); + removeController(controller); } }, 5000); }, cancel() { clearInterval(heartbeat); - const targetControllerIndex = global.HMR_CONTROLLERS.findIndex((c) => c.controller == controller); - if (typeof targetControllerIndex == "number" && - targetControllerIndex >= 0) { - global.HMR_CONTROLLERS.splice(targetControllerIndex, 1); - } + removeController(controller); }, }); return new Response(stream, { diff --git a/dist/functions/server/handle-public.js b/dist/functions/server/handle-public.js index 41639d3..1debd3e 100644 --- a/dist/functions/server/handle-public.js +++ b/dist/functions/server/handle-public.js @@ -2,13 +2,14 @@ import grabDirNames from "../../utils/grab-dir-names"; import path from "path"; import isDevelopment from "../../utils/is-development"; import { existsSync } from "fs"; +import isSafePath from "../../utils/is-safe-path"; const { PUBLIC_DIR } = grabDirNames(); 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/, "")); - if (!file_path.startsWith(PUBLIC_DIR + path.sep)) { + if (!isSafePath({ filePath: file_path, allowedDir: PUBLIC_DIR })) { return new Response("Forbidden", { status: 403 }); } return readFileResponse({ file_path }); @@ -33,6 +34,9 @@ export function readFileResponse({ file_path, cache }) { else if (cache?.duration) { headers.set("Cache-Control", `public, max-age=${cache.duration}`); } + else if (!isDevelopment()) { + headers.set("Cache-Control", "public, max-age=3600"); + } return new Response(file, { headers, }); diff --git a/dist/functions/server/handle-routes.js b/dist/functions/server/handle-routes.js index bf3c2d0..7ab65d3 100644 --- a/dist/functions/server/handle-routes.js +++ b/dist/functions/server/handle-routes.js @@ -41,12 +41,13 @@ export default async function ({ req }) { module = await import(import_path); } const config = module.config; + const maxBodyBytes = config?.max_request_body_mb + ? config.max_request_body_mb * MBInBytes + : ServerDefaultRequestBodyLimitBytes; const contentLength = req.headers.get("content-length"); if (contentLength) { const size = parseInt(contentLength, 10); - if ((config?.max_request_body_mb && - size > config.max_request_body_mb * MBInBytes) || - size > ServerDefaultRequestBodyLimitBytes) { + if (size > maxBodyBytes) { return Response.json({ success: false, msg: "Request Body Too Large!", @@ -58,6 +59,21 @@ export default async function ({ req }) { }); } } + 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", + }, + }); + } + routeParams.body = JSON.parse(new TextDecoder().decode(body) || "{}"); + } const target_module = (module["default"] || module["handler"]); const res = await target_module?.({ diff --git a/dist/functions/server/server-post-build-fn.js b/dist/functions/server/server-post-build-fn.js index 4cb25a4..d179fa2 100644 --- a/dist/functions/server/server-post-build-fn.js +++ b/dist/functions/server/server-post-build-fn.js @@ -30,8 +30,8 @@ export default async function serverPostBuildFn(params) { controller.controller.enqueue(reload_enqueue); continue; } - const mock_req = target_artifact.req - ? target_artifact.req.clone() + const mock_req = target_artifact.req_url + ? new Request(target_artifact.req_url) : new Request(controller.page_url); const page_component = global.IS_SERVER_COMPONENT ? await grabPageComponent({ diff --git a/dist/functions/server/web-pages/generate-web-html.js b/dist/functions/server/web-pages/generate-web-html.js index 74beb06..ec27bc7 100644 --- a/dist/functions/server/web-pages/generate-web-html.js +++ b/dist/functions/server/web-pages/generate-web-html.js @@ -76,15 +76,20 @@ export default async function genWebHTML({ component: Main, pageProps, bundledMa console.error = () => { }; console.info = () => { }; console.debug = () => { }; - const stream = await renderToReadableStream(final_component, { - onError(error) { - if (error.message.includes('unique "key" prop')) - return; - originalConsole.error(error); - }, - }); - const htmlBody = await new Response(stream).text(); - Object.assign(console, originalConsole); + let htmlBody; + try { + const stream = await renderToReadableStream(final_component, { + onError(error) { + if (error.message.includes('unique "key" prop')) + return; + originalConsole.error(error); + }, + }); + htmlBody = await new Response(stream).text(); + } + finally { + Object.assign(console, originalConsole); + } html += htmlBody; return html; } diff --git a/dist/functions/server/web-pages/grab-page-combined-server-res.js b/dist/functions/server/web-pages/grab-page-combined-server-res.js index 9e64b7a..cc8e4e9 100644 --- a/dist/functions/server/web-pages/grab-page-combined-server-res.js +++ b/dist/functions/server/web-pages/grab-page-combined-server-res.js @@ -33,7 +33,7 @@ export default async function grabPageCombinedServerRes({ file_path, debug, url, const page_server_ctx = global.SSR_BUNDLER_CTX_MAP[server_file_path || ""]; const final_page_server_path = page_server_ctx?.local_path ? path.join(ROOT_DIR, page_server_ctx.path) - : root_server_file_path; + : server_file_path; const server_module = final_page_server_path ? await import(`${final_page_server_path}?t=${now}`) : undefined; diff --git a/dist/functions/server/web-pages/grab-page-component.js b/dist/functions/server/web-pages/grab-page-component.js index 4710dec..e7ab8b4 100644 --- a/dist/functions/server/web-pages/grab-page-component.js +++ b/dist/functions/server/web-pages/grab-page-component.js @@ -72,7 +72,7 @@ export default async function grabPageComponent(params) { } } if (req && !is_hydration) { - global.BUNDLER_CTX_MAP[file_path].req = req; + global.BUNDLER_CTX_MAP[file_path].req_url = req.url; } if (debug) { log.info(`bundledMap:`, bundledMap); diff --git a/dist/types/index.d.ts b/dist/types/index.d.ts index cffdab2..87c46a5 100644 --- a/dist/types/index.d.ts +++ b/dist/types/index.d.ts @@ -306,7 +306,7 @@ export type BundlerCTXMap = { url_path: string; file_name: string; css_path?: string; - req?: Request; + req_url?: string; }; export type GlobalHMRControllerObject = { controller: ReadableStreamDefaultController; diff --git a/dist/utils/deserialize-query.d.ts b/dist/utils/deserialize-query.d.ts index d0930b8..01689d5 100644 --- a/dist/utils/deserialize-query.d.ts +++ b/dist/utils/deserialize-query.d.ts @@ -1,6 +1,3 @@ -/** - * # Convert Serialized Query back to object - */ export default function deserializeQuery(query: string | { [s: string]: any; }): { diff --git a/dist/utils/deserialize-query.js b/dist/utils/deserialize-query.js index 4cc68f5..f5da80c 100644 --- a/dist/utils/deserialize-query.js +++ b/dist/utils/deserialize-query.js @@ -1,18 +1,33 @@ import EJSON from "./ejson"; -/** - * # Convert Serialized Query back to object - */ +const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]); +function sanitize(value) { + if (value === null || typeof value !== "object") + return value; + if (Array.isArray(value)) + return value.map(sanitize); + const clean = Object.create(null); + for (const key of Object.keys(value)) { + if (DANGEROUS_KEYS.has(key)) + continue; + clean[key] = sanitize(value[key]); + } + return clean; +} export default function deserializeQuery(query) { let queryObject = typeof query == "object" ? query : Object(EJSON.parse(query)); const keys = Object.keys(queryObject); for (let i = 0; i < keys.length; i++) { const key = keys[i]; const value = queryObject[key]; + if (DANGEROUS_KEYS.has(key)) { + delete queryObject[key]; + continue; + } if (typeof value == "string") { if (value.match(/^\{|^\[/)) { - queryObject[key] = EJSON.parse(value); + queryObject[key] = sanitize(EJSON.parse(value)); } } } - return queryObject; + return sanitize(queryObject); } diff --git a/dist/utils/grab-dir-names.d.ts b/dist/utils/grab-dir-names.d.ts index b35b411..37b0172 100644 --- a/dist/utils/grab-dir-names.d.ts +++ b/dist/utils/grab-dir-names.d.ts @@ -1,4 +1,4 @@ -export default function grabDirNames(): { +export type DirNames = { ROOT_DIR: string; SRC_DIR: string; PAGES_DIR: string; @@ -27,3 +27,4 @@ export default function grabDirNames(): { BUNX_ERROR_LOGS_DIR: string; BUNX_LOGS_DIR: string; }; +export default function grabDirNames(): DirNames; diff --git a/dist/utils/grab-dir-names.js b/dist/utils/grab-dir-names.js index faf22f5..9143ab3 100644 --- a/dist/utils/grab-dir-names.js +++ b/dist/utils/grab-dir-names.js @@ -1,5 +1,7 @@ import path from "path"; export default function grabDirNames() { + if (global.DIR_NAMES) + return global.DIR_NAMES; const ROOT_DIR = process.cwd(); const SRC_DIR = path.join(ROOT_DIR, "src"); const PAGES_DIR = path.join(SRC_DIR, "pages"); diff --git a/dist/utils/is-development.js b/dist/utils/is-development.js index 5074564..b5ad080 100644 --- a/dist/utils/is-development.js +++ b/dist/utils/is-development.js @@ -1,10 +1,6 @@ export default function isDevelopment() { - const config = global.CONFIG; - if (process.env.NODE_ENV == "production") { + if (process.env.NODE_ENV === "production") { return false; } - if (config.development) { - return true; - } - return false; + return Boolean(global.CONFIG?.development); } diff --git a/dist/utils/is-safe-path.d.ts b/dist/utils/is-safe-path.d.ts new file mode 100644 index 0000000..bce5820 --- /dev/null +++ b/dist/utils/is-safe-path.d.ts @@ -0,0 +1,4 @@ +export default function isSafePath({ filePath, allowedDir, }: { + filePath: string; + allowedDir: string; +}): boolean; diff --git a/dist/utils/is-safe-path.js b/dist/utils/is-safe-path.js new file mode 100644 index 0000000..729607f --- /dev/null +++ b/dist/utils/is-safe-path.js @@ -0,0 +1,15 @@ +import { realpathSync } from "fs"; +import path from "path"; +export default function isSafePath({ filePath, allowedDir, }) { + const resolved = path.resolve(filePath); + if (!resolved.startsWith(allowedDir + path.sep) && resolved !== allowedDir) { + return false; + } + try { + const real = realpathSync(resolved); + return (real.startsWith(allowedDir + path.sep) || real === allowedDir); + } + catch { + return false; + } +} diff --git a/src/commands/start/index.ts b/src/commands/start/index.ts index 130118d..2146287 100644 --- a/src/commands/start/index.ts +++ b/src/commands/start/index.ts @@ -3,6 +3,10 @@ import path from "path"; import type { BunSpawnOptions } from "../../types"; import writeErrorFile from "../../functions/write-error-file"; +let retries = 0; +let timeout: any; +const MAX_RETRIES = 5; + export default function () { return new Command("start") .description("Start production server") @@ -12,6 +16,13 @@ export default function () { } async function start() { + clearTimeout(timeout); + + if (retries >= MAX_RETRIES) { + console.error(`Production server crashed ${MAX_RETRIES} times. Exiting.`); + process.exit(1); + } + const dev_spawn_file = path.resolve(__dirname, "prod-spawn.ts"); const spawn_options: BunSpawnOptions = { @@ -28,6 +39,12 @@ async function start() { let dev_process = Bun.spawn(spawn_options); + retries++; + + timeout = setTimeout(() => { + retries = 0; + }, 10000); + const exited = await dev_process.exited; if (exited) { diff --git a/src/functions/bundler/build-on-start-error-handler.ts b/src/functions/bundler/build-on-start-error-handler.ts index a864020..dc25906 100644 --- a/src/functions/bundler/build-on-start-error-handler.ts +++ b/src/functions/bundler/build-on-start-error-handler.ts @@ -9,7 +9,7 @@ export default async function buildOnstartErrorHandler(params?: Params) { global.RECOMPILING = false; global.IS_SERVER_COMPONENT = false; - Promise.all([ + await Promise.all([ global.SSR_BUNDLER_CTX?.dispose(), global.BUNDLER_CTX?.dispose(), ]); diff --git a/src/functions/bunext-init.ts b/src/functions/bunext-init.ts index 0bff623..680df65 100644 --- a/src/functions/bunext-init.ts +++ b/src/functions/bunext-init.ts @@ -5,7 +5,7 @@ import type { PageFiles, } from "../types"; import type { FileSystemRouter, Server } from "bun"; -import grabDirNames from "../utils/grab-dir-names"; +import grabDirNames, { type DirNames } from "../utils/grab-dir-names"; import { type FSWatcher } from "fs"; import init from "./init"; import isDevelopment from "../utils/is-development"; @@ -43,7 +43,7 @@ declare global { var BUNDLER_CTX: BuildContext | undefined; var SSR_BUNDLER_CTX: BuildContext | undefined; // var API_ROUTES_BUNDLER_CTX: BuildContext | undefined; - var DIR_NAMES: ReturnType; + var DIR_NAMES: DirNames; var REACT_IMPORTS_MAP: { imports: Record }; var REACT_DOM_SERVER: any; var REACT_DOM_MODULE_CACHE: Map; diff --git a/src/functions/cache/trim-all-cache.ts b/src/functions/cache/trim-all-cache.ts index 1f144f7..3725e7e 100644 --- a/src/functions/cache/trim-all-cache.ts +++ b/src/functions/cache/trim-all-cache.ts @@ -17,6 +17,11 @@ export default async function trimAllCache() { const trim_key = await trimCacheKey({ key: cache_key, }); + + if (trim_key.success) { + cached_items.splice(i, 1); + i--; + } } } catch (error) { return undefined; diff --git a/src/functions/server/bunext-req-handler.ts b/src/functions/server/bunext-req-handler.ts index 692ea2e..e21672e 100644 --- a/src/functions/server/bunext-req-handler.ts +++ b/src/functions/server/bunext-req-handler.ts @@ -8,6 +8,10 @@ import handleBunextPublicAssets from "./handle-bunext-public-assets"; import checkExcludedPatterns from "../../utils/check-excluded-patterns"; import { AppData } from "../../data/app-data"; import fullRebuild from "./full-rebuild"; + +const HMR_RETRY_COOLDOWN_MS = 5000; +let lastHmrRetryTime = 0; + type Params = { req: Request; server: Bun.Server; @@ -45,6 +49,11 @@ export default async function bunextRequestHandler({ } if (is_dev && url.pathname == AppData["BunextHMRRetryRoute"]) { + const now = Date.now(); + if (now - lastHmrRetryTime < HMR_RETRY_COOLDOWN_MS) { + return new Response("Too Many Requests", { status: 429 }); + } + lastHmrRetryTime = now; await fullRebuild({ msg: `HMR Retry Rebuild ...` }); return new Response("Modules Rebuilt"); } @@ -76,8 +85,12 @@ export default async function bunextRequestHandler({ return response; } catch (error: any) { - return new Response(`Server Error: ${error.message}`, { - status: 500, - }); + if (is_dev) { + return new Response(`Server Error: ${error.message}`, { + status: 500, + }); + } + console.error(`Server Error: ${error.message}`, error); + return new Response("Internal Server Error", { status: 500 }); } } diff --git a/src/functions/server/handle-bunext-public-assets.ts b/src/functions/server/handle-bunext-public-assets.ts index c9d5bef..eebdef2 100644 --- a/src/functions/server/handle-bunext-public-assets.ts +++ b/src/functions/server/handle-bunext-public-assets.ts @@ -2,6 +2,7 @@ import grabDirNames from "../../utils/grab-dir-names"; import path from "path"; import isDevelopment from "../../utils/is-development"; import { readFileResponse } from "./handle-public"; +import isSafePath from "../../utils/is-safe-path"; const { BUNEXT_PUBLIC_DIR } = grabDirNames(); @@ -19,7 +20,7 @@ export default async function ({ req }: Params): Promise { url.pathname.replace(/\/\.bunext\/public\//, ""), ); - if (!file_path.startsWith(BUNEXT_PUBLIC_DIR + path.sep)) { + if (!isSafePath({ filePath: file_path, allowedDir: BUNEXT_PUBLIC_DIR })) { return new Response("Forbidden", { status: 403 }); } diff --git a/src/functions/server/handle-files.ts b/src/functions/server/handle-files.ts index cce9bed..04c1905 100644 --- a/src/functions/server/handle-files.ts +++ b/src/functions/server/handle-files.ts @@ -2,6 +2,7 @@ import grabDirNames from "../../utils/grab-dir-names"; import path from "path"; import isDevelopment from "../../utils/is-development"; import { existsSync } from "fs"; +import isSafePath from "../../utils/is-safe-path"; const { PUBLIC_DIR } = grabDirNames(); @@ -15,7 +16,7 @@ export default async function ({ req }: Params): Promise { const url = new URL(req.url); const file_path = path.join(PUBLIC_DIR, url.pathname); - if (!file_path.startsWith(PUBLIC_DIR + path.sep)) { + if (!isSafePath({ filePath: file_path, allowedDir: PUBLIC_DIR })) { return new Response("Forbidden", { status: 403 }); } @@ -26,7 +27,13 @@ export default async function ({ req }: Params): Promise { } const file = Bun.file(file_path); - return new Response(file); + const headers = new Headers(); + + if (!is_dev) { + headers.set("Cache-Control", "public, max-age=3600"); + } + + return new Response(file, { headers }); } catch (error) { return new Response(`File Not Found`, { status: 404, diff --git a/src/functions/server/handle-hmr.ts b/src/functions/server/handle-hmr.ts index fcb9004..498fdfb 100644 --- a/src/functions/server/handle-hmr.ts +++ b/src/functions/server/handle-hmr.ts @@ -2,8 +2,28 @@ type Params = { req: Request; }; +function removeController(controller: ReadableStreamDefaultController) { + const idx = global.HMR_CONTROLLERS.findIndex( + (c) => c.controller == controller, + ); + if (typeof idx == "number" && idx >= 0) { + global.HMR_CONTROLLERS.splice(idx, 1); + } +} + export default async function ({ req }: Params): Promise { - const referer_url = new URL(req.headers.get("referer") || ""); + const referer = req.headers.get("referer"); + if (!referer) { + return new Response("Missing Referer Header", { status: 400 }); + } + + let referer_url: URL; + try { + referer_url = new URL(referer); + } catch { + return new Response("Invalid Referer Header", { status: 400 }); + } + const match = global.ROUTER.match(referer_url.pathname); const target_map = match?.filePath @@ -25,21 +45,13 @@ export default async function ({ req }: Params): Promise { c.enqueue(": keep-alive\n\n"); } catch { clearInterval(heartbeat); + removeController(controller); } }, 5000); }, cancel() { clearInterval(heartbeat); - const targetControllerIndex = global.HMR_CONTROLLERS.findIndex( - (c) => c.controller == controller, - ); - - if ( - typeof targetControllerIndex == "number" && - targetControllerIndex >= 0 - ) { - global.HMR_CONTROLLERS.splice(targetControllerIndex, 1); - } + removeController(controller); }, }); diff --git a/src/functions/server/handle-public.ts b/src/functions/server/handle-public.ts index 75a6208..80bc62f 100644 --- a/src/functions/server/handle-public.ts +++ b/src/functions/server/handle-public.ts @@ -2,6 +2,7 @@ import grabDirNames from "../../utils/grab-dir-names"; import path from "path"; import isDevelopment from "../../utils/is-development"; import { existsSync } from "fs"; +import isSafePath from "../../utils/is-safe-path"; const { PUBLIC_DIR } = grabDirNames(); @@ -19,7 +20,7 @@ export default async function ({ req }: Params): Promise { url.pathname.replace(/^\/public/, ""), ); - if (!file_path.startsWith(PUBLIC_DIR + path.sep)) { + if (!isSafePath({ filePath: file_path, allowedDir: PUBLIC_DIR })) { return new Response("Forbidden", { status: 403 }); } @@ -53,6 +54,8 @@ export function readFileResponse({ file_path, cache }: FileResponse) { headers.set("Cache-Control", "public, max-age=31536000, immutable"); } else if (cache?.duration) { headers.set("Cache-Control", `public, max-age=${cache.duration}`); + } else if (!isDevelopment()) { + headers.set("Cache-Control", "public, max-age=3600"); } return new Response(file, { diff --git a/src/functions/server/server-post-build-fn.ts b/src/functions/server/server-post-build-fn.ts index d92474a..bed8651 100644 --- a/src/functions/server/server-post-build-fn.ts +++ b/src/functions/server/server-post-build-fn.ts @@ -45,8 +45,8 @@ export default async function serverPostBuildFn(params?: Params) { continue; } - const mock_req = target_artifact.req - ? target_artifact.req.clone() + const mock_req = target_artifact.req_url + ? new Request(target_artifact.req_url) : new Request(controller.page_url); const page_component = global.IS_SERVER_COMPONENT diff --git a/src/functions/server/web-pages/generate-web-html.tsx b/src/functions/server/web-pages/generate-web-html.tsx index 9cf45dc..18232aa 100644 --- a/src/functions/server/web-pages/generate-web-html.tsx +++ b/src/functions/server/web-pages/generate-web-html.tsx @@ -178,16 +178,19 @@ export default async function genWebHTML({ console.info = () => {}; console.debug = () => {}; - const stream = await renderToReadableStream(final_component, { - onError(error: any) { - if (error.message.includes('unique "key" prop')) return; - originalConsole.error(error); - }, - }); + let htmlBody: string; + try { + const stream = await renderToReadableStream(final_component, { + onError(error: any) { + if (error.message.includes('unique "key" prop')) return; + originalConsole.error(error); + }, + }); - const htmlBody = await new Response(stream).text(); - - Object.assign(console, originalConsole); + htmlBody = await new Response(stream).text(); + } finally { + Object.assign(console, originalConsole); + } html += htmlBody; diff --git a/src/functions/server/web-pages/grab-page-combined-server-res.ts b/src/functions/server/web-pages/grab-page-combined-server-res.ts index 457989c..affd9c2 100644 --- a/src/functions/server/web-pages/grab-page-combined-server-res.ts +++ b/src/functions/server/web-pages/grab-page-combined-server-res.ts @@ -63,7 +63,7 @@ export default async function grabPageCombinedServerRes({ const page_server_ctx = global.SSR_BUNDLER_CTX_MAP[server_file_path || ""]; const final_page_server_path = page_server_ctx?.local_path ? path.join(ROOT_DIR, page_server_ctx.path) - : root_server_file_path; + : server_file_path; const server_module: BunextPageServerModule = final_page_server_path ? await import(`${final_page_server_path}?t=${now}`) diff --git a/src/functions/server/web-pages/grab-page-component.tsx b/src/functions/server/web-pages/grab-page-component.tsx index ce775e4..deb539a 100644 --- a/src/functions/server/web-pages/grab-page-component.tsx +++ b/src/functions/server/web-pages/grab-page-component.tsx @@ -117,7 +117,7 @@ export default async function grabPageComponent( } if (req && !is_hydration) { - global.BUNDLER_CTX_MAP[file_path].req = req; + global.BUNDLER_CTX_MAP[file_path].req_url = req.url; } if (debug) { diff --git a/src/types/index.ts b/src/types/index.ts index 2c0fb4c..f614f41 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -344,7 +344,7 @@ export type BundlerCTXMap = { url_path: string; file_name: string; css_path?: string; - req?: Request; + req_url?: string; }; export type GlobalHMRControllerObject = { diff --git a/src/utils/grab-dir-names.ts b/src/utils/grab-dir-names.ts index 2f42024..7b267fe 100644 --- a/src/utils/grab-dir-names.ts +++ b/src/utils/grab-dir-names.ts @@ -1,6 +1,38 @@ import path from "path"; -export default function grabDirNames() { +export type DirNames = { + ROOT_DIR: string; + SRC_DIR: string; + PAGES_DIR: string; + API_DIR: string; + PUBLIC_DIR: string; + HYDRATION_DST_DIR: string; + BUNX_CWD_DIR: string; + BUNX_ROOT_DIR: string; + CONFIG_FILE: string; + BUNX_TMP_DIR: string; + BUNX_HYDRATION_SRC_DIR: string; + BUNX_ROOT_SRC_DIR: string; + BUNX_ROOT_PRESETS_DIR: string; + BUNX_ROOT_500_PRESET_COMPONENT: string; + BUNX_ROOT_500_FILE_NAME: string; + BUNX_ROOT_404_PRESET_COMPONENT: string; + BUNX_ROOT_404_FILE_NAME: string; + HYDRATION_DST_DIR_MAP_JSON_FILE: string; + BUNEXT_CACHE_DIR: string; + BUNX_CWD_MODULE_CACHE_DIR: string; + BUNX_CWD_PAGES_REWRITE_DIR: string; + HYDRATION_DST_DIR_MAP_JSON_FILE_NAME: string; + BUNEXT_VENDOR_DIR: string; + BUNEXT_PUBLIC_DIR: string; + BUNX_BUNDLER_ERROR_EXIT_FILE: string; + BUNX_ERROR_LOGS_DIR: string; + BUNX_LOGS_DIR: string; +}; + +export default function grabDirNames(): DirNames { + if (global.DIR_NAMES) return global.DIR_NAMES; + const ROOT_DIR = process.cwd(); const SRC_DIR = path.join(ROOT_DIR, "src"); const PAGES_DIR = path.join(SRC_DIR, "pages"); diff --git a/src/utils/is-development.ts b/src/utils/is-development.ts index fa73505..db28098 100644 --- a/src/utils/is-development.ts +++ b/src/utils/is-development.ts @@ -1,13 +1,7 @@ export default function isDevelopment() { - const config = global.CONFIG; - - if (process.env.NODE_ENV == "production") { + if (process.env.NODE_ENV === "production") { return false; } - if (config.development) { - return true; - } - - return false; + return Boolean(global.CONFIG?.development); } diff --git a/src/utils/is-safe-path.ts b/src/utils/is-safe-path.ts new file mode 100644 index 0000000..4b44457 --- /dev/null +++ b/src/utils/is-safe-path.ts @@ -0,0 +1,24 @@ +import { realpathSync } from "fs"; +import path from "path"; + +export default function isSafePath({ + filePath, + allowedDir, +}: { + filePath: string; + allowedDir: string; +}): boolean { + const resolved = path.resolve(filePath); + if (!resolved.startsWith(allowedDir + path.sep) && resolved !== allowedDir) { + return false; + } + + try { + const real = realpathSync(resolved); + return ( + real.startsWith(allowedDir + path.sep) || real === allowedDir + ); + } catch { + return false; + } +}