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:
+24
-18
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: "&",
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user