@bractjs/bractjs 0.1.5 → 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.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/action-handler.test.ts +47 -0
  3. package/src/__tests__/action-registry.test.ts +73 -0
  4. package/src/__tests__/codegen.test.ts +50 -0
  5. package/src/__tests__/deferred.test.ts +96 -0
  6. package/src/__tests__/directives.test.ts +52 -0
  7. package/src/__tests__/env.test.ts +73 -0
  8. package/src/__tests__/errors.test.ts +113 -0
  9. package/src/__tests__/hash.test.ts +19 -0
  10. package/src/__tests__/integration.test.ts +1 -1
  11. package/src/__tests__/manifest.test.ts +60 -0
  12. package/src/__tests__/middleware.test.ts +216 -0
  13. package/src/__tests__/response.test.ts +106 -0
  14. package/src/__tests__/security.test.ts +348 -0
  15. package/src/__tests__/session.test.ts +3 -3
  16. package/src/build/bundler.ts +15 -5
  17. package/src/build/directives.ts +30 -3
  18. package/src/build/env-plugin.ts +1 -0
  19. package/src/build/hash.ts +0 -20
  20. package/src/client/ClientRouter.tsx +8 -4
  21. package/src/codegen/route-codegen.ts +33 -9
  22. package/src/dev/hmr-module-handler.ts +14 -4
  23. package/src/image/cache.ts +28 -8
  24. package/src/image/handler.ts +26 -11
  25. package/src/image/optimizer.ts +45 -13
  26. package/src/image/types.ts +1 -0
  27. package/src/middleware/cors.ts +24 -8
  28. package/src/server/action-handler.ts +40 -1
  29. package/src/server/action-registry.ts +14 -1
  30. package/src/server/csrf.ts +16 -0
  31. package/src/server/env.ts +10 -4
  32. package/src/server/middleware.ts +11 -7
  33. package/src/server/render.ts +7 -5
  34. package/src/server/request-handler.ts +14 -13
  35. package/src/server/response.ts +29 -5
  36. package/src/server/scanner.ts +6 -2
  37. package/src/server/session.ts +16 -5
  38. package/src/server/static.ts +23 -7
@@ -3,7 +3,7 @@ import { createCookieSession } from "../server/session.ts";
3
3
 
4
4
  const sessionStorage = createCookieSession({
5
5
  name: "__test",
6
- secrets: ["secret-one", "secret-two"],
6
+ secrets: ["secret-one-1234567890", "secret-two-1234567890"],
7
7
  secure: false,
8
8
  sameSite: "Lax",
9
9
  });
@@ -71,7 +71,7 @@ describe("createCookieSession — commitSession + roundtrip", () => {
71
71
  test("secret rotation: old secret still verifies", async () => {
72
72
  const oldStorage = createCookieSession({
73
73
  name: "__test",
74
- secrets: ["secret-two"], // only the old secret
74
+ secrets: ["secret-two-1234567890"], // only the old secret
75
75
  secure: false,
76
76
  });
77
77
  const s1 = await oldStorage.getSession(null);
@@ -81,7 +81,7 @@ describe("createCookieSession — commitSession + roundtrip", () => {
81
81
  // New storage has new secret first, old secret second (rotation)
82
82
  const newStorage = createCookieSession({
83
83
  name: "__test",
84
- secrets: ["secret-one", "secret-two"],
84
+ secrets: ["secret-one-1234567890", "secret-two-1234567890"],
85
85
  secure: false,
86
86
  });
87
87
  const cookieValue = cookie.split(";")[0];
@@ -1,4 +1,4 @@
1
- import { join } from "node:path";
1
+ import { join, basename, extname, resolve } from "node:path";
2
2
  import { rename } from "node:fs/promises";
3
3
  import type { BractJSConfig } from "../server/serve.ts";
4
4
  import { scanRoutes } from "../server/scanner.ts";
@@ -47,6 +47,10 @@ export async function runBuild(config: BractJSConfig): Promise<void> {
47
47
  const routeChunks = new Map<string, string>();
48
48
  let clientEntry = "";
49
49
  let rootChunk: string | undefined;
50
+ const outdirAbs = resolve("build/client");
51
+ const appDirClean = appDir.replace(/^\.\//, "");
52
+ const entryBase = basename("src/client/entry.tsx", extname("src/client/entry.tsx")); // "entry"
53
+ const rootBase = basename(rootFilePath, extname(rootFilePath)); // "root"
50
54
 
51
55
  for (const artifact of clientResult.outputs) {
52
56
  if (artifact.kind !== "chunk" && artifact.kind !== "entry-point") continue;
@@ -57,13 +61,19 @@ export async function runBuild(config: BractJSConfig): Promise<void> {
57
61
  await rename(artifact.path, hashedPath);
58
62
 
59
63
  const publicPath = "/" + hashedPath.replace(/^build\//, "build/");
60
- if (artifact.kind === "entry-point" && artifact.path.includes("entry")) {
64
+ const absPath = resolve(artifact.path);
65
+ const rel = absPath.startsWith(outdirAbs + "/") ? absPath.slice(outdirAbs.length + 1) : basename(artifact.path);
66
+ const outBase = basename(artifact.path, extname(artifact.path));
67
+
68
+ if (artifact.kind === "entry-point" && outBase === entryBase) {
61
69
  clientEntry = publicPath;
62
- } else if (artifact.kind === "entry-point" && artifact.path.includes("root")) {
70
+ } else if (artifact.kind === "entry-point" && outBase === rootBase) {
63
71
  rootChunk = publicPath;
64
72
  } else {
65
- // Map route file path back to URL pattern
66
- const matched = routes.find((r) => artifact.path.includes(r.filePath.replace("./", "")));
73
+ const matched = routes.find((r) => {
74
+ const expected = join(appDirClean, r.filePath).replace(/\.[^.]+$/, ".js");
75
+ return rel === expected;
76
+ });
67
77
  if (matched) routeChunks.set(matched.urlPattern, publicPath);
68
78
  }
69
79
  }
@@ -3,10 +3,37 @@ import type { BunPlugin } from "bun";
3
3
  const CLIENT_RE = /^["']use client["']/m;
4
4
  const SERVER_RE = /^["']use server["']/m;
5
5
 
6
+ // Strip a UTF-8 BOM and any leading ASCII whitespace before testing the
7
+ // directive regex. Editors that save files with BOM otherwise let "use server"
8
+ // fall through and ship server code to the client bundle.
9
+ function normalizeForDirectiveCheck(src: string): string {
10
+ return src.replace(/^/, "").replace(/^\s+/, "");
11
+ }
12
+ function hasClientDirective(src: string): boolean {
13
+ return CLIENT_RE.test(normalizeForDirectiveCheck(src));
14
+ }
15
+ function hasServerDirective(src: string): boolean {
16
+ return SERVER_RE.test(normalizeForDirectiveCheck(src));
17
+ }
18
+
6
19
  function extractExports(src: string): string[] {
7
20
  const names: string[] = [];
8
21
  for (const m of src.matchAll(/^export\s+(?:async\s+)?function\s+(\w+)/gm)) names.push(m[1]);
9
- for (const m of src.matchAll(/^export\s+(?:let|const)\s+(\w+)\s*=/gm)) names.push(m[1]);
22
+ for (const m of src.matchAll(/^export\s+(?:let|const|var)\s+(\w+)\s*=/gm)) names.push(m[1]);
23
+ for (const m of src.matchAll(/^export\s+default\s+(?:async\s+)?function\s+(\w+)/gm)) names.push(m[1]);
24
+ for (const m of src.matchAll(/^export\s+class\s+(\w+)/gm)) names.push(m[1]);
25
+ for (const m of src.matchAll(/^export\s*\{([^}]+)\}/gm)) {
26
+ for (const part of m[1].split(",")) {
27
+ const trimmed = part.trim();
28
+ if (!trimmed) continue;
29
+ const asMatch = trimmed.match(/\bas\s+(\w+)$/);
30
+ if (asMatch) names.push(asMatch[1]);
31
+ else {
32
+ const idMatch = trimmed.match(/^(\w+)/);
33
+ if (idMatch) names.push(idMatch[1]);
34
+ }
35
+ }
36
+ }
10
37
  return names;
11
38
  }
12
39
 
@@ -25,7 +52,7 @@ export const useClientStubPlugin: BunPlugin = {
25
52
  setup(build) {
26
53
  build.onLoad({ filter: /\.(tsx?|jsx?)$/ }, async ({ path }) => {
27
54
  const src = await Bun.file(path).text();
28
- if (!CLIENT_RE.test(src)) return undefined;
55
+ if (!hasClientDirective(src)) return undefined;
29
56
  const stubs = extractExports(src).map((n) => `export const ${n} = () => null;`).join("\n");
30
57
  return { contents: stubs || "export {};", loader: "ts" };
31
58
  });
@@ -52,7 +79,7 @@ export const useServerProxyPlugin: BunPlugin = {
52
79
  setup(build) {
53
80
  build.onLoad({ filter: /\.(tsx?|jsx?)$/ }, async ({ path }) => {
54
81
  const src = await Bun.file(path).text();
55
- if (!SERVER_RE.test(src)) return undefined;
82
+ if (!hasServerDirective(src)) return undefined;
56
83
  const names = extractExports(src);
57
84
  if (names.length === 0) return { contents: "export {};", loader: "ts" };
58
85
  const proxies = await Promise.all(
@@ -41,6 +41,7 @@ export function clientEnvPlugin(
41
41
  name: "bractjs-client-env",
42
42
  setup(build) {
43
43
  build.onLoad({ filter: /\.(ts|tsx|js|jsx)$/ }, async (args) => {
44
+ if (args.path.includes("/node_modules/")) return undefined;
44
45
  const src = await Bun.file(args.path).text();
45
46
  const contents = src.replace(
46
47
  /process\.env\.([A-Z_][A-Z0-9_]*)/g,
package/src/build/hash.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { extname, basename, dirname, join } from "node:path";
2
- import { test, expect } from "bun:test";
3
2
 
4
3
  // ── Helpers ────────────────────────────────────────────────────────────────
5
4
 
@@ -35,22 +34,3 @@ export async function renameWithHash(filePath: string): Promise<string> {
35
34
  const base = basename(filePath, ext);
36
35
  return join(dirname(filePath), `${base}.${hash}${ext}`);
37
36
  }
38
-
39
- // ── Tests ──────────────────────────────────────────────────────────────────
40
-
41
- test("same content → same hash", async () => {
42
- const a = await hashString("hello world");
43
- const b = await hashString("hello world");
44
- expect(a).toBe(b);
45
- });
46
-
47
- test("different content → different hash", async () => {
48
- const a = await hashString("foo");
49
- const b = await hashString("bar");
50
- expect(a).not.toBe(b);
51
- });
52
-
53
- test("hash is 8 hex chars", async () => {
54
- const h = await hashString("bractjs");
55
- expect(h).toMatch(/^[0-9a-f]{8}$/);
56
- });
@@ -68,8 +68,10 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
68
68
  setPathname(to);
69
69
  setCurrentModule(routeModule);
70
70
  });
71
- if ((data.meta as { title?: string })?.title) {
72
- document.title = (data.meta as { title: string }).title;
71
+ const metaList = data.meta as Array<Record<string, unknown>> | undefined;
72
+ const titleEntry = metaList?.find((m) => "title" in m);
73
+ if (titleEntry && typeof titleEntry.title === "string") {
74
+ document.title = titleEntry.title;
73
75
  }
74
76
  } catch (err) {
75
77
  console.error("[bractjs] loadRoute error:", err);
@@ -93,9 +95,11 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
93
95
  // Module-level HMR: swap the current route module without a full reload.
94
96
  // The injected HMR client script calls window.__BRACTJS_HMR_ACCEPT__(pattern, mod)
95
97
  // after importing the freshly-built chunk from /_hmr/module.
98
+ // Dev gate: prod builds inject __BRACT_DEV__ = false; absence in browser also
99
+ // counts as prod since we never reference `process` here.
96
100
  useEffect(() => {
97
- if (process.env.NODE_ENV === "production") return;
98
- const w = window as unknown as { __BRACTJS_HMR_ACCEPT__?: unknown };
101
+ const w = window as unknown as { __BRACT_DEV__?: boolean; __BRACTJS_HMR_ACCEPT__?: unknown };
102
+ if (w.__BRACT_DEV__ !== true) return;
99
103
  w.__BRACTJS_HMR_ACCEPT__ = (pattern: string, mod: RouteModuleClient) => {
100
104
  const current = matchPatternForPath(pathname, manifest);
101
105
  if (current === pattern) startTransition(() => setCurrentModule(mod));
@@ -29,23 +29,44 @@ function substituteParams(pattern: string, params: string[]): string {
29
29
  ).join("/");
30
30
  }
31
31
 
32
+ // Allowed pattern: "/" + (segment | ":ident") (segments are filename-derived).
33
+ // Restricting upfront removes any chance that a hostile filename injects a
34
+ // backtick, ${ }, or quote into the generated TS source.
35
+ const SAFE_PATTERN_RE = /^\/(?:[A-Za-z0-9_\-]+|:[A-Za-z_][A-Za-z0-9_]*)(?:\/(?:[A-Za-z0-9_\-]+|:[A-Za-z_][A-Za-z0-9_]*))*$|^\/$/;
36
+ const SAFE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
37
+
38
+ function assertSafePattern(pattern: string): void {
39
+ if (!SAFE_PATTERN_RE.test(pattern)) {
40
+ throw new Error(`[bractjs] codegen: refusing to emit unsafe route pattern: ${JSON.stringify(pattern)}`);
41
+ }
42
+ }
43
+ function assertSafeParam(name: string): void {
44
+ if (!SAFE_IDENT_RE.test(name)) {
45
+ throw new Error(`[bractjs] codegen: refusing to emit unsafe param name: ${JSON.stringify(name)}`);
46
+ }
47
+ }
48
+
32
49
  function builderEntry(pattern: string, params: string[]): string {
33
- if (params.length === 0)
34
- return " \"" + pattern + "\": () => \"" + pattern + "\" as const,";
50
+ assertSafePattern(pattern);
51
+ params.forEach(assertSafeParam);
52
+ const key = JSON.stringify(pattern);
53
+ if (params.length === 0) return " " + key + ": () => " + key + " as const,";
35
54
  const paramType = params.map((p) => p + ": string").join("; ");
36
55
  const body = substituteParams(pattern, params);
37
- return " \"" + pattern + "\": (params: { " + paramType + " }) => `" + body + "` as const,";
56
+ return " " + key + ": (params: { " + paramType + " }) => `" + body + "` as const,";
38
57
  }
39
58
 
40
59
  function paramsTypeLines(routes: Array<{ pattern: string; params: string[] }>): string {
41
60
  const dynamic = routes.filter((r) => r.params.length > 0);
42
61
  if (dynamic.length === 0) return "export type RouteParams<_T extends AppRoutes> = Record<never, never>;";
43
62
  const branches = dynamic
44
- .map((r) =>
45
- " T extends \"" + r.pattern + "\" ? { "
46
- + r.params.map((p) => p + ": string").join("; ")
47
- + " } :",
48
- )
63
+ .map((r) => {
64
+ assertSafePattern(r.pattern);
65
+ r.params.forEach(assertSafeParam);
66
+ return " T extends " + JSON.stringify(r.pattern) + " ? { "
67
+ + r.params.map((p) => p + ": string").join("; ")
68
+ + " } :";
69
+ })
49
70
  .join("\n");
50
71
  return "export type RouteParams<T extends AppRoutes> =\n" + branches + "\n Record<never, never>;";
51
72
  }
@@ -65,7 +86,10 @@ export async function generateRouteTypes(appDir: string): Promise<string> {
65
86
  }));
66
87
 
67
88
  const union = routes.length > 0
68
- ? routes.map((r) => " | \"" + r.pattern + "\"").join("\n")
89
+ ? routes.map((r) => {
90
+ assertSafePattern(r.pattern);
91
+ return " | " + JSON.stringify(r.pattern);
92
+ }).join("\n")
69
93
  : " never";
70
94
 
71
95
  const builderEntries = routes.map((r) => builderEntry(r.pattern, r.params)).join("\n");
@@ -1,4 +1,5 @@
1
- import { resolve, join } from "node:path";
1
+ import { resolve, join, sep } from "node:path";
2
+ import { realpath } from "node:fs/promises";
2
3
 
3
4
  /**
4
5
  * Dev-only HTTP handler for /_hmr/module?file=routes/about.tsx
@@ -17,10 +18,19 @@ export async function handleHmrModuleRequest(
17
18
  return new Response("Missing file param", { status: 400 });
18
19
  }
19
20
 
20
- // Resolve and guard against path traversal
21
+ // Resolve and guard against path traversal AND symlink escape.
21
22
  const rootDir = resolve(appDir);
22
- const fullPath = resolve(join(rootDir, file));
23
- if (!fullPath.startsWith(rootDir + "/") && fullPath !== rootDir) {
23
+ const candidate = resolve(join(rootDir, file));
24
+ if (!candidate.startsWith(rootDir + sep) && candidate !== rootDir) {
25
+ return new Response("Forbidden", { status: 403 });
26
+ }
27
+ let fullPath: string;
28
+ try {
29
+ fullPath = await realpath(candidate);
30
+ } catch {
31
+ return new Response("Not Found", { status: 404 });
32
+ }
33
+ if (!fullPath.startsWith(rootDir + sep) && fullPath !== rootDir) {
24
34
  return new Response("Forbidden", { status: 403 });
25
35
  }
26
36
 
@@ -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
- if (!(await metaFile.exists()) || !(await dataFile.exists())) return null;
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 metaFile.json() as { contentType: string; format: ImageFormat };
56
- const data = await dataFile.arrayBuffer();
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
- await Promise.all([
72
- Bun.write(join(dir, `${key}.json`), JSON.stringify({ contentType: result.contentType, format: result.format })),
73
- Bun.write(join(dir, `${key}.bin`), result.data),
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
  }
@@ -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 MAX_DIM = 4096;
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 filePath = resolve(join(root, rel));
21
- if (!filePath.startsWith(root + "/") && filePath !== root) return null;
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 < 1 || w > MAX_DIM)) return null;
28
- if (h !== undefined && (isNaN(h) || h < 1 || h > MAX_DIM)) return null;
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 fit = (sp.get("fit") ?? FIT_DEFAULT) as ImageFit;
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;
@@ -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
- const proc = Bun.spawn(buildArgs(binary, filePath, params), {
61
- stdout: "pipe",
62
- stderr: "pipe",
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
- const [data, , exitCode] = await Promise.all([
66
- new Response(proc.stdout!).arrayBuffer(),
67
- new Response(proc.stderr!).arrayBuffer(),
68
- proc.exited,
69
- ]);
93
+ const [data, exitCode] = await Promise.all([
94
+ new Response(proc.stdout!).arrayBuffer(),
95
+ proc.exited,
96
+ ]);
70
97
 
71
- if (exitCode !== 0) {
72
- throw new Error(`[bractjs] ImageMagick exited ${exitCode} for ${filePath}`);
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
- return { data, contentType: MIME[params.format], format: params.format };
104
+ return { data, contentType: MIME[params.format], format: params.format };
105
+ } finally {
106
+ releaseSlot();
107
+ }
76
108
  }
@@ -25,3 +25,4 @@ export const MIME: Record<ImageFormat, string> = {
25
25
  jpeg: "image/jpeg",
26
26
  png: "image/png",
27
27
  };
28
+ export const ALLOWED_FITS: ReadonlySet<ImageFit> = new Set(["cover", "contain", "fill"]);
@@ -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 (allowed) corsHeaders["Access-Control-Allow-Origin"] = origin || "*";
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
- const patched = new Response(response.body, response);
33
- for (const [k, v] of Object.entries(corsHeaders)) patched.headers.set(k, v);
34
- return patched;
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
- args = text ? JSON.parse(text) as unknown[] : [];
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
- const SERVER_RE = /^["']use server["']/m;
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; }