Fix security vulnerabilities

This commit is contained in:
2026-10-13 15:52:34 +01:00
parent 74103a19c9
commit eefb2ea5d2
46 changed files with 375 additions and 403 deletions
+1 -1
View File
@@ -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 {
+2 -1
View File
@@ -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, "../../");
+9 -1
View File
@@ -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<BunxRouteParams>;
/**
* Throws `RequestBodyTooLargeError` if the body exceeds `max_body_bytes`.
*/
export default function grabRouteParams({ req: initial_req, query: passed_query, max_body_bytes, }: Params): Promise<BunxRouteParams>;
export {};
+28 -4
View File
@@ -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 {};
+24 -3
View File
@@ -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;
}
}
+15
View File
@@ -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<Uint8Array<ArrayBuffer> | undefined>;
export {};
+42
View File
@@ -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;
}
+8
View File
@@ -0,0 +1,8 @@
/**
* # Serialize a value as JSON that is safe to embed in an inline `<script>`
*
* Escapes `<`, `>`, `&`, U+2028 and U+2029 so the payload can never close
* the script tag (`</script>`), open an HTML comment (`<!--`) or otherwise
* change the HTML parser state.
*/
export default function serializeForScript(value: any): string;
+16
View File
@@ -0,0 +1,16 @@
/**
* # Serialize a value as JSON that is safe to embed in an inline `<script>`
*
* Escapes `<`, `>`, `&`, U+2028 and U+2029 so the payload can never close
* the script tag (`</script>`), open an HTML comment (`<!--`) or otherwise
* change the HTML parser state.
*/
export default function serializeForScript(value) {
const json = JSON.stringify(value ?? null) ?? "null";
return json
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e")
.replace(/&/g, "\\u0026")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
}