@bractjs/bractjs 0.1.28 → 0.1.29

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 (46) hide show
  1. package/package.json +3 -2
  2. package/src/__tests__/fixtures/app/routes/features-demo.tsx +28 -0
  3. package/src/__tests__/headers.test.ts +111 -0
  4. package/src/__tests__/integration.test.ts +34 -0
  5. package/src/__tests__/layout-registry.test.ts +7 -3
  6. package/src/__tests__/matcher.test.ts +29 -0
  7. package/src/__tests__/module-registry.test.ts +2 -3
  8. package/src/__tests__/route-lint.test.ts +5 -0
  9. package/src/__tests__/route-middleware.test.ts +84 -0
  10. package/src/__tests__/scanner.test.ts +46 -1
  11. package/src/__tests__/security-fixes.test.ts +201 -0
  12. package/src/__tests__/use-matches.test.ts +54 -0
  13. package/src/build/route-lint.ts +3 -3
  14. package/src/client/ClientRouter.tsx +118 -18
  15. package/src/client/hooks/useMatches.ts +32 -0
  16. package/src/client/router.tsx +7 -1
  17. package/src/client/rpc.ts +11 -1
  18. package/src/codegen/module-registry.ts +13 -21
  19. package/src/codegen/route-codegen.ts +8 -3
  20. package/src/config/load.ts +1 -0
  21. package/src/index.ts +11 -3
  22. package/src/server/action-handler.ts +1 -20
  23. package/src/server/adapter.ts +16 -0
  24. package/src/server/api-route.ts +47 -0
  25. package/src/server/csp.ts +9 -3
  26. package/src/server/csrf.ts +10 -3
  27. package/src/server/headers.ts +49 -0
  28. package/src/server/layout.ts +12 -19
  29. package/src/server/matcher.ts +29 -2
  30. package/src/server/matches.ts +50 -0
  31. package/src/server/middleware.ts +66 -0
  32. package/src/server/proto-guard.ts +56 -0
  33. package/src/server/render.ts +34 -16
  34. package/src/server/request-handler.ts +67 -27
  35. package/src/server/scanner.ts +45 -3
  36. package/src/server/search.ts +5 -1
  37. package/src/server/serve.ts +28 -3
  38. package/src/server/session.ts +12 -1
  39. package/src/server/validate.ts +4 -1
  40. package/src/shared/context.ts +3 -1
  41. package/src/shared/route-types.ts +108 -0
  42. package/types/config.d.ts +3 -0
  43. package/types/index.d.ts +17 -0
  44. package/types/route.d.ts +76 -1
  45. package/LICENSE +0 -21
  46. package/README.md +0 -1331
@@ -1,4 +1,6 @@
1
1
  import { isExplicitDev } from "./env.ts";
2
+ import { isAllowedMutation, csrfForbiddenResponse } from "./csrf.ts";
3
+ import { hasForbiddenKey } from "./proto-guard.ts";
2
4
 
3
5
  // ── Types ──────────────────────────────────────────────────────────────────
4
6
 
@@ -8,6 +10,27 @@ export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
8
10
  // cannot exhaust memory. Same 1 MiB ceiling used by /_action JSON.
9
11
  const MAX_BODY_BYTES = 1_048_576;
10
12
 
13
+ // SECURITY(high): the same state-changing methods the route-action / _action
14
+ // paths CSRF-gate. A typed API route using one of these is cross-site
15
+ // forgeable (cookies ride along; a form-encoded body is CORS-"simple" and
16
+ // skips preflight) unless the caller proves same-origin. We require that proof
17
+ // by default — see the gate in handleApiRequest.
18
+ const MUTATING_METHODS = new Set<HttpMethod>(["POST", "PUT", "PATCH", "DELETE"]);
19
+
20
+ export interface ApiRouteOptions {
21
+ /**
22
+ * Cross-site-request-forgery protection for this route. Default `true` for
23
+ * mutating methods (POST/PUT/PATCH/DELETE): the request must be same-origin
24
+ * (proven via `Sec-Fetch-Site`, the `X-BractJS-Action` header, or a matching
25
+ * `Origin`), exactly like server actions. Set `false` ONLY for endpoints
26
+ * that are safe to call cross-site — i.e. they do NOT rely on ambient
27
+ * credentials (session cookies / Basic auth) and are intentionally public
28
+ * (webhooks, token-authenticated APIs, public read/write services).
29
+ * Only GET is exempt from the gate; DELETE is treated as mutating.
30
+ */
31
+ csrf?: boolean;
32
+ }
33
+
11
34
  export interface ApiRouteDefinition<
12
35
  TMethod extends HttpMethod,
13
36
  TPath extends string,
@@ -17,6 +40,8 @@ export interface ApiRouteDefinition<
17
40
  method: TMethod;
18
41
  path: TPath;
19
42
  handler: (input: TInput, request: Request) => TOutput | Promise<TOutput>;
43
+ /** Resolved CSRF setting (defaults applied at registration). */
44
+ csrf: boolean;
20
45
  _types: { input: TInput; output: TOutput };
21
46
  }
22
47
 
@@ -29,6 +54,11 @@ const routeRegistry: ApiRouteDefinition<HttpMethod, string, any, any>[] = [];
29
54
  *
30
55
  * Usage in app/api/users.ts:
31
56
  * export const getUsers = bract.route("GET", "/api/users", async () => db.users.findAll());
57
+ *
58
+ * Mutating routes (POST/PUT/PATCH/DELETE) are CSRF-protected by default — the
59
+ * request must be same-origin. Pass `{ csrf: false }` for a deliberately public,
60
+ * credential-free endpoint (e.g. a webhook):
61
+ * bract.route("POST", "/api/webhook", handler, { csrf: false });
32
62
  */
33
63
  export function route<
34
64
  TMethod extends HttpMethod,
@@ -39,11 +69,14 @@ export function route<
39
69
  method: TMethod,
40
70
  path: TPath,
41
71
  handler: (input: TInput, request: Request) => TOutput | Promise<TOutput>,
72
+ options?: ApiRouteOptions,
42
73
  ): ApiRouteDefinition<TMethod, TPath, TInput, TOutput> {
43
74
  const def: ApiRouteDefinition<TMethod, TPath, TInput, TOutput> = {
44
75
  method,
45
76
  path,
46
77
  handler,
78
+ // Default ON. Opt out only for endpoints that don't trust ambient creds.
79
+ csrf: options?.csrf ?? true,
47
80
  _types: {} as { input: TInput; output: TOutput },
48
81
  };
49
82
  routeRegistry.push(def);
@@ -62,6 +95,14 @@ export async function handleApiRequest(request: Request): Promise<Response | nul
62
95
  if (def.method !== request.method) continue;
63
96
  if (!pathMatches(def.path, url.pathname)) continue;
64
97
 
98
+ // SECURITY(high): CSRF gate for mutating methods. Same check the route
99
+ // action / _action / _stream paths use, so an authenticated user's cookies
100
+ // can't be used to forge a cross-site write to an /api route. Routes that
101
+ // opt out (`csrf: false`) are responsible for not trusting ambient creds.
102
+ if (def.csrf && MUTATING_METHODS.has(def.method) && !isAllowedMutation(request)) {
103
+ return csrfForbiddenResponse();
104
+ }
105
+
65
106
  let input: unknown = undefined;
66
107
  if (request.method !== "GET" && request.method !== "DELETE") {
67
108
  // Trust an advertised Content-Length up front so oversized payloads
@@ -86,6 +127,12 @@ export async function handleApiRequest(request: Request): Promise<Response | nul
86
127
  } catch {
87
128
  return new Response("Bad Request: invalid JSON", { status: 400 });
88
129
  }
130
+ // SECURITY(high): reject prototype-pollution keys before the parsed
131
+ // body reaches a handler that might merge it into another object.
132
+ // Parity with the /_action JSON path.
133
+ if (hasForbiddenKey(input)) {
134
+ return new Response("Bad Request: forbidden keys", { status: 400 });
135
+ }
89
136
  } else if (ct.includes("application/x-www-form-urlencoded") || ct.includes("multipart/form-data")) {
90
137
  input = await request.formData();
91
138
  }
package/src/server/csp.ts CHANGED
@@ -75,14 +75,20 @@ export function csp(options: CspOptions = {}): MiddlewareFn {
75
75
 
76
76
  const directives: Record<string, string | null> = {
77
77
  "default-src": "'self'",
78
- // 'strict-dynamic' lets the nonced bootstrap script load the chunks it
79
- // imports without each chunk needing its own nonce. Falls back to 'self'
80
- // in browsers that don't support it.
78
+ // 'strict-dynamic': trust flows through the nonce a nonced script may
79
+ // load the chunks it imports without each chunk carrying its own nonce.
80
+ // NOTE: in browsers that support 'strict-dynamic', the 'self' and any
81
+ // host/allowlist expressions in script-src are IGNORED; only the nonce
82
+ // (and scripts it transitively loads) are trusted. 'self' is kept solely
83
+ // as a fallback for older browsers that don't implement 'strict-dynamic'.
81
84
  "script-src": `'self' 'nonce-${nonce}' 'strict-dynamic'`,
82
85
  "style-src": options.strict ? "'self'" : "'self' 'unsafe-inline'",
83
86
  "img-src": "'self' data: blob:",
84
87
  "connect-src": "'self'",
85
88
  "base-uri": "'self'",
89
+ // Restrict where <form> can submit so an injected form can't exfiltrate
90
+ // to an attacker origin even if it slips past other controls.
91
+ "form-action": "'self'",
86
92
  "frame-ancestors": "'self'",
87
93
  "object-src": "'none'",
88
94
  ...(options.directives ?? {}),
@@ -23,12 +23,19 @@
23
23
  * none of these headers and are rejected by default — they must set
24
24
  * `X-BractJS-Action` or a same-origin `Origin` to mutate.
25
25
  */
26
+ // This gate protects server actions (/_action), streaming actions (/_stream),
27
+ // route mutations, AND typed /api routes (see api-route.ts) — every
28
+ // state-changing, cookie-trusting surface in the framework.
29
+ //
26
30
  // SECURITY(medium): X-BractJS-Action acts as a CSRF token by relying on CORS
27
31
  // preflight blocking custom headers cross-origin. This is safe only while the
28
32
  // server does NOT emit a permissive Access-Control-Allow-Headers listing this
29
- // header. If CORS policy is ever loosened, Sec-Fetch-Site (1) remains as the
30
- // browser-enforced backstop, and apps that loosen CORS should add a
31
- // cryptographic double-submit token.
33
+ // header. The built-in cors() (middleware/cors.ts) deliberately omits it; if
34
+ // you ship your OWN CORS layer and expose this header cross-origin, you defeat
35
+ // CSRF everywhere — add a cryptographic double-submit token in that case.
36
+ // Sec-Fetch-Site (1) remains a browser-enforced backstop, but note it is NOT
37
+ // sent by every client/proxy: behind a header-stripping proxy the gate falls
38
+ // back to the same-origin Origin check, which cors() does not weaken.
32
39
  import { isExplicitDev } from "./env.ts";
33
40
 
34
41
  /**
@@ -0,0 +1,49 @@
1
+ import type { LayoutChain } from "./layout.ts";
2
+ import type { LoaderResults } from "./loader.ts";
3
+ import type { HeadersFunction } from "../shared/route-types.ts";
4
+
5
+ type Params = Record<string, string>;
6
+
7
+ /**
8
+ * Walk the route module chain (root → layouts → route) calling each module's
9
+ * optional `headers()` export, threading the accumulated `Headers` through as
10
+ * `parentHeaders`. Each call's returned `HeadersInit` is merged on top, so the
11
+ * innermost route wins per key (RR7 semantics).
12
+ *
13
+ * Returns `null` when no module in the chain exports `headers` — callers keep
14
+ * their existing default headers untouched in that case.
15
+ */
16
+ export function resolveHeaders(
17
+ chain: LayoutChain,
18
+ loaderData: LoaderResults,
19
+ params: Params,
20
+ request: Request,
21
+ ): Headers | null {
22
+ const links: Array<{ fn: HeadersFunction; data: unknown }> = [];
23
+
24
+ if (chain.root.headers) links.push({ fn: chain.root.headers, data: loaderData.root });
25
+ chain.layouts.forEach((mod, i) => {
26
+ if (mod.headers) links.push({ fn: mod.headers, data: loaderData.layouts[i] ?? null });
27
+ });
28
+ if (chain.route.headers) links.push({ fn: chain.route.headers, data: loaderData.route });
29
+
30
+ if (links.length === 0) return null;
31
+
32
+ const merged = new Headers();
33
+ for (const { fn, data } of links) {
34
+ const produced = new Headers(fn({ loaderData: data, params, request, parentHeaders: merged }));
35
+ // `set` (not `append`) so an inner route overrides an ancestor's value for
36
+ // the same key rather than accumulating duplicates.
37
+ produced.forEach((value, key) => merged.set(key, value));
38
+ }
39
+ return merged;
40
+ }
41
+
42
+ /**
43
+ * Copy resolved route headers onto a base headers object, overriding any
44
+ * same-key defaults. Mutates and returns `base`. No-op when `resolved` is null.
45
+ */
46
+ export function applyRouteHeaders(base: Headers, resolved: Headers | null): Headers {
47
+ if (resolved) resolved.forEach((value, key) => base.set(key, value));
48
+ return base;
49
+ }
@@ -1,5 +1,5 @@
1
1
  import { join, resolve } from "node:path";
2
- import type { RouteFile } from "./scanner.ts";
2
+ import { layoutDirsFromFilePath, type RouteFile } from "./scanner.ts";
3
3
  import type { RouteModule } from "../shared/route-types.ts";
4
4
 
5
5
  // ── Types ──────────────────────────────────────────────────────────────────
@@ -28,21 +28,6 @@ export interface ResolvedRoute extends RouteFile {
28
28
  */
29
29
  export type ModuleRegistry = Record<string, RouteModule | Record<string, unknown>>;
30
30
 
31
- // ── Helpers ────────────────────────────────────────────────────────────────
32
-
33
- /** Derive the ancestor directory segments from a route's urlPattern. */
34
- function layoutDirs(urlPattern: string): string[] {
35
- if (urlPattern === "") return [];
36
- const segments = urlPattern.split("/");
37
- // For "blog/[id]" → check "routes/blog/layout.tsx" only (not the leaf)
38
- segments.pop();
39
- const dirs: string[] = [];
40
- for (let i = 1; i <= segments.length; i++) {
41
- dirs.push(segments.slice(0, i).join("/"));
42
- }
43
- return dirs;
44
- }
45
-
46
31
  // ── resolveLayoutChain ─────────────────────────────────────────────────────
47
32
 
48
33
  export async function resolveLayoutChain(
@@ -58,8 +43,9 @@ export async function resolveLayoutChain(
58
43
  layoutFiles.push(rootPath);
59
44
  }
60
45
 
61
- // Intermediate layout.tsx files, outermost → innermost
62
- for (const dir of layoutDirs(routeFile.urlPattern)) {
46
+ // Intermediate layout.tsx files, outermost → innermost. Derived from the
47
+ // file path so route-group folders ((marketing)/…) contribute their layout.
48
+ for (const dir of layoutDirsFromFilePath(routeFile.filePath)) {
63
49
  const layoutPath = resolve(join(appDir, "routes", dir, "layout.tsx"));
64
50
  if (await Bun.file(layoutPath).exists()) {
65
51
  layoutFiles.push(layoutPath);
@@ -84,7 +70,7 @@ export function resolveLayoutChainFromRegistry(
84
70
  if (registry["root.tsx"]) layoutFiles.push("root.tsx");
85
71
  else if (registry["root.ts"]) layoutFiles.push("root.ts");
86
72
 
87
- for (const dir of layoutDirs(routeFile.urlPattern)) {
73
+ for (const dir of layoutDirsFromFilePath(routeFile.filePath)) {
88
74
  const tsxKey = `routes/${dir}/layout.tsx`;
89
75
  const tsKey = `routes/${dir}/layout.ts`;
90
76
  if (registry[tsxKey]) layoutFiles.push(tsxKey);
@@ -102,6 +88,10 @@ export async function importRouteModule(filePath: string): Promise<RouteModule>
102
88
  loader: mod.loader,
103
89
  action: mod.action,
104
90
  meta: mod.meta,
91
+ headers: mod.headers,
92
+ // SECURITY(high): like beforeLoad, route middleware can be an auth gate —
93
+ // project it or every `middleware` export becomes a silent no-op.
94
+ middleware: mod.middleware,
105
95
  // SECURITY(high): beforeLoad is the auth/redirect gate and `context` is the
106
96
  // per-route context factory. Both MUST be projected here — dropping them
107
97
  // turns every beforeLoad() export into a silent no-op, bypassing auth on
@@ -134,6 +124,9 @@ function pickRouteModule(mod: Record<string, unknown> | RouteModule | undefined)
134
124
  loader: m.loader as RouteModule["loader"],
135
125
  action: m.action as RouteModule["action"],
136
126
  meta: m.meta as RouteModule["meta"],
127
+ headers: m.headers as RouteModule["headers"],
128
+ // SECURITY(high): keep middleware (auth gate) — see importRouteModule.
129
+ middleware: m.middleware as RouteModule["middleware"],
137
130
  // SECURITY(high): keep beforeLoad + context in the projection — see the
138
131
  // note in importRouteModule. The compiled-binary path goes through here.
139
132
  beforeLoad: m.beforeLoad as RouteModule["beforeLoad"],
@@ -5,6 +5,13 @@ import type { RouteFile, Segment } from "./scanner.ts";
5
5
  export interface TrieNode {
6
6
  children: Map<string, TrieNode>;
7
7
  paramChild?: { name: string; node: TrieNode };
8
+ /**
9
+ * An optional param segment (`[[id]]`). When present it behaves like a param
10
+ * child (binds `name` to the consumed part); the matcher additionally tries
11
+ * skipping it entirely, so the route at `node` matches with the segment
12
+ * absent too (the param is then simply not set).
13
+ */
14
+ optionalChild?: { name: string; node: TrieNode };
8
15
  catchAllChild?: { name: string; node: TrieNode };
9
16
  routeFile?: RouteFile;
10
17
  }
@@ -33,6 +40,9 @@ export function buildTrie(routes: RouteFile[]): TrieNode {
33
40
  } else if ("param" in seg) {
34
41
  if (!node.paramChild) node.paramChild = { name: seg.param, node: makeNode() };
35
42
  node = node.paramChild.node;
43
+ } else if ("optional" in seg) {
44
+ if (!node.optionalChild) node.optionalChild = { name: seg.optional, node: makeNode() };
45
+ node = node.optionalChild.node;
36
46
  } else {
37
47
  // catchAll — terminal, store and stop
38
48
  if (!node.catchAllChild) node.catchAllChild = { name: seg.catchAll, node: makeNode() };
@@ -62,7 +72,13 @@ function walk(
62
72
  ): MatchResult {
63
73
  // All parts consumed — check for route at this node
64
74
  if (idx === parts.length) {
65
- return node.routeFile ? { routeFile: node.routeFile, params } : null;
75
+ if (node.routeFile) return { routeFile: node.routeFile, params };
76
+ // An optional param's segment was omitted (e.g. /users for [[id]]). The
77
+ // route lives one node deeper; the param is simply left unset.
78
+ if (node.optionalChild?.node.routeFile) {
79
+ return { routeFile: node.optionalChild.node.routeFile, params };
80
+ }
81
+ return null;
66
82
  }
67
83
 
68
84
  const part = parts[idx];
@@ -83,7 +99,18 @@ function walk(
83
99
  if (result) return result;
84
100
  }
85
101
 
86
- // 3. Try catch-allconsumes remaining segments
102
+ // 3. Try optional param consume this part as the param (the "present"
103
+ // case). The "absent" case is handled at the all-parts-consumed branch
104
+ // above. Param-before-catch-all keeps optional more specific than splat.
105
+ if (node.optionalChild) {
106
+ const result = walk(node.optionalChild.node, parts, idx + 1, {
107
+ ...params,
108
+ [node.optionalChild.name]: part,
109
+ });
110
+ if (result) return result;
111
+ }
112
+
113
+ // 4. Try catch-all — consumes remaining segments
87
114
  if (node.catchAllChild) {
88
115
  const remaining = parts.slice(idx).join("/");
89
116
  const catchNode = node.catchAllChild.node;
@@ -0,0 +1,50 @@
1
+ import type { LayoutChain } from "./layout.ts";
2
+ import type { LoaderResults } from "./loader.ts";
3
+ import type { RouteMatch } from "../shared/route-types.ts";
4
+
5
+ /**
6
+ * Build the `useMatches()` payload: one entry per module in the chain
7
+ * (root → layouts → route), pairing each module's static `handle` export with
8
+ * its loader-data slice. Serialized into the SSR bootstrap and `/_data` so the
9
+ * client can read it without re-importing every module.
10
+ *
11
+ * `handle` must be JSON-serializable to survive the SSR/soft-nav transport —
12
+ * the same constraint loader data already has.
13
+ */
14
+ export function buildMatches(
15
+ chain: LayoutChain,
16
+ loaderData: LoaderResults,
17
+ params: Record<string, string>,
18
+ pathname: string,
19
+ ): RouteMatch[] {
20
+ const matches: RouteMatch[] = [];
21
+ const files = chain.files;
22
+
23
+ matches.push({
24
+ id: files?.root ?? "root",
25
+ pathname,
26
+ params,
27
+ data: loaderData.root,
28
+ handle: chain.root.handle,
29
+ });
30
+
31
+ chain.layouts.forEach((mod, i) => {
32
+ matches.push({
33
+ id: files?.layouts?.[i] ?? `layout:${i}`,
34
+ pathname,
35
+ params,
36
+ data: loaderData.layouts[i] ?? null,
37
+ handle: mod.handle,
38
+ });
39
+ });
40
+
41
+ matches.push({
42
+ id: files?.route ?? "route",
43
+ pathname,
44
+ params,
45
+ data: loaderData.route,
46
+ handle: chain.route.handle,
47
+ });
48
+
49
+ return matches;
50
+ }
@@ -22,6 +22,13 @@ export class MiddlewarePipeline {
22
22
  return this;
23
23
  }
24
24
 
25
+ /** Remove all registered middleware. Useful for tests and for embedders that
26
+ * rebuild the pipeline (e.g. on a hot reload). */
27
+ clear(): this {
28
+ this.fns = [];
29
+ return this;
30
+ }
31
+
25
32
  /**
26
33
  * Compose all registered middleware into a single chain and execute it.
27
34
  * Each fn calls `next()` to invoke the next fn; the last `next()` calls `handler`.
@@ -49,3 +56,62 @@ export class MiddlewarePipeline {
49
56
 
50
57
  /** Module-level default pipeline — attach middleware here via pipeline.use(). */
51
58
  export const pipeline = new MiddlewarePipeline();
59
+
60
+ // ── Per-route (nested) middleware ────────────────────────────────────────────
61
+
62
+ /**
63
+ * A route/layout/root module's `middleware` entry. Same shape as the global
64
+ * {@link MiddlewareFn}: call `next()` to continue the chain, or return a
65
+ * `Response` to short-circuit (auth gate, redirect). The `ctx.context` object
66
+ * is shared and mutable — set fields on it and downstream middleware, loaders,
67
+ * and actions see them.
68
+ */
69
+ export type RouteMiddleware = MiddlewareFn;
70
+
71
+ /**
72
+ * Compose a route's nested middleware chain (root → layouts → route, in that
73
+ * order) around `handler` and run it. Mirrors {@link MiddlewarePipeline.run}
74
+ * but for an ad-hoc, per-request list rather than the module-level pipeline.
75
+ * An empty list calls `handler` directly (zero overhead for routes that don't
76
+ * use middleware).
77
+ */
78
+ export function runRouteMiddleware(
79
+ fns: RouteMiddleware[],
80
+ ctx: MiddlewareContext,
81
+ handler: () => Promise<Response>,
82
+ ): Promise<Response> {
83
+ if (fns.length === 0) return handler();
84
+ let lastCalled = -1;
85
+ const dispatch = (i: number): Promise<Response> => {
86
+ if (i <= lastCalled) {
87
+ return Promise.reject(new Error("route middleware: next() called more than once"));
88
+ }
89
+ lastCalled = i;
90
+ if (i >= fns.length) return handler();
91
+ return fns[i](ctx, () => dispatch(i + 1));
92
+ };
93
+ return dispatch(0);
94
+ }
95
+
96
+ /**
97
+ * Flatten a route chain's `middleware` exports into a single ordered list:
98
+ * root first, then each layout outermost→innermost, then the leaf route. Each
99
+ * module may export `middleware` as a single fn or an array; both normalize
100
+ * here. Non-function entries are ignored defensively.
101
+ */
102
+ export function collectRouteMiddleware(chain: {
103
+ root: { middleware?: unknown };
104
+ layouts: Array<{ middleware?: unknown }>;
105
+ route: { middleware?: unknown };
106
+ }): RouteMiddleware[] {
107
+ const out: RouteMiddleware[] = [];
108
+ const add = (m: unknown) => {
109
+ if (!m) return;
110
+ const list = Array.isArray(m) ? m : [m];
111
+ for (const fn of list) if (typeof fn === "function") out.push(fn as RouteMiddleware);
112
+ };
113
+ add(chain.root.middleware);
114
+ for (const layout of chain.layouts) add(layout.middleware);
115
+ add(chain.route.middleware);
116
+ return out;
117
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Prototype-pollution guards, shared across every untrusted-object boundary
3
+ * (server actions, typed /api JSON, form/search → object conversions).
4
+ *
5
+ * Two strategies, used where each fits:
6
+ *
7
+ * 1. {@link hasForbiddenKey} — a deep scan that REJECTS a parsed JSON value
8
+ * carrying a dangerous key. Used on /_action and /api JSON bodies, where a
9
+ * 400 is the right UX and the payload shape is the app's contract.
10
+ *
11
+ * 2. {@link nullProtoFromEntries} — builds a null-prototype object from
12
+ * key/value pairs so a key literally named "__proto__" becomes an ordinary
13
+ * own property that can never reach Object.prototype. Used for the
14
+ * FormData / URLSearchParams → object conversions, which must accept
15
+ * arbitrary field names without erroring.
16
+ */
17
+
18
+ // `__proto__` is the actual pollution vector for own-keys produced by
19
+ // JSON.parse. `constructor`/`prototype` are included defensively: a recursive
20
+ // merge that walks `obj.constructor.prototype` can be steered by them too.
21
+ const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
22
+
23
+ // Max nesting we will fully scan. Legitimate payloads are shallow; anything
24
+ // deeper is treated as hostile and rejected (fail closed) — see hasForbiddenKey.
25
+ const MAX_SCAN_DEPTH = 200;
26
+
27
+ /**
28
+ * Deep scan for a forbidden key anywhere in a parsed JSON value.
29
+ *
30
+ * SECURITY(high): this is a security filter, so it FAILS CLOSED. A value nested
31
+ * past MAX_SCAN_DEPTH returns `true` (rejected) rather than being passed
32
+ * through — otherwise an attacker could bury `__proto__` below the cap to evade
33
+ * the check and reach a recursive-merge sink in handler code.
34
+ */
35
+ export function hasForbiddenKey(value: unknown, depth = 0): boolean {
36
+ if (!value || typeof value !== "object") return false;
37
+ if (depth > MAX_SCAN_DEPTH) return true;
38
+ for (const key of Object.keys(value as Record<string, unknown>)) {
39
+ if (FORBIDDEN_KEYS.has(key)) return true;
40
+ if (hasForbiddenKey((value as Record<string, unknown>)[key], depth + 1)) return true;
41
+ }
42
+ return false;
43
+ }
44
+
45
+ /**
46
+ * Build a null-prototype object from [key, value] entries. A key named
47
+ * "__proto__" lands as a plain own property instead of mutating the prototype,
48
+ * so downstream spreads/merges of the result are pollution-safe.
49
+ */
50
+ export function nullProtoFromEntries<V>(
51
+ entries: Iterable<readonly [string, V]>,
52
+ ): Record<string, V> {
53
+ const out = Object.create(null) as Record<string, V>;
54
+ for (const [k, v] of entries) out[k] = v;
55
+ return out;
56
+ }
@@ -1,6 +1,6 @@
1
1
  import { renderToReadableStream } from "react-dom/server";
2
2
  import { createElement, Fragment, type ReactNode } from "react";
3
- import type { MetaDescriptor } from "../shared/route-types.ts";
3
+ import type { MetaDescriptor, RouteMatch } from "../shared/route-types.ts";
4
4
  import { safeStringify, isDevRuntime, getDevHmrPort } from "./env.ts";
5
5
  import { errorOverlayScript } from "../dev/error-overlay.ts";
6
6
  import { mergeMeta } from "./meta.ts";
@@ -22,6 +22,8 @@ export interface RenderOptions {
22
22
  search?: Record<string, unknown>;
23
23
  manifest: ServerManifest;
24
24
  meta: MetaDescriptor[];
25
+ /** The matched route chain (root → layouts → route) for `useMatches()`. */
26
+ matches?: RouteMatch[];
25
27
  status?: number;
26
28
  /** Path of the matched route file (e.g. "routes/_index.tsx"), used by the client to pre-import the module before hydration. */
27
29
  routeFile?: string;
@@ -34,6 +36,11 @@ export interface RenderOptions {
34
36
  * "spa": static shell, everything resolved client-side).
35
37
  */
36
38
  ssrMode?: "client-only" | "data-only" | "spa";
39
+ /**
40
+ * Resolved route `headers()` output (root → layout → route merged). Applied
41
+ * on top of the baseline document headers, overriding any same-key default.
42
+ */
43
+ headers?: Headers | null;
37
44
  }
38
45
 
39
46
  export async function renderRoute(options: RenderOptions): Promise<Response> {
@@ -59,7 +66,7 @@ export async function renderRoute(options: RenderOptions): Promise<Response> {
59
66
  // The merged descriptor array is what the client reads to keep the document
60
67
  // head in sync on soft navigation — keep it shaped, not stringified HTML.
61
68
  const bootstrapScriptContent =
62
- devOverlay + `window.__BRACTJS_DATA__=${safeStringify({ loaderData, actionData, params, pathname, search: options.search, manifest, routeFile: options.routeFile, meta: mergedMeta, ssrMode: options.ssrMode })};`;
69
+ devOverlay + `window.__BRACTJS_DATA__=${safeStringify({ loaderData, actionData, params, pathname, search: options.search, manifest, routeFile: options.routeFile, meta: mergedMeta, matches: options.matches, ssrMode: options.ssrMode })};`;
63
70
 
64
71
  // Render <title>/<meta> elements alongside the app shell. React 19 hoists
65
72
  // document-metadata elements into <head> during streaming SSR, so crawlers
@@ -90,19 +97,30 @@ export async function renderRoute(options: RenderOptions): Promise<Response> {
90
97
 
91
98
  const responseStatus = renderError ? 500 : status;
92
99
 
93
- return new Response(stream, {
94
- status: responseStatus,
95
- headers: {
96
- "Content-Type": "text/html; charset=utf-8",
97
- "Transfer-Encoding": "chunked",
98
- // SECURITY(medium): baseline hardening headers. For a Content-Security-
99
- // Policy, opt into the nonce-based `csp()` middleware — it generates a
100
- // per-request nonce, applies it to the inline bootstrap script + client
101
- // entry module here (via renderToReadableStream's `nonce` option), and
102
- // sets the CSP response header.
103
- "X-Content-Type-Options": "nosniff",
104
- "X-Frame-Options": "SAMEORIGIN",
105
- "Referrer-Policy": "strict-origin-when-cross-origin",
106
- },
100
+ const headers = new Headers({
101
+ "Content-Type": "text/html; charset=utf-8",
102
+ "Transfer-Encoding": "chunked",
103
+ // SECURITY(medium): baseline hardening headers. For a Content-Security-
104
+ // Policy, opt into the nonce-based `csp()` middleware — it generates a
105
+ // per-request nonce, applies it to the inline bootstrap script + client
106
+ // entry module here (via renderToReadableStream's `nonce` option), and
107
+ // sets the CSP response header.
108
+ "X-Content-Type-Options": "nosniff",
109
+ "X-Frame-Options": "SAMEORIGIN",
110
+ "Referrer-Policy": "strict-origin-when-cross-origin",
107
111
  });
112
+
113
+ // Route `headers()` output (root → layout → route) overrides the baseline.
114
+ // Content-Type / Transfer-Encoding stay framework-owned: a route shouldn't
115
+ // be able to corrupt the streamed document envelope. Don't apply on render
116
+ // errors — that path serves a generic 500, not the route's cached document.
117
+ if (options.headers && !renderError) {
118
+ options.headers.forEach((value, key) => {
119
+ const k = key.toLowerCase();
120
+ if (k === "content-type" || k === "transfer-encoding") return;
121
+ headers.set(key, value);
122
+ });
123
+ }
124
+
125
+ return new Response(stream, { status: responseStatus, headers });
108
126
  }