Fix security vulnerabilities and request handling bugs

- Move page cache out of the publicly served .bunext/public dir and hash cache keys
- Skip caching credentialed requests, non-2xx and Set-Cookie responses; cap entries
- Enforce API body size limit while reading; keep req body readable for handlers
- Hide error messages from clients in production
- Escape inline script JSON; strip cookies from HMR payloads
- Only trust X-Forwarded-Proto with trust_proxy config
- Fix console race in SSR, dotted page paths, middleware rewrites,
  excluded path status, encoded static file names, symlinked dirs
- Upgrade vulnerable deps, remove micromatch, pin bun versions, delete dead code

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-13 15:48:08 +01:00
co-authored by Claude Opus 5.5
parent 4238d9a4ea
commit 74103a19c9
37 changed files with 708 additions and 573 deletions
+24 -18
View File
@@ -1,11 +1,17 @@
import { describe, it, expect } from "bun:test";
import grabCacheNames from "../../../functions/cache/grab-cache-names";
const sha256 = (s: string) =>
new Bun.CryptoHasher("sha256").update(s).digest("hex");
describe("grabCacheNames", () => {
it("returns cache_name and cache_meta_name for a simple key", () => {
const { cache_name, cache_meta_name } = grabCacheNames({ key: "home" });
expect(cache_name).toBe("home.res.html");
expect(cache_meta_name).toBe("home.meta.json");
it("returns hashed cache_name and cache_meta_name for a simple key", () => {
const { cache_name, cache_meta_name, cache_id } = grabCacheNames({
key: "home",
});
expect(cache_id).toBe(sha256("home"));
expect(cache_name).toBe(`${sha256("home")}.res.html`);
expect(cache_meta_name).toBe(`${sha256("home")}.meta.json`);
});
it("defaults paradigm to html", () => {
@@ -15,22 +21,22 @@ describe("grabCacheNames", () => {
it("uses json paradigm when specified", () => {
const { cache_name } = grabCacheNames({ key: "api-data", paradigm: "json" });
expect(cache_name).toBe("api-data.res.json");
expect(cache_name).toBe(`${sha256("api-data")}.res.json`);
});
it("URL-encodes the key", () => {
const { cache_name, cache_meta_name } = grabCacheNames({
key: "/blog/hello world",
});
const encoded = encodeURIComponent("/blog/hello world");
expect(cache_name).toBe(`${encoded}.res.html`);
expect(cache_meta_name).toBe(`${encoded}.meta.json`);
});
it("handles keys with special characters", () => {
const key = "page?id=1&sort=asc";
it("produces fixed-length, path-safe names for long or special keys", () => {
const key = "/blog/hello world?q=" + "x".repeat(1000) + "&a=../../etc";
const { cache_name } = grabCacheNames({ key });
expect(cache_name).toBe(`${encodeURIComponent(key)}.res.html`);
expect(cache_name).toMatch(/^[0-9a-f]{64}\.res\.html$/);
});
it("uses cache_id as-is when given", () => {
const { cache_name, cache_meta_name } = grabCacheNames({
cache_id: "abc",
paradigm: "json",
});
expect(cache_name).toBe("abc.res.json");
expect(cache_meta_name).toBe("abc.meta.json");
});
it("cache_meta_name always uses .meta.json regardless of paradigm", () => {
@@ -38,6 +44,6 @@ describe("grabCacheNames", () => {
key: "test",
paradigm: "json",
});
expect(cache_meta_name).toBe("test.meta.json");
expect(cache_meta_name).toBe(`${sha256("test")}.meta.json`);
});
});
@@ -27,7 +27,10 @@ mock.module("../../../../src/functions/server/handle-public", () => ({
}));
mock.module("../../../../src/functions/server/handle-files", () => ({
default: async () => new Response("files")
default: async ({ req }: any) =>
new URL(req.url).pathname.includes("john.doe")
? new Response("missing", { status: 404 })
: new Response("files")
}));
mock.module("../../../../src/functions/server/web-pages/handle-web-pages", () => ({
@@ -42,10 +45,13 @@ describe("bunext-req-handler", () => {
if (url.pathname === "/blocked") {
return new Response("Blocked by middleware", { status: 403 });
}
if (url.pathname === "/rewrite-me") {
return new Request("http://localhost/api/rewritten");
}
return undefined;
}
},
RouteIgnorePatterns: [],
RouteIgnorePatterns: [/\/\(/],
} as any;
});
@@ -93,4 +99,26 @@ describe("bunext-req-handler", () => {
const res = await bunextRequestHandler({ req });
expect(await res.text()).toBe("web-pages");
});
test("excluded paths return 404", async () => {
const req = new Request("http://localhost/(group)/page");
const res = await bunextRequestHandler({ req });
expect(res.status).toBe(404);
});
test("routes using the request returned by middleware", async () => {
const req = new Request("http://localhost/rewrite-me");
const res = await bunextRequestHandler({ req });
expect(await res.text()).toBe("api-routes");
});
test("falls back to pages for dotted paths that are not files", async () => {
global.BUNEXT_ROUTER = {
match: (p: string) =>
p === "/users/john.doe" ? { filePath: "/users/[id].tsx" } : null,
} as any;
const req = new Request("http://localhost/users/john.doe");
const res = await bunextRequestHandler({ req });
expect(await res.text()).toBe("web-pages");
});
});
+5 -4
View File
@@ -21,11 +21,12 @@ describe("grabDirNames", () => {
);
});
it("nests BUNEXT_CACHE_DIR under .bunext/public/cache", () => {
it("keeps BUNEXT_CACHE_DIR outside the publicly served directory", () => {
const dirs = grabDirNames();
expect(dirs.BUNEXT_CACHE_DIR).toBe(
path.join(dirs.BUNX_CWD_DIR, "public", "cache"),
);
expect(dirs.BUNEXT_CACHE_DIR).toBe(path.join(dirs.BUNX_CWD_DIR, "cache"));
expect(
dirs.BUNEXT_CACHE_DIR.startsWith(dirs.BUNEXT_PUBLIC_DIR + path.sep),
).toBe(false);
});
it("places map JSON file inside HYDRATION_DST_DIR", () => {
@@ -0,0 +1,48 @@
import { describe, it, expect } from "bun:test";
import grabRouteParams from "../../utils/grab-route-params";
import { RequestBodyTooLargeError } from "../../utils/read-request-body";
describe("grabRouteParams", () => {
it("parses a JSON body and keeps the request body readable", async () => {
const req = new Request("http://localhost/api/test?x=1", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ a: 1 }),
});
const params = await grabRouteParams({ req, max_body_bytes: 1000 });
expect(params.body).toEqual({ a: 1 });
expect(params.query.x).toBe("1");
expect(await params.req.text()).toBe('{"a":1}');
});
it("returns an empty body object for non-JSON bodies", async () => {
const req = new Request("http://localhost/api/test", {
method: "POST",
body: "raw text",
});
const params = await grabRouteParams({ req, max_body_bytes: 1000 });
expect(params.body).toEqual({});
expect(await params.req.text()).toBe("raw text");
});
it("leaves the body undefined for GET requests", async () => {
const req = new Request("http://localhost/api/test");
const params = await grabRouteParams({ req, max_body_bytes: 1000 });
expect(params.body).toBeUndefined();
});
it("throws when the body is larger than the limit", async () => {
const req = new Request("http://localhost/api/test", {
method: "POST",
body: "x".repeat(2000),
});
expect(
grabRouteParams({ req, max_body_bytes: 1000 }),
).rejects.toBeInstanceOf(RequestBodyTooLargeError);
});
});
+52
View File
@@ -0,0 +1,52 @@
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "fs";
import os from "os";
import path from "path";
import isSafePath from "../../utils/is-safe-path";
let tmp: string;
let real_public: string;
let linked_public: string;
beforeAll(() => {
tmp = mkdtempSync(path.join(os.tmpdir(), "bunext-safe-path-"));
real_public = path.join(tmp, "real", "public");
mkdirSync(real_public, { recursive: true });
writeFileSync(path.join(real_public, "file name.txt"), "ok");
writeFileSync(path.join(tmp, "secret.txt"), "secret");
symlinkSync(path.join(tmp, "real"), path.join(tmp, "link"));
linked_public = path.join(tmp, "link", "public");
});
afterAll(() => {
rmSync(tmp, { recursive: true, force: true });
});
describe("isSafePath", () => {
it("allows files inside the directory", () => {
expect(
isSafePath({
filePath: path.join(real_public, "file name.txt"),
allowedDir: real_public,
}),
).toBe(true);
});
it("rejects traversal outside the directory", () => {
expect(
isSafePath({
filePath: path.join(real_public, "..", "..", "secret.txt"),
allowedDir: real_public,
}),
).toBe(false);
});
it("allows files when the directory itself is under a symlink", () => {
expect(
isSafePath({
filePath: path.join(linked_public, "file name.txt"),
allowedDir: linked_public,
}),
).toBe(true);
});
});
@@ -0,0 +1,60 @@
import { describe, it, expect } from "bun:test";
import readRequestBody, {
RequestBodyTooLargeError,
} from "../../utils/read-request-body";
function chunkedRequest(chunks: string[]) {
const stream = new ReadableStream({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(new TextEncoder().encode(chunk));
}
controller.close();
},
});
return new Request("http://localhost/api/test", {
method: "POST",
body: stream,
});
}
describe("readRequestBody", () => {
it("returns undefined for GET requests", async () => {
const req = new Request("http://localhost/");
expect(await readRequestBody({ req, max_bytes: 10 })).toBeUndefined();
});
it("returns the body bytes", async () => {
const req = new Request("http://localhost/", {
method: "POST",
body: "hello",
});
const body = await readRequestBody({ req, max_bytes: 10 });
expect(new TextDecoder().decode(body)).toBe("hello");
});
it("rejects a too-large Content-Length without reading the body", async () => {
const req = new Request("http://localhost/", {
method: "POST",
headers: { "content-length": "2000000" },
body: "x",
});
expect(readRequestBody({ req, max_bytes: 1000 })).rejects.toBeInstanceOf(
RequestBodyTooLargeError,
);
});
it("enforces the limit on chunked bodies without a Content-Length", async () => {
const req = chunkedRequest(["x".repeat(600), "x".repeat(600)]);
expect(readRequestBody({ req, max_bytes: 1000 })).rejects.toBeInstanceOf(
RequestBodyTooLargeError,
);
});
it("reads chunked bodies within the limit", async () => {
const req = chunkedRequest(['{"a":', "1}"]);
const body = await readRequestBody({ req, max_bytes: 1000 });
expect(new TextDecoder().decode(body)).toBe('{"a":1}');
});
});
@@ -0,0 +1,24 @@
import { describe, it, expect } from "bun:test";
import serializeForScript from "../../utils/serialize-for-script";
describe("serializeForScript", () => {
it("escapes sequences that could break out of a script tag", () => {
const out = serializeForScript({
a: "</script><script>alert(1)</script>",
b: "<!--",
c: "&amp;",
d: "

",
});
expect(out).not.toContain("<");
expect(out).not.toContain(">");
expect(out).not.toContain("&");
expect(out).not.toContain("
");
expect(out).not.toContain("
");
});
it("round-trips through JSON.parse", () => {
const value = { a: "</script><!--", b: [1, "&"], c: "
" };
expect(JSON.parse(serializeForScript(value))).toEqual(value);
});
});