@bractjs/bractjs 0.1.5 → 0.1.7

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 (66) 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__/loader.test.ts +5 -2
  12. package/src/__tests__/manifest.test.ts +60 -0
  13. package/src/__tests__/middleware.test.ts +216 -0
  14. package/src/__tests__/response.test.ts +106 -0
  15. package/src/__tests__/security.test.ts +348 -0
  16. package/src/__tests__/session.test.ts +3 -3
  17. package/src/adapters/cloudflare.ts +65 -0
  18. package/src/build/bundler.ts +17 -6
  19. package/src/build/directives.ts +30 -3
  20. package/src/build/env-plugin.ts +8 -0
  21. package/src/build/hash.ts +0 -20
  22. package/src/build/plugins/css-modules.ts +110 -0
  23. package/src/client/ClientRouter.tsx +121 -13
  24. package/src/client/cache.ts +69 -0
  25. package/src/client/components/Link.tsx +16 -2
  26. package/src/client/components/LiveReload.tsx +4 -0
  27. package/src/client/hooks/useBlocker.ts +44 -0
  28. package/src/client/hooks/useFetcher.ts +66 -6
  29. package/src/client/hooks/useLocale.ts +12 -0
  30. package/src/client/hooks/useLocalizedLink.ts +18 -0
  31. package/src/client/hooks/useSearchParams.ts +74 -0
  32. package/src/client/rpc.ts +70 -0
  33. package/src/codegen/route-codegen.ts +96 -10
  34. package/src/dev/devtools.ts +144 -0
  35. package/src/dev/hmr-client.ts +14 -0
  36. package/src/dev/hmr-module-handler.ts +31 -5
  37. package/src/dev/hmr-server.ts +16 -0
  38. package/src/image/cache.ts +28 -8
  39. package/src/image/handler.ts +31 -13
  40. package/src/image/optimizer.ts +51 -14
  41. package/src/image/types.ts +1 -0
  42. package/src/index.ts +27 -0
  43. package/src/middleware/cors.ts +28 -8
  44. package/src/middleware/requestLogger.ts +4 -0
  45. package/src/server/action-handler.ts +45 -2
  46. package/src/server/action-registry.ts +14 -1
  47. package/src/server/adapter.ts +57 -0
  48. package/src/server/api-route.ts +127 -0
  49. package/src/server/context.ts +22 -0
  50. package/src/server/csrf.ts +17 -0
  51. package/src/server/env.ts +26 -4
  52. package/src/server/i18n.ts +63 -0
  53. package/src/server/loader.ts +61 -1
  54. package/src/server/middleware.ts +11 -7
  55. package/src/server/render.ts +14 -5
  56. package/src/server/request-handler.ts +77 -18
  57. package/src/server/response.ts +29 -5
  58. package/src/server/scanner.ts +6 -2
  59. package/src/server/serve.ts +102 -55
  60. package/src/server/session.ts +17 -5
  61. package/src/server/static.ts +31 -8
  62. package/src/server/stream-handler.ts +111 -0
  63. package/src/server/validate.ts +89 -0
  64. package/src/shared/route-types.ts +11 -0
  65. package/types/index.d.ts +94 -1
  66. package/types/route.d.ts +11 -0
@@ -1,4 +1,7 @@
1
- import { resolve, join } from "node:path";
1
+ import { resolve, join, sep } from "node:path";
2
+ import { realpath } from "node:fs/promises";
3
+ import { serverOnlyPlugin } from "../build/env-plugin.ts";
4
+ import { useServerProxyPlugin } from "../build/directives.ts";
2
5
 
3
6
  /**
4
7
  * Dev-only HTTP handler for /_hmr/module?file=routes/about.tsx
@@ -17,19 +20,42 @@ export async function handleHmrModuleRequest(
17
20
  return new Response("Missing file param", { status: 400 });
18
21
  }
19
22
 
20
- // Resolve and guard against path traversal
23
+ // SECURITY(high): restrict to JS/TS source files. Without this, /_hmr/module
24
+ // would build and ship the contents of any file inside appDir (e.g. .env,
25
+ // .json, .md) as JavaScript to the browser — useful only for compiling
26
+ // route modules, so allowlist their extensions.
27
+ if (!/\.(tsx?|jsx?)$/.test(file)) {
28
+ return new Response("Forbidden", { status: 403 });
29
+ }
30
+
31
+ // Resolve and guard against path traversal AND symlink escape.
21
32
  const rootDir = resolve(appDir);
22
- const fullPath = resolve(join(rootDir, file));
23
- if (!fullPath.startsWith(rootDir + "/") && fullPath !== rootDir) {
33
+ const candidate = resolve(join(rootDir, file));
34
+ if (!candidate.startsWith(rootDir + sep) && candidate !== rootDir) {
35
+ return new Response("Forbidden", { status: 403 });
36
+ }
37
+ let fullPath: string;
38
+ try {
39
+ fullPath = await realpath(candidate);
40
+ } catch {
41
+ return new Response("Not Found", { status: 404 });
42
+ }
43
+ if (!fullPath.startsWith(rootDir + sep) && fullPath !== rootDir) {
24
44
  return new Response("Forbidden", { status: 403 });
25
45
  }
26
46
 
27
- // Build in-memory (no outdir outputs held in memory, no disk write)
47
+ // SECURITY(high): apply the same client-bundle guard plugins the production
48
+ // build uses. Without these, a route module that imports `*.server.ts` or
49
+ // contains "use server" exports would have that server source compiled and
50
+ // shipped to the browser as JavaScript over /_hmr/module — leaking
51
+ // credentials, DB code, etc. The serverOnlyPlugin hard-fails such imports
52
+ // and useServerProxyPlugin rewrites "use server" exports to fetch stubs.
28
53
  const result = await Bun.build({
29
54
  entrypoints: [fullPath],
30
55
  target: "browser",
31
56
  minify: false,
32
57
  sourcemap: "inline",
58
+ plugins: [serverOnlyPlugin, useServerProxyPlugin],
33
59
  });
34
60
 
35
61
  if (!result.success || result.outputs.length === 0) {
@@ -18,6 +18,22 @@ export function createHmrServer(port = 3001): {
18
18
  const server = Bun.serve({
19
19
  port,
20
20
  fetch(req, srv) {
21
+ // SECURITY(medium): reject WebSocket upgrades that don't come from a
22
+ // loopback Origin. Without this, any website the developer visits could
23
+ // open a WS to ws://localhost:<port> and receive file paths from HMR
24
+ // broadcasts (a passive leak of project structure). Same-origin /
25
+ // missing Origin (curl, native ws clients) are allowed for dev DX.
26
+ const origin = req.headers.get("Origin");
27
+ if (origin) {
28
+ try {
29
+ const host = new URL(origin).hostname;
30
+ if (host !== "localhost" && host !== "127.0.0.1" && host !== "[::1]" && host !== "::1") {
31
+ return new Response("Forbidden", { status: 403 });
32
+ }
33
+ } catch {
34
+ return new Response("Forbidden", { status: 403 });
35
+ }
36
+ }
21
37
  if (srv.upgrade(req)) return undefined;
22
38
  return new Response("HMR WebSocket endpoint", { status: 426 });
23
39
  },
@@ -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,54 @@
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
- // src must be a /public/ path with no traversal sequences
16
- if (!src || !src.startsWith("/public/") || src.includes("..")) return null;
17
+ // src must be a /public/ path with no ".." path segment. We check segments
18
+ // (not substring) so filenames like "foo..bar.jpg" are still allowed —
19
+ // realpath()/prefix check below is the authoritative escape guard.
20
+ if (!src || !src.startsWith("/public/")) return null;
21
+ if (src.split("/").includes("..")) return null;
17
22
 
18
23
  const rel = src.slice("/public/".length);
19
24
  const root = resolve(publicDir);
20
- const filePath = resolve(join(root, rel));
21
- if (!filePath.startsWith(root + "/") && filePath !== root) return null;
25
+ const candidate = resolve(join(root, rel));
26
+ if (!candidate.startsWith(root + sep) && candidate !== root) return null;
27
+ // Re-check after symlink resolution. If the file doesn't exist yet, realpath
28
+ // throws — fall through and let the existence check below handle it.
29
+ let filePath = candidate;
30
+ try {
31
+ const real = await realpath(candidate);
32
+ if (!real.startsWith(root + sep) && real !== root) return null;
33
+ filePath = real;
34
+ } catch {
35
+ // missing file: defer to Bun.file(...).exists() below
36
+ }
22
37
 
23
38
  const wRaw = sp.get("w");
24
39
  const hRaw = sp.get("h");
25
40
  const w = wRaw ? parseInt(wRaw, 10) : undefined;
26
41
  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;
42
+ if (w !== undefined && (isNaN(w) || !ALLOWED_DIMS.has(w))) return null;
43
+ if (h !== undefined && (isNaN(h) || !ALLOWED_DIMS.has(h))) return null;
44
+ if (w !== undefined && h !== undefined && w * h > MAX_AREA) return null;
29
45
 
30
46
  const q = Math.min(100, Math.max(1, parseInt(sp.get("q") ?? String(QUALITY_DEFAULT), 10)));
31
47
  const fmt = (sp.get("format") ?? FORMAT_DEFAULT) as ImageFormat;
32
- const fit = (sp.get("fit") ?? FIT_DEFAULT) as ImageFit;
48
+ const fitRaw = sp.get("fit") ?? FIT_DEFAULT;
33
49
  if (!MIME[fmt]) return null;
50
+ if (!ALLOWED_FITS.has(fitRaw as ImageFit)) return null;
51
+ const fit = fitRaw as ImageFit;
34
52
 
35
53
  return { src, filePath, params: { w, h, q, format: fmt, fit } };
36
54
  }
@@ -53,7 +71,7 @@ export async function handleImageRequest(
53
71
  const url = new URL(request.url);
54
72
  if (url.pathname !== "/_image") return null;
55
73
 
56
- const parsed = parseParams(url.searchParams, publicDir);
74
+ const parsed = await parseParams(url.searchParams, publicDir);
57
75
  if (!parsed) return new Response("Bad Request", { status: 400 });
58
76
 
59
77
  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
 
@@ -33,9 +57,14 @@ function resizeArgs(params: ImageTransformParams): string[] {
33
57
 
34
58
  function buildArgs(binary: string, input: string, params: ImageTransformParams): string[] {
35
59
  const base = binary === "magick" ? ["magick", "convert"] : ["convert"];
60
+ // SECURITY(low): prefix the input with `file:` so ImageMagick treats it as
61
+ // a filesystem path even if it contains a coder prefix like `mvg:` or
62
+ // `https:` (e.g. an attacker who plants a file named "https:evil.txt"
63
+ // inside publicDir). Defense in depth — realpath() already constrains the
64
+ // path to publicDir, so this prevents the "format coder hijack" class only.
36
65
  return [
37
66
  ...base,
38
- input,
67
+ `file:${input}`,
39
68
  ...resizeArgs(params),
40
69
  "-quality", String(params.q),
41
70
  "-strip",
@@ -57,20 +86,28 @@ export async function transformImage(
57
86
  return { data, contentType: MIME[fmt] ?? "image/jpeg", format: fmt };
58
87
  }
59
88
 
60
- const proc = Bun.spawn(buildArgs(binary, filePath, params), {
61
- stdout: "pipe",
62
- stderr: "pipe",
63
- });
89
+ await acquireSlot();
90
+ try {
91
+ const proc = Bun.spawn(buildArgs(binary, filePath, params), {
92
+ stdout: "pipe",
93
+ stderr: "ignore",
94
+ timeout: SPAWN_TIMEOUT_MS,
95
+ killSignal: "SIGKILL",
96
+ });
64
97
 
65
- const [data, , exitCode] = await Promise.all([
66
- new Response(proc.stdout!).arrayBuffer(),
67
- new Response(proc.stderr!).arrayBuffer(),
68
- proc.exited,
69
- ]);
98
+ const [data, exitCode] = await Promise.all([
99
+ new Response(proc.stdout!).arrayBuffer(),
100
+ proc.exited,
101
+ ]);
70
102
 
71
- if (exitCode !== 0) {
72
- throw new Error(`[bractjs] ImageMagick exited ${exitCode} for ${filePath}`);
73
- }
103
+ if (exitCode !== 0) {
104
+ // Non-zero exit covers normal failures AND timeout-induced SIGKILL,
105
+ // since Bun reports the signal as a non-zero exit code.
106
+ throw new Error(`[bractjs] ImageMagick exited ${exitCode} for ${filePath}`);
107
+ }
74
108
 
75
- return { data, contentType: MIME[params.format], format: params.format };
109
+ return { data, contentType: MIME[params.format], format: params.format };
110
+ } finally {
111
+ releaseSlot();
112
+ }
76
113
  }
@@ -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"]);
package/src/index.ts CHANGED
@@ -1,5 +1,23 @@
1
1
  // Server
2
2
  export { createServer, renderRoute, redirect, json, error } from "./server/index.ts";
3
+ export { buildFetchHandler } from "./server/serve.ts";
4
+ export { defineContext } from "./server/context.ts";
5
+ export type { ContextFactory } from "./server/context.ts";
6
+ export { route } from "./server/api-route.ts";
7
+ export type { ApiRouteDefinition, AppApiRoutes } from "./server/api-route.ts";
8
+ export { validate } from "./server/validate.ts";
9
+ export type { FieldErrors, ValidationError } from "./server/validate.ts";
10
+ export type { BractAdapter } from "./server/adapter.ts";
11
+ export { BunAdapter } from "./server/adapter.ts";
12
+
13
+ // Adapters
14
+ export { createCloudflareAdapter, makeCloudflareHandler } from "./adapters/cloudflare.ts";
15
+
16
+ // Build plugins
17
+ export { cssModulesPlugin, transformCssModule } from "./build/plugins/css-modules.ts";
18
+
19
+ // Client RPC
20
+ export { createClient } from "./client/rpc.ts";
3
21
  export type { BractJSConfig, RenderOptions, ServerManifest } from "./server/index.ts";
4
22
 
5
23
  // Shared types
@@ -49,3 +67,12 @@ export { useActionData } from "./client/hooks/useActionData.ts";
49
67
  export { useParams } from "./client/hooks/useParams.ts";
50
68
  export { useNavigation } from "./client/hooks/useNavigation.ts";
51
69
  export { useFetcher } from "./client/hooks/useFetcher.ts";
70
+ export { useSearchParams } from "./client/hooks/useSearchParams.ts";
71
+ export type { SearchParamsResult } from "./client/hooks/useSearchParams.ts";
72
+ export { useBlocker } from "./client/hooks/useBlocker.ts";
73
+ export { useLocale } from "./client/hooks/useLocale.ts";
74
+ export { useLocalizedLink } from "./client/hooks/useLocalizedLink.ts";
75
+
76
+ // i18n utilities (server-side)
77
+ export { wrapRoutesWithLocale, stripLocale, localizedDataPath } from "./server/i18n.ts";
78
+ export type { I18nConfig } from "./server/serve.ts";
@@ -3,25 +3,43 @@ 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);
28
+ // SECURITY(high): Access-Control-Allow-Headers MUST NOT list
29
+ // `X-BractJS-Action`. That header is the CSRF gate in csrf.ts — its
30
+ // protection relies on browsers blocking non-allowlisted custom headers
31
+ // cross-origin. Adding it here would let any origin forge mutations.
20
32
  const corsHeaders: Record<string, string> = {
21
33
  "Access-Control-Allow-Methods": allowedMethods,
22
34
  "Access-Control-Allow-Headers": "Content-Type, Authorization",
35
+ "Vary": "Origin",
23
36
  };
24
- if (allowed) corsHeaders["Access-Control-Allow-Origin"] = origin || "*";
37
+ if (wildcard) {
38
+ corsHeaders["Access-Control-Allow-Origin"] = "*";
39
+ } else if (origin && allowedOrigins.includes(origin)) {
40
+ corsHeaders["Access-Control-Allow-Origin"] = origin;
41
+ }
42
+ if (credentials) corsHeaders["Access-Control-Allow-Credentials"] = "true";
25
43
 
26
44
  // Preflight
27
45
  if (ctx.request.method === "OPTIONS") {
@@ -29,8 +47,10 @@ export function cors(options: CorsOptions): MiddlewareFn {
29
47
  }
30
48
 
31
49
  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;
50
+ // Mutate headers in place rather than wrapping body. Wrapping with
51
+ // `new Response(response.body, response)` makes the original Response
52
+ // unusable to anyone holding a reference (single-shot stream).
53
+ for (const [k, v] of Object.entries(corsHeaders)) response.headers.set(k, v);
54
+ return response;
35
55
  };
36
56
  }
@@ -3,6 +3,10 @@ import type { MiddlewareFn } from "../server/middleware.ts";
3
3
  /**
4
4
  * Logs "[METHOD] /path → status in Xms" for every request.
5
5
  */
6
+ // SECURITY(medium): only the pathname is logged — query string is intentionally
7
+ // omitted because it may carry tokens (e.g. password-reset links, OAuth codes,
8
+ // signed share URLs). Do not extend this to log searchParams without a redaction
9
+ // allowlist.
6
10
  export function requestLogger(): MiddlewareFn {
7
11
  return async (ctx, next) => {
8
12
  const start = Date.now();
@@ -1,10 +1,29 @@
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
+ // Deep scan: nested objects can carry __proto__ pollution vectors too.
11
+ function hasForbiddenKey(value: unknown, depth = 0): boolean {
12
+ if (depth > 20 || !value || typeof value !== "object") return false;
13
+ for (const key of Object.keys(value as Record<string, unknown>)) {
14
+ if (FORBIDDEN_KEYS.has(key)) return true;
15
+ if (hasForbiddenKey((value as Record<string, unknown>)[key], depth + 1)) return true;
16
+ }
17
+ return false;
18
+ }
3
19
 
4
20
  export async function handleActionRequest(request: Request): Promise<Response | null> {
5
21
  const url = new URL(request.url);
6
- if (!url.pathname.startsWith("/_action")) return null;
22
+ // SECURITY(medium): exact-match prevents URL confusion (e.g. "/_actionfoo"
23
+ // would otherwise also reach this handler).
24
+ if (url.pathname !== "/_action") return null;
7
25
  if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405 });
26
+ if (!isAllowedMutation(request)) return new Response("Forbidden", { status: 403 });
8
27
 
9
28
  const id = url.searchParams.get("id");
10
29
  if (!id) return new Response("Bad Request: missing action id", { status: 400 });
@@ -18,8 +37,32 @@ export async function handleActionRequest(request: Request): Promise<Response |
18
37
  if (ct.includes("multipart/form-data") || ct.includes("application/x-www-form-urlencoded")) {
19
38
  args = [await request.formData()];
20
39
  } else {
40
+ // Cheap pre-check: trust Content-Length if the client sent one.
41
+ const clRaw = request.headers.get("Content-Length");
42
+ if (clRaw) {
43
+ const cl = Number(clRaw);
44
+ if (Number.isFinite(cl) && cl > MAX_JSON_BODY_BYTES) {
45
+ return new Response("Payload Too Large", { status: 413 });
46
+ }
47
+ }
21
48
  const text = await request.text();
22
- args = text ? JSON.parse(text) as unknown[] : [];
49
+ // Defense in depth: clients can lie about Content-Length, so verify the
50
+ // actual decoded text length too.
51
+ if (text.length > MAX_JSON_BODY_BYTES) {
52
+ return new Response("Payload Too Large", { status: 413 });
53
+ }
54
+ if (!text) {
55
+ args = [];
56
+ } else {
57
+ const parsed: unknown = JSON.parse(text);
58
+ if (!Array.isArray(parsed)) {
59
+ return new Response("Bad Request: args must be array", { status: 400 });
60
+ }
61
+ if (parsed.some((v) => hasForbiddenKey(v))) {
62
+ return new Response("Bad Request: forbidden keys", { status: 400 });
63
+ }
64
+ args = parsed;
65
+ }
23
66
  }
24
67
  } catch {
25
68
  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; }
@@ -0,0 +1,57 @@
1
+ // ── BractAdapter ──────────────────────────────────────────────────────────
2
+
3
+ /**
4
+ * Minimal interface that adapters must implement.
5
+ *
6
+ * `fetch` is the standard WinterCG-compatible fetch handler — it receives a
7
+ * Request and returns a Response. The server core calls this for every
8
+ * incoming HTTP request after routing special endpoints.
9
+ *
10
+ * `listen` starts the adapter's underlying server on the given port.
11
+ * It is optional for environments that do not control port binding (e.g.
12
+ * Cloudflare Workers).
13
+ */
14
+ export interface BractAdapter {
15
+ fetch(request: Request): Promise<Response>;
16
+ listen?(port: number): void;
17
+ }
18
+
19
+ // ── BunAdapter ────────────────────────────────────────────────────────────
20
+
21
+ /**
22
+ * Default adapter — wraps `Bun.serve()`.
23
+ * Created internally by `createServer()` when no adapter is provided.
24
+ */
25
+ export class BunAdapter implements BractAdapter {
26
+ private server: ReturnType<typeof Bun.serve> | null = null;
27
+ private handler: ((request: Request) => Promise<Response>) | null = null;
28
+
29
+ setHandler(handler: (request: Request) => Promise<Response>): void {
30
+ this.handler = handler;
31
+ }
32
+
33
+ async fetch(request: Request): Promise<Response> {
34
+ if (!this.handler) throw new Error("BunAdapter: handler not set");
35
+ return this.handler(request);
36
+ }
37
+
38
+ listen(port: number): void {
39
+ if (!this.handler) throw new Error("BunAdapter: handler not set before listen()");
40
+ const handler = this.handler;
41
+ this.server = Bun.serve({
42
+ port,
43
+ fetch: handler,
44
+ error(err: Error) {
45
+ console.error("[bractjs] unhandled server error:", err);
46
+ return new Response(JSON.stringify({ error: err.message }), {
47
+ status: 500,
48
+ headers: { "Content-Type": "application/json; charset=utf-8" },
49
+ });
50
+ },
51
+ });
52
+ }
53
+
54
+ stop(): void {
55
+ this.server?.stop();
56
+ }
57
+ }