@bractjs/bractjs 0.1.28 → 0.2.0
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 +98 -17
- package/package.json +8 -7
- package/src/__tests__/fixtures/app/routes/features-demo.tsx +28 -0
- package/src/__tests__/headers.test.ts +111 -0
- package/src/__tests__/integration.test.ts +34 -0
- package/src/__tests__/layout-registry.test.ts +7 -3
- package/src/__tests__/matcher.test.ts +29 -0
- package/src/__tests__/module-registry.test.ts +2 -3
- package/src/__tests__/route-lint.test.ts +5 -0
- package/src/__tests__/route-middleware.test.ts +84 -0
- package/src/__tests__/scanner.test.ts +46 -1
- package/src/__tests__/security-fixes.test.ts +201 -0
- package/src/__tests__/use-matches.test.ts +54 -0
- package/src/build/route-lint.ts +3 -3
- package/src/client/ClientRouter.tsx +118 -18
- package/src/client/hooks/useMatches.ts +32 -0
- package/src/client/router.tsx +7 -1
- package/src/client/rpc.ts +11 -1
- package/src/codegen/module-registry.ts +13 -21
- package/src/codegen/route-codegen.ts +8 -3
- package/src/config/load.ts +1 -0
- package/src/index.ts +11 -3
- package/src/server/action-handler.ts +1 -20
- package/src/server/adapter.ts +16 -0
- package/src/server/api-route.ts +47 -0
- package/src/server/csp.ts +9 -3
- package/src/server/csrf.ts +10 -3
- package/src/server/headers.ts +49 -0
- package/src/server/layout.ts +12 -19
- package/src/server/matcher.ts +29 -2
- package/src/server/matches.ts +50 -0
- package/src/server/middleware.ts +66 -0
- package/src/server/proto-guard.ts +56 -0
- package/src/server/render.ts +34 -16
- package/src/server/request-handler.ts +67 -27
- package/src/server/scanner.ts +45 -3
- package/src/server/search.ts +5 -1
- package/src/server/serve.ts +28 -3
- package/src/server/session.ts +12 -1
- package/src/server/validate.ts +4 -1
- package/src/shared/context.ts +3 -1
- package/src/shared/route-types.ts +108 -0
- package/types/config.d.ts +3 -0
- package/types/index.d.ts +17 -0
- package/types/route.d.ts +76 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join, resolve } from "node:path";
|
|
2
|
-
import { scanRoutes, type RouteFile } from "../server/scanner.ts";
|
|
2
|
+
import { scanRoutes, layoutDirsFromFilePath, type RouteFile } from "../server/scanner.ts";
|
|
3
3
|
|
|
4
4
|
// Codegen entry-points: `bun build --compile` can't statically trace
|
|
5
5
|
// `Bun.Glob` scans or `import(absPath)` calls, so we materialise the route /
|
|
@@ -9,13 +9,14 @@ import { scanRoutes, type RouteFile } from "../server/scanner.ts";
|
|
|
9
9
|
|
|
10
10
|
// ── Path safety ────────────────────────────────────────────────────────────
|
|
11
11
|
|
|
12
|
-
// Allow ASCII filename characters, `/` for nested directories,
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
12
|
+
// Allow ASCII filename characters, `/` for nested directories, `[`/`]` for
|
|
13
|
+
// file-based dynamic route syntax (`[id]`, `[...slug]`, `[[id]]`), and `(`/`)`
|
|
14
|
+
// for route-group folders (`(marketing)`). All emit sites wrap the path in
|
|
15
|
+
// JSON.stringify, but we still allowlist the charset as defense-in-depth
|
|
16
|
+
// against a hostile filename containing a backtick, $, quote, backslash, or
|
|
17
|
+
// whitespace breaking out of the generated literal. `..` as a whole segment is
|
|
18
|
+
// rejected separately below (path-traversal guard).
|
|
19
|
+
const SAFE_FILEPATH_RE = /^[A-Za-z0-9._\/\-\[\]()]+$/;
|
|
19
20
|
|
|
20
21
|
function assertSafeFilePath(filePath: string): void {
|
|
21
22
|
if (!SAFE_FILEPATH_RE.test(filePath)) {
|
|
@@ -37,26 +38,17 @@ function pathToIdent(prefix: string, relPath: string): string {
|
|
|
37
38
|
|
|
38
39
|
// ── Layout discovery ───────────────────────────────────────────────────────
|
|
39
40
|
|
|
40
|
-
function layoutDirsForPattern(urlPattern: string): string[] {
|
|
41
|
-
if (urlPattern === "") return [];
|
|
42
|
-
const segments = urlPattern.split("/");
|
|
43
|
-
segments.pop();
|
|
44
|
-
const dirs: string[] = [];
|
|
45
|
-
for (let i = 1; i <= segments.length; i++) {
|
|
46
|
-
dirs.push(segments.slice(0, i).join("/"));
|
|
47
|
-
}
|
|
48
|
-
return dirs;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
41
|
/**
|
|
52
42
|
* Find every `routes/<dir>/layout.tsx` (or `.ts`) that exists on disk for the
|
|
53
43
|
* given set of routes. Mirrors the runtime probe in `resolveLayoutChain` but
|
|
54
|
-
* runs once at codegen time so the generated registry is exhaustive.
|
|
44
|
+
* runs once at codegen time so the generated registry is exhaustive. Layout
|
|
45
|
+
* dirs are derived from each route's FILE path (via `layoutDirsFromFilePath`)
|
|
46
|
+
* so route-group folders are covered identically to the runtime.
|
|
55
47
|
*/
|
|
56
48
|
async function collectLayouts(appDir: string, routes: RouteFile[]): Promise<string[]> {
|
|
57
49
|
const layoutPaths = new Set<string>();
|
|
58
50
|
for (const route of routes) {
|
|
59
|
-
for (const dir of
|
|
51
|
+
for (const dir of layoutDirsFromFilePath(route.filePath)) {
|
|
60
52
|
for (const ext of ["tsx", "ts"]) {
|
|
61
53
|
const rel = `routes/${dir}/layout.${ext}`;
|
|
62
54
|
const abs = resolve(join(appDir, rel));
|
|
@@ -3,11 +3,12 @@ import { scanRoutes } from "../server/scanner.ts";
|
|
|
3
3
|
import type { Segment } from "../server/scanner.ts";
|
|
4
4
|
import { hashString } from "../build/hash.ts";
|
|
5
5
|
|
|
6
|
-
// Convert [param] / [...catchAll] notation to :param colon-style
|
|
6
|
+
// Convert [param] / [[optional]] / [...catchAll] notation to :param colon-style.
|
|
7
7
|
function patternToColon(urlPattern: string): string {
|
|
8
8
|
if (urlPattern === "") return "/";
|
|
9
9
|
return "/" + urlPattern.split("/").map((seg) => {
|
|
10
10
|
if (seg.startsWith("[...") && seg.endsWith("]")) return ":" + seg.slice(4, -1);
|
|
11
|
+
if (seg.startsWith("[[") && seg.endsWith("]]")) return ":" + seg.slice(2, -2);
|
|
11
12
|
if (seg.startsWith("[") && seg.endsWith("]")) return ":" + seg.slice(1, -1);
|
|
12
13
|
return seg;
|
|
13
14
|
}).join("/");
|
|
@@ -16,7 +17,9 @@ function patternToColon(urlPattern: string): string {
|
|
|
16
17
|
function paramsFromSegments(segments: Segment[]): string[] {
|
|
17
18
|
return segments.flatMap((seg) =>
|
|
18
19
|
typeof seg === "string" ? [] :
|
|
19
|
-
"param" in seg ? [seg.param] :
|
|
20
|
+
"param" in seg ? [seg.param] :
|
|
21
|
+
"optional" in seg ? [seg.optional] :
|
|
22
|
+
[seg.catchAll],
|
|
20
23
|
);
|
|
21
24
|
}
|
|
22
25
|
|
|
@@ -36,7 +39,9 @@ function substituteParams(pattern: string, params: string[]): string {
|
|
|
36
39
|
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_]*))*$|^\/$/;
|
|
37
40
|
const SAFE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
38
41
|
// Same guard the module-registry codegen applies before emitting import paths.
|
|
39
|
-
|
|
42
|
+
// Parens are permitted for route-group folders like `(marketing)`; they are
|
|
43
|
+
// inert inside the double-quoted import string the codegen emits.
|
|
44
|
+
const SAFE_FILEPATH_RE = /^[A-Za-z0-9._\/\-\[\]()]+$/;
|
|
40
45
|
|
|
41
46
|
function assertSafePattern(pattern: string): void {
|
|
42
47
|
if (!SAFE_PATTERN_RE.test(pattern)) {
|
package/src/config/load.ts
CHANGED
|
@@ -26,6 +26,7 @@ export function validateUserConfig(cfg: unknown): Partial<BractJSConfig> {
|
|
|
26
26
|
|
|
27
27
|
check("port", typeof c.port === "number" && Number.isFinite(c.port), "a finite number");
|
|
28
28
|
check("hmrPort", typeof c.hmrPort === "number" && Number.isFinite(c.hmrPort), "a finite number");
|
|
29
|
+
check("maxRequestBodySize", typeof c.maxRequestBodySize === "number" && Number.isFinite(c.maxRequestBodySize) && c.maxRequestBodySize > 0, "a positive finite number");
|
|
29
30
|
check("appDir", typeof c.appDir === "string", "a string");
|
|
30
31
|
check("publicDir", typeof c.publicDir === "string", "a string");
|
|
31
32
|
check("buildDir", typeof c.buildDir === "string", "a string");
|
package/src/index.ts
CHANGED
|
@@ -4,9 +4,10 @@ export { buildFetchHandler } from "./server/serve.ts";
|
|
|
4
4
|
export { defineContext } from "./server/context.ts";
|
|
5
5
|
export type { ContextFactory } from "./server/context.ts";
|
|
6
6
|
export { route } from "./server/api-route.ts";
|
|
7
|
-
export type { ApiRouteDefinition, AppApiRoutes } from "./server/api-route.ts";
|
|
7
|
+
export type { ApiRouteDefinition, ApiRouteOptions, AppApiRoutes } from "./server/api-route.ts";
|
|
8
8
|
export { validate, safeValidate, isValidationResponse, readValidationError } from "./server/validate.ts";
|
|
9
9
|
export type { FieldErrors, ValidationError, SafeValidateResult } from "./server/validate.ts";
|
|
10
|
+
export { hasForbiddenKey, nullProtoFromEntries } from "./server/proto-guard.ts";
|
|
10
11
|
export { formText, formValues } from "./shared/form-data.ts";
|
|
11
12
|
export { defineActions } from "./shared/define-actions.ts";
|
|
12
13
|
export { validateSearch, searchParamsToObject } from "./server/search.ts";
|
|
@@ -71,6 +72,12 @@ export type {
|
|
|
71
72
|
LoaderFunction,
|
|
72
73
|
ActionFunction,
|
|
73
74
|
MetaFunction,
|
|
75
|
+
HeadersFunction,
|
|
76
|
+
HeadersArgs,
|
|
77
|
+
RouteMiddlewareFunction,
|
|
78
|
+
ClientLoaderFunction,
|
|
79
|
+
ClientActionFunction,
|
|
80
|
+
RouteMatch,
|
|
74
81
|
RouteModule,
|
|
75
82
|
RouteDefinition,
|
|
76
83
|
RouterLocation,
|
|
@@ -87,8 +94,8 @@ export { BractJSContext, BractJSProvider, useBractJSContext } from "./shared/con
|
|
|
87
94
|
export type { BractJSContextValue, RouteManifest } from "./shared/context.ts";
|
|
88
95
|
|
|
89
96
|
// Middleware
|
|
90
|
-
export { pipeline, MiddlewarePipeline } from "./server/middleware.ts";
|
|
91
|
-
export type { MiddlewareFn, MiddlewareContext } from "./server/middleware.ts";
|
|
97
|
+
export { pipeline, MiddlewarePipeline, runRouteMiddleware, collectRouteMiddleware } from "./server/middleware.ts";
|
|
98
|
+
export type { MiddlewareFn, MiddlewareContext, RouteMiddleware } from "./server/middleware.ts";
|
|
92
99
|
export { requestLogger } from "./middleware/requestLogger.ts";
|
|
93
100
|
export { cors } from "./middleware/cors.ts";
|
|
94
101
|
export type { CorsOptions } from "./middleware/cors.ts";
|
|
@@ -118,6 +125,7 @@ export { useLoaderData } from "./client/hooks/useLoaderData.ts";
|
|
|
118
125
|
export { useActionData } from "./client/hooks/useActionData.ts";
|
|
119
126
|
export { useLocation } from "./client/hooks/useLocation.ts";
|
|
120
127
|
export { useParams } from "./client/hooks/useParams.ts";
|
|
128
|
+
export { useMatches } from "./client/hooks/useMatches.ts";
|
|
121
129
|
export { useNavigation } from "./client/hooks/useNavigation.ts";
|
|
122
130
|
export { useNavigate } from "./client/hooks/useNavigate.ts";
|
|
123
131
|
export type { NavigateFn, NavigateOptions } from "./client/hooks/useNavigate.ts";
|
|
@@ -1,31 +1,12 @@
|
|
|
1
1
|
import { resolveAction } from "./action-registry.ts";
|
|
2
2
|
import { json } from "./response.ts";
|
|
3
3
|
import { isAllowedMutation, csrfForbiddenResponse } from "./csrf.ts";
|
|
4
|
+
import { hasForbiddenKey } from "./proto-guard.ts";
|
|
4
5
|
|
|
5
|
-
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
6
6
|
// Cap action JSON bodies. Anything over this looks like an abuse attempt;
|
|
7
7
|
// FormData uploads (large files) take the multipart branch and bypass this.
|
|
8
8
|
const MAX_JSON_BODY_BYTES = 1_048_576; // 1 MiB
|
|
9
9
|
|
|
10
|
-
// Max nesting we will fully scan for forbidden keys. Legitimate action payloads
|
|
11
|
-
// are shallow; anything deeper is treated as hostile.
|
|
12
|
-
const MAX_SCAN_DEPTH = 200;
|
|
13
|
-
|
|
14
|
-
// Deep scan: nested objects can carry __proto__ pollution vectors too.
|
|
15
|
-
// SECURITY(high): this is a security filter, so it must FAIL CLOSED. A payload
|
|
16
|
-
// nested past MAX_SCAN_DEPTH is rejected (returns true) rather than silently
|
|
17
|
-
// passed — otherwise an attacker could bury `__proto__` below the cap to evade
|
|
18
|
-
// the check and reach a recursive-merge sink in action code.
|
|
19
|
-
function hasForbiddenKey(value: unknown, depth = 0): boolean {
|
|
20
|
-
if (!value || typeof value !== "object") return false;
|
|
21
|
-
if (depth > MAX_SCAN_DEPTH) return true;
|
|
22
|
-
for (const key of Object.keys(value as Record<string, unknown>)) {
|
|
23
|
-
if (FORBIDDEN_KEYS.has(key)) return true;
|
|
24
|
-
if (hasForbiddenKey((value as Record<string, unknown>)[key], depth + 1)) return true;
|
|
25
|
-
}
|
|
26
|
-
return false;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
10
|
export async function handleActionRequest(request: Request): Promise<Response | null> {
|
|
30
11
|
const url = new URL(request.url);
|
|
31
12
|
// SECURITY(medium): exact-match prevents URL confusion (e.g. "/_actionfoo"
|
package/src/server/adapter.ts
CHANGED
|
@@ -22,9 +22,24 @@ export interface BractAdapter {
|
|
|
22
22
|
* Default adapter — wraps `Bun.serve()`.
|
|
23
23
|
* Created internally by `createServer()` when no adapter is provided.
|
|
24
24
|
*/
|
|
25
|
+
// SECURITY(medium): hard ceiling on request body size at the server boundary,
|
|
26
|
+
// independent of any Content-Length the client advertises. The per-route and
|
|
27
|
+
// /_action handlers apply their own (smaller) caps and double-check the decoded
|
|
28
|
+
// size, but this is the single backstop every code path inherits — it bounds
|
|
29
|
+
// memory even for paths that don't pre-check (e.g. an app's own /api handler
|
|
30
|
+
// that reads request.formData() directly). Sits above the 10 MiB route-form
|
|
31
|
+
// cap so legitimate uploads still pass; raise it via the `maxRequestBodySize`
|
|
32
|
+
// config for apps with a dedicated large-upload endpoint.
|
|
33
|
+
const DEFAULT_MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024; // 16 MiB
|
|
34
|
+
|
|
25
35
|
export class BunAdapter implements BractAdapter {
|
|
26
36
|
private server: ReturnType<typeof Bun.serve> | null = null;
|
|
27
37
|
private handler: ((request: Request) => Promise<Response>) | null = null;
|
|
38
|
+
private maxRequestBodySize: number;
|
|
39
|
+
|
|
40
|
+
constructor(maxRequestBodySize: number = DEFAULT_MAX_REQUEST_BODY_BYTES) {
|
|
41
|
+
this.maxRequestBodySize = maxRequestBodySize;
|
|
42
|
+
}
|
|
28
43
|
|
|
29
44
|
setHandler(handler: (request: Request) => Promise<Response>): void {
|
|
30
45
|
this.handler = handler;
|
|
@@ -40,6 +55,7 @@ export class BunAdapter implements BractAdapter {
|
|
|
40
55
|
const handler = this.handler;
|
|
41
56
|
this.server = Bun.serve({
|
|
42
57
|
port,
|
|
58
|
+
maxRequestBodySize: this.maxRequestBodySize,
|
|
43
59
|
fetch: handler,
|
|
44
60
|
error(err: Error) {
|
|
45
61
|
console.error("[bractjs] unhandled server error:", err);
|
package/src/server/api-route.ts
CHANGED
|
@@ -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'
|
|
79
|
-
// imports without each chunk
|
|
80
|
-
// in browsers that
|
|
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 ?? {}),
|
package/src/server/csrf.ts
CHANGED
|
@@ -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.
|
|
30
|
-
//
|
|
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
|
+
}
|
package/src/server/layout.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join, resolve } from "node:path";
|
|
2
|
-
import type
|
|
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
|
-
|
|
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
|
|
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"],
|
package/src/server/matcher.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
+
}
|