@bractjs/bractjs 0.1.0 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -13
- package/package.json +4 -1
- package/src/__tests__/action-handler.test.ts +47 -0
- package/src/__tests__/action-registry.test.ts +73 -0
- package/src/__tests__/codegen.test.ts +50 -0
- package/src/__tests__/deferred.test.ts +96 -0
- package/src/__tests__/directives.test.ts +52 -0
- package/src/__tests__/env.test.ts +73 -0
- package/src/__tests__/errors.test.ts +113 -0
- package/src/__tests__/hash.test.ts +19 -0
- package/src/__tests__/integration.test.ts +1 -1
- package/src/__tests__/manifest.test.ts +60 -0
- package/src/__tests__/middleware.test.ts +216 -0
- package/src/__tests__/response.test.ts +106 -0
- package/src/__tests__/security.test.ts +348 -0
- package/src/__tests__/session.test.ts +3 -3
- package/src/build/bundler.ts +15 -5
- package/src/build/directives.ts +30 -3
- package/src/build/env-plugin.ts +1 -0
- package/src/build/hash.ts +0 -20
- package/src/client/ClientRouter.tsx +8 -4
- package/src/codegen/route-codegen.ts +33 -9
- package/src/dev/hmr-module-handler.ts +14 -4
- package/src/image/cache.ts +28 -8
- package/src/image/handler.ts +26 -11
- package/src/image/optimizer.ts +45 -13
- package/src/image/types.ts +1 -0
- package/src/middleware/cors.ts +24 -8
- package/src/server/action-handler.ts +40 -1
- package/src/server/action-registry.ts +14 -1
- package/src/server/csrf.ts +16 -0
- package/src/server/env.ts +10 -4
- package/src/server/middleware.ts +11 -7
- package/src/server/render.ts +7 -5
- package/src/server/request-handler.ts +14 -13
- package/src/server/response.ts +29 -5
- package/src/server/scanner.ts +6 -2
- package/src/server/session.ts +16 -5
- package/src/server/static.ts +23 -7
- package/templates/new-app/app/root.tsx +1 -1
- package/templates/new-app/app/routes/_index.tsx +3 -3
- package/templates/new-app/app/routes/about.tsx +1 -1
- package/templates/new-app/bractjs.config.ts +1 -1
- package/templates/new-app/package.json +1 -1
package/src/image/cache.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
-
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { mkdir, rename, unlink } from "node:fs/promises";
|
|
3
3
|
import type { ImageTransformParams, TransformResult, ImageFormat } from "./types.ts";
|
|
4
4
|
|
|
5
5
|
const MAX_MEM = 200;
|
|
@@ -50,10 +50,14 @@ export async function getFromDisk(
|
|
|
50
50
|
const key = await cacheKey(src, params);
|
|
51
51
|
const metaFile = Bun.file(join(dir, `${key}.json`));
|
|
52
52
|
const dataFile = Bun.file(join(dir, `${key}.bin`));
|
|
53
|
-
|
|
53
|
+
// No existence pre-check: it would create a TOCTOU race where the file is
|
|
54
|
+
// deleted between exists() and read(). Just attempt the reads and let either
|
|
55
|
+
// a missing file or invalid JSON fall through to MISS.
|
|
54
56
|
try {
|
|
55
|
-
const meta = await
|
|
56
|
-
|
|
57
|
+
const [meta, data] = await Promise.all([
|
|
58
|
+
metaFile.json() as Promise<{ contentType: string; format: ImageFormat }>,
|
|
59
|
+
dataFile.arrayBuffer(),
|
|
60
|
+
]);
|
|
57
61
|
return { data, contentType: meta.contentType, format: meta.format };
|
|
58
62
|
} catch {
|
|
59
63
|
return null;
|
|
@@ -68,8 +72,24 @@ export async function setOnDisk(
|
|
|
68
72
|
): Promise<void> {
|
|
69
73
|
await mkdir(dir, { recursive: true });
|
|
70
74
|
const key = await cacheKey(src, params);
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
+
const jsonFinal = join(dir, `${key}.json`);
|
|
76
|
+
const binFinal = join(dir, `${key}.bin`);
|
|
77
|
+
const jsonTmp = `${jsonFinal}.tmp`;
|
|
78
|
+
const binTmp = `${binFinal}.tmp`;
|
|
79
|
+
// Write both temp files, then atomically rename. Readers see either both
|
|
80
|
+
// files present or neither — never a half-written pair.
|
|
81
|
+
try {
|
|
82
|
+
await Promise.all([
|
|
83
|
+
Bun.write(jsonTmp, JSON.stringify({ contentType: result.contentType, format: result.format })),
|
|
84
|
+
Bun.write(binTmp, result.data),
|
|
85
|
+
]);
|
|
86
|
+
await Promise.all([rename(jsonTmp, jsonFinal), rename(binTmp, binFinal)]);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
// Best-effort cleanup so failed writes don't leak .tmp files indefinitely.
|
|
89
|
+
await Promise.all([
|
|
90
|
+
unlink(jsonTmp).catch(() => {}),
|
|
91
|
+
unlink(binTmp).catch(() => {}),
|
|
92
|
+
]);
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
75
95
|
}
|
package/src/image/handler.ts
CHANGED
|
@@ -1,36 +1,51 @@
|
|
|
1
|
-
import { join, resolve } from "node:path";
|
|
1
|
+
import { join, resolve, sep } from "node:path";
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
2
3
|
import type { ImageTransformParams, ImageFormat, ImageFit } from "./types.ts";
|
|
3
|
-
import { QUALITY_DEFAULT, FORMAT_DEFAULT, FIT_DEFAULT, MIME } from "./types.ts";
|
|
4
|
+
import { QUALITY_DEFAULT, FORMAT_DEFAULT, FIT_DEFAULT, MIME, ALLOWED_FITS } from "./types.ts";
|
|
4
5
|
import { transformImage } from "./optimizer.ts";
|
|
5
6
|
import { getFromMemory, setInMemory, getFromDisk, setOnDisk } from "./cache.ts";
|
|
6
7
|
|
|
7
|
-
const
|
|
8
|
+
const ALLOWED_DIMS = new Set([320, 640, 768, 1024, 1280, 1536, 1920, 3840]);
|
|
9
|
+
const MAX_AREA = 4_000_000;
|
|
8
10
|
const CACHE_CTRL = "public, max-age=31536000, immutable";
|
|
9
11
|
|
|
10
|
-
function parseParams(
|
|
12
|
+
async function parseParams(
|
|
11
13
|
sp: URLSearchParams,
|
|
12
14
|
publicDir: string,
|
|
13
|
-
): { src: string; filePath: string; params: ImageTransformParams } | null {
|
|
15
|
+
): Promise<{ src: string; filePath: string; params: ImageTransformParams } | null> {
|
|
14
16
|
const src = sp.get("src");
|
|
15
17
|
// src must be a /public/ path with no traversal sequences
|
|
16
18
|
if (!src || !src.startsWith("/public/") || src.includes("..")) return null;
|
|
17
19
|
|
|
18
20
|
const rel = src.slice("/public/".length);
|
|
19
21
|
const root = resolve(publicDir);
|
|
20
|
-
const
|
|
21
|
-
if (!
|
|
22
|
+
const candidate = resolve(join(root, rel));
|
|
23
|
+
if (!candidate.startsWith(root + sep) && candidate !== root) return null;
|
|
24
|
+
// Re-check after symlink resolution. If the file doesn't exist yet, realpath
|
|
25
|
+
// throws — fall through and let the existence check below handle it.
|
|
26
|
+
let filePath = candidate;
|
|
27
|
+
try {
|
|
28
|
+
const real = await realpath(candidate);
|
|
29
|
+
if (!real.startsWith(root + sep) && real !== root) return null;
|
|
30
|
+
filePath = real;
|
|
31
|
+
} catch {
|
|
32
|
+
// missing file: defer to Bun.file(...).exists() below
|
|
33
|
+
}
|
|
22
34
|
|
|
23
35
|
const wRaw = sp.get("w");
|
|
24
36
|
const hRaw = sp.get("h");
|
|
25
37
|
const w = wRaw ? parseInt(wRaw, 10) : undefined;
|
|
26
38
|
const h = hRaw ? parseInt(hRaw, 10) : undefined;
|
|
27
|
-
if (w !== undefined && (isNaN(w) || w
|
|
28
|
-
if (h !== undefined && (isNaN(h) || h
|
|
39
|
+
if (w !== undefined && (isNaN(w) || !ALLOWED_DIMS.has(w))) return null;
|
|
40
|
+
if (h !== undefined && (isNaN(h) || !ALLOWED_DIMS.has(h))) return null;
|
|
41
|
+
if (w !== undefined && h !== undefined && w * h > MAX_AREA) return null;
|
|
29
42
|
|
|
30
43
|
const q = Math.min(100, Math.max(1, parseInt(sp.get("q") ?? String(QUALITY_DEFAULT), 10)));
|
|
31
44
|
const fmt = (sp.get("format") ?? FORMAT_DEFAULT) as ImageFormat;
|
|
32
|
-
const
|
|
45
|
+
const fitRaw = sp.get("fit") ?? FIT_DEFAULT;
|
|
33
46
|
if (!MIME[fmt]) return null;
|
|
47
|
+
if (!ALLOWED_FITS.has(fitRaw as ImageFit)) return null;
|
|
48
|
+
const fit = fitRaw as ImageFit;
|
|
34
49
|
|
|
35
50
|
return { src, filePath, params: { w, h, q, format: fmt, fit } };
|
|
36
51
|
}
|
|
@@ -53,7 +68,7 @@ export async function handleImageRequest(
|
|
|
53
68
|
const url = new URL(request.url);
|
|
54
69
|
if (url.pathname !== "/_image") return null;
|
|
55
70
|
|
|
56
|
-
const parsed = parseParams(url.searchParams, publicDir);
|
|
71
|
+
const parsed = await parseParams(url.searchParams, publicDir);
|
|
57
72
|
if (!parsed) return new Response("Bad Request", { status: 400 });
|
|
58
73
|
|
|
59
74
|
const { src, filePath, params } = parsed;
|
package/src/image/optimizer.ts
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
import type { ImageTransformParams, TransformResult, ImageFormat } from "./types.ts";
|
|
2
2
|
import { MIME } from "./types.ts";
|
|
3
3
|
|
|
4
|
+
// In-process semaphore (DoS guard): cap concurrent ImageMagick spawns so a
|
|
5
|
+
// burst of /_image requests can't fork-bomb the server.
|
|
6
|
+
const MAX_CONCURRENT = 4;
|
|
7
|
+
// Per-spawn timeout (ms). A pathological input must not hold a slot forever;
|
|
8
|
+
// without this, four hung spawns wedge the whole image pipeline.
|
|
9
|
+
const SPAWN_TIMEOUT_MS = 15_000;
|
|
10
|
+
let inFlight = 0;
|
|
11
|
+
const waiters: Array<() => void> = [];
|
|
12
|
+
|
|
13
|
+
async function acquireSlot(): Promise<void> {
|
|
14
|
+
if (inFlight < MAX_CONCURRENT) {
|
|
15
|
+
inFlight++;
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
await new Promise<void>((resolve) => waiters.push(resolve));
|
|
19
|
+
inFlight++;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function releaseSlot(): void {
|
|
23
|
+
inFlight--;
|
|
24
|
+
const next = waiters.shift();
|
|
25
|
+
if (next) next();
|
|
26
|
+
}
|
|
27
|
+
|
|
4
28
|
// Probe for an available ImageMagick binary once, then cache the result.
|
|
5
29
|
let _binary: string | null | undefined;
|
|
6
30
|
|
|
@@ -57,20 +81,28 @@ export async function transformImage(
|
|
|
57
81
|
return { data, contentType: MIME[fmt] ?? "image/jpeg", format: fmt };
|
|
58
82
|
}
|
|
59
83
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
84
|
+
await acquireSlot();
|
|
85
|
+
try {
|
|
86
|
+
const proc = Bun.spawn(buildArgs(binary, filePath, params), {
|
|
87
|
+
stdout: "pipe",
|
|
88
|
+
stderr: "ignore",
|
|
89
|
+
timeout: SPAWN_TIMEOUT_MS,
|
|
90
|
+
killSignal: "SIGKILL",
|
|
91
|
+
});
|
|
64
92
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
]);
|
|
93
|
+
const [data, exitCode] = await Promise.all([
|
|
94
|
+
new Response(proc.stdout!).arrayBuffer(),
|
|
95
|
+
proc.exited,
|
|
96
|
+
]);
|
|
70
97
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
98
|
+
if (exitCode !== 0) {
|
|
99
|
+
// Non-zero exit covers normal failures AND timeout-induced SIGKILL,
|
|
100
|
+
// since Bun reports the signal as a non-zero exit code.
|
|
101
|
+
throw new Error(`[bractjs] ImageMagick exited ${exitCode} for ${filePath}`);
|
|
102
|
+
}
|
|
74
103
|
|
|
75
|
-
|
|
104
|
+
return { data, contentType: MIME[params.format], format: params.format };
|
|
105
|
+
} finally {
|
|
106
|
+
releaseSlot();
|
|
107
|
+
}
|
|
76
108
|
}
|
package/src/image/types.ts
CHANGED
package/src/middleware/cors.ts
CHANGED
|
@@ -3,25 +3,39 @@ import type { MiddlewareFn } from "../server/middleware.ts";
|
|
|
3
3
|
export interface CorsOptions {
|
|
4
4
|
origin: string | string[];
|
|
5
5
|
methods?: string[];
|
|
6
|
+
credentials?: boolean;
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Sets CORS headers. Handles OPTIONS preflight with 204.
|
|
11
|
+
*
|
|
12
|
+
* Never reflects the Origin header when "*" is configured — emits literal "*".
|
|
13
|
+
* Refuses to combine credentials:true with "*" (browsers reject it anyway).
|
|
14
|
+
* Always sets `Vary: Origin` so caches don't serve a cross-origin response to
|
|
15
|
+
* the wrong site.
|
|
10
16
|
*/
|
|
11
17
|
export function cors(options: CorsOptions): MiddlewareFn {
|
|
12
|
-
const allowedOrigins = Array.isArray(options.origin)
|
|
13
|
-
? options.origin
|
|
14
|
-
: [options.origin];
|
|
18
|
+
const allowedOrigins = Array.isArray(options.origin) ? options.origin : [options.origin];
|
|
15
19
|
const allowedMethods = options.methods?.join(", ") ?? "GET, POST, PUT, DELETE, PATCH, OPTIONS";
|
|
20
|
+
const wildcard = allowedOrigins.includes("*");
|
|
21
|
+
const credentials = options.credentials === true;
|
|
22
|
+
if (wildcard && credentials) {
|
|
23
|
+
throw new Error("cors: credentials=true cannot be combined with origin='*'");
|
|
24
|
+
}
|
|
16
25
|
|
|
17
26
|
return async (ctx, next) => {
|
|
18
27
|
const origin = ctx.request.headers.get("Origin") ?? "";
|
|
19
|
-
const allowed = allowedOrigins.includes("*") || allowedOrigins.includes(origin);
|
|
20
28
|
const corsHeaders: Record<string, string> = {
|
|
21
29
|
"Access-Control-Allow-Methods": allowedMethods,
|
|
22
30
|
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
31
|
+
"Vary": "Origin",
|
|
23
32
|
};
|
|
24
|
-
if (
|
|
33
|
+
if (wildcard) {
|
|
34
|
+
corsHeaders["Access-Control-Allow-Origin"] = "*";
|
|
35
|
+
} else if (origin && allowedOrigins.includes(origin)) {
|
|
36
|
+
corsHeaders["Access-Control-Allow-Origin"] = origin;
|
|
37
|
+
}
|
|
38
|
+
if (credentials) corsHeaders["Access-Control-Allow-Credentials"] = "true";
|
|
25
39
|
|
|
26
40
|
// Preflight
|
|
27
41
|
if (ctx.request.method === "OPTIONS") {
|
|
@@ -29,8 +43,10 @@ export function cors(options: CorsOptions): MiddlewareFn {
|
|
|
29
43
|
}
|
|
30
44
|
|
|
31
45
|
const response = await next();
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
46
|
+
// Mutate headers in place rather than wrapping body. Wrapping with
|
|
47
|
+
// `new Response(response.body, response)` makes the original Response
|
|
48
|
+
// unusable to anyone holding a reference (single-shot stream).
|
|
49
|
+
for (const [k, v] of Object.entries(corsHeaders)) response.headers.set(k, v);
|
|
50
|
+
return response;
|
|
35
51
|
};
|
|
36
52
|
}
|
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
import { resolveAction } from "./action-registry.ts";
|
|
2
2
|
import { json } from "./response.ts";
|
|
3
|
+
import { isAllowedMutation } from "./csrf.ts";
|
|
4
|
+
|
|
5
|
+
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
6
|
+
// Cap action JSON bodies. Anything over this looks like an abuse attempt;
|
|
7
|
+
// FormData uploads (large files) take the multipart branch and bypass this.
|
|
8
|
+
const MAX_JSON_BODY_BYTES = 1_048_576; // 1 MiB
|
|
9
|
+
|
|
10
|
+
function hasForbiddenKey(value: unknown): boolean {
|
|
11
|
+
if (!value || typeof value !== "object") return false;
|
|
12
|
+
for (const key of Object.keys(value as Record<string, unknown>)) {
|
|
13
|
+
if (FORBIDDEN_KEYS.has(key)) return true;
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
3
17
|
|
|
4
18
|
export async function handleActionRequest(request: Request): Promise<Response | null> {
|
|
5
19
|
const url = new URL(request.url);
|
|
6
20
|
if (!url.pathname.startsWith("/_action")) return null;
|
|
7
21
|
if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405 });
|
|
22
|
+
if (!isAllowedMutation(request)) return new Response("Forbidden", { status: 403 });
|
|
8
23
|
|
|
9
24
|
const id = url.searchParams.get("id");
|
|
10
25
|
if (!id) return new Response("Bad Request: missing action id", { status: 400 });
|
|
@@ -18,8 +33,32 @@ export async function handleActionRequest(request: Request): Promise<Response |
|
|
|
18
33
|
if (ct.includes("multipart/form-data") || ct.includes("application/x-www-form-urlencoded")) {
|
|
19
34
|
args = [await request.formData()];
|
|
20
35
|
} else {
|
|
36
|
+
// Cheap pre-check: trust Content-Length if the client sent one.
|
|
37
|
+
const clRaw = request.headers.get("Content-Length");
|
|
38
|
+
if (clRaw) {
|
|
39
|
+
const cl = Number(clRaw);
|
|
40
|
+
if (Number.isFinite(cl) && cl > MAX_JSON_BODY_BYTES) {
|
|
41
|
+
return new Response("Payload Too Large", { status: 413 });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
21
44
|
const text = await request.text();
|
|
22
|
-
|
|
45
|
+
// Defense in depth: clients can lie about Content-Length, so verify the
|
|
46
|
+
// actual decoded text length too.
|
|
47
|
+
if (text.length > MAX_JSON_BODY_BYTES) {
|
|
48
|
+
return new Response("Payload Too Large", { status: 413 });
|
|
49
|
+
}
|
|
50
|
+
if (!text) {
|
|
51
|
+
args = [];
|
|
52
|
+
} else {
|
|
53
|
+
const parsed: unknown = JSON.parse(text);
|
|
54
|
+
if (!Array.isArray(parsed)) {
|
|
55
|
+
return new Response("Bad Request: args must be array", { status: 400 });
|
|
56
|
+
}
|
|
57
|
+
if (parsed.some(hasForbiddenKey)) {
|
|
58
|
+
return new Response("Bad Request: forbidden keys", { status: 400 });
|
|
59
|
+
}
|
|
60
|
+
args = parsed;
|
|
61
|
+
}
|
|
23
62
|
}
|
|
24
63
|
} catch {
|
|
25
64
|
return new Response("Bad Request: invalid body", { status: 400 });
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// Anchored at start-of-file. Allow whitespace and line/block comments before
|
|
4
|
+
// the "use server" string literal. This prevents false matches from a "use
|
|
5
|
+
// server" found inside template literals or runtime strings.
|
|
6
|
+
const SERVER_RE = /^(?:\s|\/\/[^\n]*\n|\/\*[\s\S]*?\*\/)*["']use server["']/;
|
|
4
7
|
const registry = new Map<string, (...args: unknown[]) => Promise<unknown>>();
|
|
5
8
|
|
|
6
9
|
async function computeId(filePath: string, name: string): Promise<string> {
|
|
@@ -16,9 +19,19 @@ export function resolveAction(id: string): ((...args: unknown[]) => Promise<unkn
|
|
|
16
19
|
return registry.get(id) ?? null;
|
|
17
20
|
}
|
|
18
21
|
|
|
22
|
+
function isEligible(rel: string): boolean {
|
|
23
|
+
return (
|
|
24
|
+
rel.endsWith(".server.ts") ||
|
|
25
|
+
rel.endsWith(".server.tsx") ||
|
|
26
|
+
rel.startsWith("routes/") ||
|
|
27
|
+
rel.startsWith("routes\\")
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
19
31
|
export async function loadServerActions(appDir: string): Promise<void> {
|
|
20
32
|
const glob = new Bun.Glob("**/*.{ts,tsx}");
|
|
21
33
|
for await (const rel of glob.scan(appDir)) {
|
|
34
|
+
if (!isEligible(rel)) continue;
|
|
22
35
|
const filePath = join(appDir, rel);
|
|
23
36
|
let src: string;
|
|
24
37
|
try { src = await Bun.file(filePath).text(); } catch { continue; }
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-origin POST/PUT/DELETE/PATCH protection.
|
|
3
|
+
* Allow when: request carries X-BractJS-Action header (client-issued, blocked
|
|
4
|
+
* cross-origin by CORS for non-simple requests), OR the Origin header matches
|
|
5
|
+
* the request URL's origin.
|
|
6
|
+
*/
|
|
7
|
+
export function isAllowedMutation(request: Request): boolean {
|
|
8
|
+
if (request.headers.get("X-BractJS-Action")) return true;
|
|
9
|
+
const origin = request.headers.get("Origin");
|
|
10
|
+
if (!origin) return false;
|
|
11
|
+
try {
|
|
12
|
+
return new URL(origin).origin === new URL(request.url).origin;
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/server/env.ts
CHANGED
|
@@ -10,6 +10,11 @@ export function requireEnv(key: string): string {
|
|
|
10
10
|
return value;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
// Build LS/PS at runtime so the source contains no raw U+2028/U+2029
|
|
14
|
+
// (which would break JS parsing as LineTerminators).
|
|
15
|
+
const LS = String.fromCharCode(0x2028);
|
|
16
|
+
const PS = String.fromCharCode(0x2029);
|
|
17
|
+
|
|
13
18
|
export function safeStringify(data: unknown): string {
|
|
14
19
|
const seen = new WeakSet();
|
|
15
20
|
const json = JSON.stringify(data, (_key, value) => {
|
|
@@ -19,11 +24,12 @@ export function safeStringify(data: unknown): string {
|
|
|
19
24
|
}
|
|
20
25
|
return value;
|
|
21
26
|
});
|
|
22
|
-
// Escape HTML-sensitive
|
|
23
|
-
//
|
|
24
|
-
// JSON.parse on the client decodes them transparently.
|
|
27
|
+
// Escape HTML-sensitive chars + JS LineTerminators (U+2028 / U+2029) so this
|
|
28
|
+
// JSON is safe to embed inside a <script> tag.
|
|
25
29
|
return json
|
|
26
30
|
.replace(/</g, "\\u003c")
|
|
27
31
|
.replace(/>/g, "\\u003e")
|
|
28
|
-
.replace(/&/g, "\\u0026")
|
|
32
|
+
.replace(/&/g, "\\u0026")
|
|
33
|
+
.replaceAll(LS, "\\u2028")
|
|
34
|
+
.replaceAll(PS, "\\u2029");
|
|
29
35
|
}
|
package/src/server/middleware.ts
CHANGED
|
@@ -30,16 +30,20 @@ export class MiddlewarePipeline {
|
|
|
30
30
|
ctx: MiddlewareContext,
|
|
31
31
|
handler: () => Promise<Response>,
|
|
32
32
|
): Promise<Response> {
|
|
33
|
-
let index = 0;
|
|
34
33
|
const fns = this.fns;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
let lastCalled = -1;
|
|
35
|
+
|
|
36
|
+
const dispatch = (i: number): Promise<Response> => {
|
|
37
|
+
if (i <= lastCalled) {
|
|
38
|
+
return Promise.reject(new Error("middleware: next() called more than once"));
|
|
39
|
+
}
|
|
40
|
+
lastCalled = i;
|
|
41
|
+
if (i >= fns.length) return handler();
|
|
42
|
+
const fn = fns[i];
|
|
43
|
+
return fn(ctx, () => dispatch(i + 1));
|
|
40
44
|
};
|
|
41
45
|
|
|
42
|
-
return dispatch();
|
|
46
|
+
return dispatch(0);
|
|
43
47
|
}
|
|
44
48
|
}
|
|
45
49
|
|
package/src/server/render.ts
CHANGED
|
@@ -35,12 +35,14 @@ export async function renderRoute(options: RenderOptions): Promise<Response> {
|
|
|
35
35
|
status = 200,
|
|
36
36
|
} = options;
|
|
37
37
|
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
//
|
|
38
|
+
const devFlag = isDev() ? "window.__BRACT_DEV__=true;" : "";
|
|
39
|
+
const devOverlay = isDev() ? devFlag + errorOverlayScript + "\n" : "";
|
|
40
|
+
const mergedMeta = mergeMeta(options.meta ?? []);
|
|
41
|
+
// metaHtml is injected into <head> via React (the renderToReadableStream tree
|
|
42
|
+
// is expected to use it). The merged descriptor array is what the client
|
|
43
|
+
// reads — keep it shaped, not stringified HTML.
|
|
42
44
|
const bootstrapScriptContent =
|
|
43
|
-
devOverlay + `window.__BRACTJS_DATA__=${safeStringify({ loaderData, actionData, params, pathname, manifest, routeFile: options.routeFile, meta:
|
|
45
|
+
devOverlay + `window.__BRACTJS_DATA__=${safeStringify({ loaderData, actionData, params, pathname, manifest, routeFile: options.routeFile, meta: mergedMeta })};`;
|
|
44
46
|
|
|
45
47
|
let renderError: unknown;
|
|
46
48
|
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { join } from "node:path";
|
|
2
1
|
import { createElement } from "react";
|
|
3
2
|
import type { TrieNode } from "./matcher.ts";
|
|
4
3
|
import { matchRoute } from "./matcher.ts";
|
|
@@ -7,9 +6,11 @@ import { runLoaders, runAction, buildLoaderArgs } from "./loader.ts";
|
|
|
7
6
|
import { renderRoute, type ServerManifest } from "./render.ts";
|
|
8
7
|
import { resolveMeta } from "./meta.ts";
|
|
9
8
|
import { json, error } from "./response.ts";
|
|
10
|
-
import { isRedirect } from "../shared/errors.ts";
|
|
9
|
+
import { isRedirect, isHttpError } from "../shared/errors.ts";
|
|
10
|
+
import { isDev } from "./env.ts";
|
|
11
11
|
import { pipeline, type MiddlewareContext } from "./middleware.ts";
|
|
12
12
|
import { BractJSProvider } from "../shared/context.ts";
|
|
13
|
+
import { isAllowedMutation } from "./csrf.ts";
|
|
13
14
|
|
|
14
15
|
export interface HandlerConfig {
|
|
15
16
|
appDir: string;
|
|
@@ -39,17 +40,10 @@ async function route(
|
|
|
39
40
|
config: HandlerConfig,
|
|
40
41
|
context: Record<string, unknown>,
|
|
41
42
|
): Promise<Response> {
|
|
42
|
-
const { appDir,
|
|
43
|
+
const { appDir, manifest } = config;
|
|
43
44
|
const url = new URL(request.url);
|
|
44
45
|
const { pathname, searchParams } = url;
|
|
45
46
|
|
|
46
|
-
// ── Static public assets ──────────────────────────────────────────────
|
|
47
|
-
if (pathname.startsWith("/public/")) {
|
|
48
|
-
const file = Bun.file(join(publicDir, pathname.slice("/public/".length)));
|
|
49
|
-
if (await file.exists()) return new Response(file);
|
|
50
|
-
return error("Not Found", 404);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
47
|
// ── /_data soft-nav JSON endpoint ─────────────────────────────────────
|
|
54
48
|
if (pathname.startsWith("/_data")) {
|
|
55
49
|
const targetPath = searchParams.get("path") ?? "/";
|
|
@@ -77,12 +71,17 @@ async function route(
|
|
|
77
71
|
// ── Action (mutating methods) ─────────────────────────────────────────
|
|
78
72
|
let actionData: unknown = null;
|
|
79
73
|
if (MUTATING_METHODS.has(request.method)) {
|
|
74
|
+
if (!isAllowedMutation(request)) return error("Forbidden", 403);
|
|
80
75
|
try {
|
|
81
|
-
const
|
|
76
|
+
const ct = request.headers.get("Content-Type") ?? "";
|
|
77
|
+
const isFormLike = ct.includes("multipart/form-data") || ct.includes("application/x-www-form-urlencoded");
|
|
78
|
+
const formData = isFormLike ? await request.formData() : new FormData();
|
|
82
79
|
actionData = await runAction(chain.route, { ...args, formData });
|
|
83
80
|
} catch (err) {
|
|
84
81
|
if (isRedirect(err)) return err as Response;
|
|
85
|
-
|
|
82
|
+
if (isHttpError(err)) return error(err.message, err.status);
|
|
83
|
+
if (isDev()) return error(err instanceof Error ? err.message : String(err), 500);
|
|
84
|
+
return error("Internal Server Error", 500);
|
|
86
85
|
}
|
|
87
86
|
|
|
88
87
|
// Client-side Form submits with this header — return JSON, not HTML.
|
|
@@ -97,7 +96,9 @@ async function route(
|
|
|
97
96
|
loaderResults = await runLoaders(chain, args);
|
|
98
97
|
} catch (err) {
|
|
99
98
|
if (isRedirect(err)) return err as Response;
|
|
100
|
-
|
|
99
|
+
if (isHttpError(err)) return error(err.message, err.status);
|
|
100
|
+
if (isDev()) return error(err instanceof Error ? err.message : String(err), 500);
|
|
101
|
+
return error("Internal Server Error", 500);
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
const loaderData = {
|
package/src/server/response.ts
CHANGED
|
@@ -1,8 +1,32 @@
|
|
|
1
|
-
export
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
export interface RedirectOptions {
|
|
2
|
+
/** Allow absolute URLs to other origins. Default false. */
|
|
3
|
+
allowExternal?: boolean;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function isSafeInternalRedirect(url: string): boolean {
|
|
7
|
+
// Must be path-only: single leading "/" not followed by "/" or "\".
|
|
8
|
+
// Rejects: "//evil.com", "/\\evil.com", "https://...", "javascript:...", "".
|
|
9
|
+
if (url.length === 0) return false;
|
|
10
|
+
if (url[0] !== "/") return false;
|
|
11
|
+
if (url[1] === "/" || url[1] === "\\") return false;
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function redirect(
|
|
16
|
+
url: string,
|
|
17
|
+
status: number = 302,
|
|
18
|
+
headers?: HeadersInit,
|
|
19
|
+
options?: RedirectOptions,
|
|
20
|
+
): Response {
|
|
21
|
+
if (!options?.allowExternal && !isSafeInternalRedirect(url)) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
`[bractjs] redirect: unsafe Location "${url}". ` +
|
|
24
|
+
`Pass { allowExternal: true } to redirect off-origin.`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
const h = new Headers(headers);
|
|
28
|
+
h.set("Location", url);
|
|
29
|
+
return new Response(null, { status, headers: h });
|
|
6
30
|
}
|
|
7
31
|
|
|
8
32
|
export function json<T>(data: T, init?: ResponseInit): Response {
|
package/src/server/scanner.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
|
|
1
3
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
2
4
|
|
|
3
5
|
export type Segment = string | { param: string } | { catchAll: string };
|
|
@@ -54,8 +56,10 @@ export async function scanRoutes(appDir: string): Promise<RouteFile[]> {
|
|
|
54
56
|
const routes: RouteFile[] = [];
|
|
55
57
|
|
|
56
58
|
for await (const filePath of glob.scan(appDir)) {
|
|
57
|
-
// Skip layout files — handled separately
|
|
58
|
-
|
|
59
|
+
// Skip layout files — handled separately. Use basename so this also
|
|
60
|
+
// skips top-level "routes/layout.tsx" on any OS.
|
|
61
|
+
const base = basename(filePath);
|
|
62
|
+
if (base === "layout.tsx" || base === "layout.ts") {
|
|
59
63
|
continue;
|
|
60
64
|
}
|
|
61
65
|
|
package/src/server/session.ts
CHANGED
|
@@ -52,15 +52,20 @@ async function sign(data: string, secret: string): Promise<string> {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
async function verify(data: string, sig: string, secrets: string[]): Promise<boolean> {
|
|
55
|
+
// Iterate ALL secrets without short-circuit, and do full-length constant-time
|
|
56
|
+
// compare against every candidate to avoid leaking which secret matched (or
|
|
57
|
+
// whether a length mismatch occurred) via timing.
|
|
58
|
+
let ok = false;
|
|
55
59
|
for (const secret of secrets) {
|
|
56
60
|
const expected = await sign(data, secret);
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
+
const len = Math.max(expected.length, sig.length);
|
|
62
|
+
let diff = expected.length ^ sig.length;
|
|
63
|
+
for (let i = 0; i < len; i++) {
|
|
64
|
+
diff |= (expected.charCodeAt(i) || 0) ^ (sig.charCodeAt(i) || 0);
|
|
61
65
|
}
|
|
66
|
+
if (diff === 0) ok = true;
|
|
62
67
|
}
|
|
63
|
-
return
|
|
68
|
+
return ok;
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
function makeSession(data: SessionData): InternalSession {
|
|
@@ -77,6 +82,12 @@ function makeSession(data: SessionData): InternalSession {
|
|
|
77
82
|
|
|
78
83
|
export function createCookieSession(options: CookieSessionOptions): SessionStorage {
|
|
79
84
|
const { name, secrets, maxAge, secure = true, sameSite = "Lax" } = options;
|
|
85
|
+
if (!Array.isArray(secrets) || secrets.length === 0) {
|
|
86
|
+
throw new Error("createCookieSession: secrets must be a non-empty array");
|
|
87
|
+
}
|
|
88
|
+
if (!secrets.every((s) => typeof s === "string" && s.length >= 16)) {
|
|
89
|
+
throw new Error("createCookieSession: each secret must be a string of length >= 16");
|
|
90
|
+
}
|
|
80
91
|
|
|
81
92
|
return {
|
|
82
93
|
async getSession(cookie?: string | null): Promise<Session> {
|