@bractjs/bractjs 0.1.24 → 0.1.26
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 +755 -466
- package/bin/cli.ts +23 -3
- package/package.json +1 -1
- package/src/__tests__/compile-safety.test.ts +163 -0
- package/src/__tests__/compile-smoke.test.ts +276 -0
- package/src/__tests__/csp.test.ts +80 -0
- package/src/__tests__/fixtures/app/routes/_index.tsx +6 -2
- package/src/__tests__/fixtures/app/routes/protected.tsx +15 -0
- package/src/__tests__/fixtures/app/routes/redirect-action.tsx +13 -0
- package/src/__tests__/integration.test.ts +62 -0
- package/src/__tests__/layout-registry.test.ts +23 -0
- package/src/__tests__/loader.test.ts +23 -0
- package/src/__tests__/middleware.test.ts +22 -0
- package/src/__tests__/programmatic-api.test.ts +41 -2
- package/src/__tests__/response.test.ts +54 -1
- package/src/__tests__/security.test.ts +35 -0
- package/src/__tests__/server-module-stub.test.ts +145 -0
- package/src/__tests__/stream-handler.test.ts +36 -0
- package/src/build/bundler.ts +46 -20
- package/src/build/directives.ts +2 -2
- package/src/build/env-plugin.ts +76 -5
- package/src/build/react-dedupe.ts +41 -0
- package/src/client/ClientRouter.tsx +22 -8
- package/src/client/components/Form.tsx +10 -1
- package/src/client/hooks/useFetcher.ts +17 -1
- package/src/client/nav-utils.ts +54 -3
- package/src/client/types.ts +3 -0
- package/src/config/load.ts +50 -2
- package/src/dev/devtools.ts +72 -39
- package/src/dev/hmr-module-handler.ts +6 -4
- package/src/dev/rebuilder.ts +16 -1
- package/src/dev/server.ts +3 -0
- package/src/index.ts +13 -3
- package/src/server/csp.ts +92 -0
- package/src/server/csrf.ts +44 -6
- package/src/server/layout.ts +12 -2
- package/src/server/loader.ts +5 -7
- package/src/server/render.ts +29 -10
- package/src/server/request-handler.ts +15 -4
- package/src/server/response.ts +58 -5
- package/src/server/serve.ts +10 -0
- package/src/server/static.ts +11 -1
- package/src/server/stream-handler.ts +8 -7
- package/src/server/use-client-runtime.ts +62 -0
- package/src/shared/meta-tags.tsx +46 -0
- package/types/index.d.ts +20 -2
package/src/index.ts
CHANGED
|
@@ -25,14 +25,22 @@ export { createCloudflareAdapter, makeCloudflareHandler } from "./adapters/cloud
|
|
|
25
25
|
// - `createUseServerProxyPlugin(appDir)` (client bundle): replaces
|
|
26
26
|
// "use server" exports with fetch proxies. Without it, server-action
|
|
27
27
|
// bodies — including DB queries and secrets — ship inside the browser JS.
|
|
28
|
-
// - `
|
|
29
|
-
//
|
|
28
|
+
// - `serverModuleStubPlugin` (client bundle): replaces every export of a
|
|
29
|
+
// `*.server.ts` module with an inert stub. Because BractJS ships the whole
|
|
30
|
+
// route module (loader + action included) to the client, a route that imports
|
|
31
|
+
// a server module inside its loader pulls that module into the client graph;
|
|
32
|
+
// stubbing keeps the import resolvable while guaranteeing zero server source
|
|
33
|
+
// (DB drivers, secrets) reaches the browser. The stubs throw if ever used on
|
|
34
|
+
// the client. This is the plugin the dev and production client builds use.
|
|
35
|
+
// - `serverOnlyPlugin` (client bundle, legacy): the stricter predecessor that
|
|
36
|
+
// *hard-fails* any `*.server.ts` import. Kept for back-compat / opt-in use
|
|
37
|
+
// when you want server-module imports to be a build error rather than a stub.
|
|
30
38
|
// - `clientEnvPlugin(allowedKeys, env)` (client bundle): allowlists which
|
|
31
39
|
// `process.env.*` references survive into the browser bundle.
|
|
32
40
|
// - `cssModulesPlugin` (client bundle): handles `*.module.css` imports.
|
|
33
41
|
export { cssModulesPlugin, transformCssModule } from "./build/plugins/css-modules.ts";
|
|
34
42
|
export { useClientStubPlugin, createUseServerProxyPlugin, useServerProxyPlugin } from "./build/directives.ts";
|
|
35
|
-
export { serverOnlyPlugin, clientEnvPlugin } from "./build/env-plugin.ts";
|
|
43
|
+
export { serverModuleStubPlugin, serverOnlyPlugin, clientEnvPlugin } from "./build/env-plugin.ts";
|
|
36
44
|
|
|
37
45
|
// Module-registry codegen (drives `bun build --compile` workflow)
|
|
38
46
|
export {
|
|
@@ -78,6 +86,8 @@ export { cors } from "./middleware/cors.ts";
|
|
|
78
86
|
export type { CorsOptions } from "./middleware/cors.ts";
|
|
79
87
|
export { authGuard } from "./middleware/authGuard.ts";
|
|
80
88
|
export type { AuthGuardOptions, SessionStorageLike, SessionLike } from "./middleware/authGuard.ts";
|
|
89
|
+
export { csp, getCspNonce, CSP_NONCE_KEY } from "./server/csp.ts";
|
|
90
|
+
export type { CspOptions } from "./server/csp.ts";
|
|
81
91
|
|
|
82
92
|
// Session
|
|
83
93
|
export { createCookieSession } from "./server/session.ts";
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { MiddlewareFn } from "./middleware.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Context key under which the per-request CSP nonce is stored. The render
|
|
5
|
+
* pipeline reads this and applies it to the inline bootstrap script + the
|
|
6
|
+
* client entry module tags via `renderToReadableStream({ nonce })`, so the
|
|
7
|
+
* scripts BractJS injects satisfy a strict `script-src 'nonce-…'` policy.
|
|
8
|
+
*/
|
|
9
|
+
export const CSP_NONCE_KEY = "__bractCspNonce";
|
|
10
|
+
|
|
11
|
+
export interface CspOptions {
|
|
12
|
+
/**
|
|
13
|
+
* Extra directives to merge into the default policy, keyed by directive name.
|
|
14
|
+
* Values are joined with spaces. A value of `null` removes a default
|
|
15
|
+
* directive entirely. Example:
|
|
16
|
+
* { "img-src": "'self' https://cdn.example", "frame-ancestors": "'none'" }
|
|
17
|
+
*/
|
|
18
|
+
directives?: Record<string, string | null>;
|
|
19
|
+
/**
|
|
20
|
+
* Emit `Content-Security-Policy-Report-Only` instead of the enforcing header.
|
|
21
|
+
* Useful for staging a policy before turning it on. Default: false.
|
|
22
|
+
*/
|
|
23
|
+
reportOnly?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Read the per-request CSP nonce a `csp()` middleware stored on the context.
|
|
28
|
+
* Returns undefined when no CSP middleware ran (CSP is opt-in).
|
|
29
|
+
*/
|
|
30
|
+
export function getCspNonce(context: Record<string, unknown>): string | undefined {
|
|
31
|
+
const v = context[CSP_NONCE_KEY];
|
|
32
|
+
return typeof v === "string" ? v : undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function generateNonce(): string {
|
|
36
|
+
const bytes = new Uint8Array(16);
|
|
37
|
+
crypto.getRandomValues(bytes);
|
|
38
|
+
return btoa(String.fromCharCode(...bytes)).replace(/=+$/, "");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Opt-in nonce-based Content-Security-Policy middleware.
|
|
43
|
+
*
|
|
44
|
+
* Generates a fresh random nonce per request, stashes it on `ctx.context` so
|
|
45
|
+
* the SSR render pipeline can attach it to the scripts BractJS injects, and
|
|
46
|
+
* sets the `Content-Security-Policy` response header. The default policy is a
|
|
47
|
+
* sensible strict baseline; override or extend it via `options.directives`.
|
|
48
|
+
*
|
|
49
|
+
* import { pipeline, csp } from "@bractjs/bractjs";
|
|
50
|
+
* pipeline.use(csp({ directives: { "img-src": "'self' data: https:" } }));
|
|
51
|
+
*
|
|
52
|
+
* SECURITY: only the inline bootstrap script and the client entry module —
|
|
53
|
+
* the scripts BractJS itself emits — are nonced. Any inline script an app adds
|
|
54
|
+
* to its own `root.tsx`/components must carry the same nonce (read it via the
|
|
55
|
+
* render context) or it will be blocked, which is the point of CSP.
|
|
56
|
+
*/
|
|
57
|
+
export function csp(options: CspOptions = {}): MiddlewareFn {
|
|
58
|
+
const reportOnly = options.reportOnly === true;
|
|
59
|
+
const headerName = reportOnly
|
|
60
|
+
? "Content-Security-Policy-Report-Only"
|
|
61
|
+
: "Content-Security-Policy";
|
|
62
|
+
|
|
63
|
+
return async (ctx, next) => {
|
|
64
|
+
const nonce = generateNonce();
|
|
65
|
+
ctx.context[CSP_NONCE_KEY] = nonce;
|
|
66
|
+
|
|
67
|
+
const directives: Record<string, string | null> = {
|
|
68
|
+
"default-src": "'self'",
|
|
69
|
+
// 'strict-dynamic' lets the nonced bootstrap script load the chunks it
|
|
70
|
+
// imports without each chunk needing its own nonce. Falls back to 'self'
|
|
71
|
+
// in browsers that don't support it.
|
|
72
|
+
"script-src": `'self' 'nonce-${nonce}' 'strict-dynamic'`,
|
|
73
|
+
"style-src": "'self' 'unsafe-inline'",
|
|
74
|
+
"img-src": "'self' data: blob:",
|
|
75
|
+
"connect-src": "'self'",
|
|
76
|
+
"base-uri": "'self'",
|
|
77
|
+
"frame-ancestors": "'self'",
|
|
78
|
+
"object-src": "'none'",
|
|
79
|
+
...(options.directives ?? {}),
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const policy = Object.entries(directives)
|
|
83
|
+
.filter(([, v]) => v !== null)
|
|
84
|
+
.map(([k, v]) => `${k} ${v}`)
|
|
85
|
+
.join("; ");
|
|
86
|
+
|
|
87
|
+
const response = await next();
|
|
88
|
+
// Mutate headers in place so we don't break a single-shot streaming body.
|
|
89
|
+
response.headers.set(headerName, policy);
|
|
90
|
+
return response;
|
|
91
|
+
};
|
|
92
|
+
}
|
package/src/server/csrf.ts
CHANGED
|
@@ -1,14 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Cross-origin
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Cross-origin mutation protection for state-changing requests
|
|
3
|
+
* (POST/PUT/DELETE/PATCH and the side-effecting /_action, /_stream endpoints).
|
|
4
|
+
*
|
|
5
|
+
* Defense in depth, in priority order:
|
|
6
|
+
*
|
|
7
|
+
* 1. `Sec-Fetch-Site` — set by the browser, NOT settable from JS (it's a
|
|
8
|
+
* forbidden request header). When present it is authoritative: only
|
|
9
|
+
* `same-origin` and `none` (direct navigation / address bar) are allowed;
|
|
10
|
+
* `cross-site` and `same-site` are rejected. This catches cross-origin
|
|
11
|
+
* forgeries even when the attacker controls the Origin header (non-browser
|
|
12
|
+
* clients) — those won't carry a trustworthy Sec-Fetch-Site.
|
|
13
|
+
*
|
|
14
|
+
* 2. `X-BractJS-Action` — a custom header the client RPC layer sets on every
|
|
15
|
+
* action call. Browsers block custom headers cross-origin without a CORS
|
|
16
|
+
* preflight, so its presence implies a same-origin (or explicitly
|
|
17
|
+
* CORS-allowed) caller.
|
|
18
|
+
*
|
|
19
|
+
* 3. `Origin` — must match the request URL's origin.
|
|
20
|
+
*
|
|
21
|
+
* A request is allowed only when Sec-Fetch-Site does not veto it AND at least
|
|
22
|
+
* one of (2) or (3) holds. Non-browser clients (curl, server-to-server) send
|
|
23
|
+
* none of these headers and are rejected by default — they must set
|
|
24
|
+
* `X-BractJS-Action` or a same-origin `Origin` to mutate.
|
|
6
25
|
*/
|
|
7
|
-
// SECURITY(medium): X-BractJS-Action acts as a CSRF token by relying on CORS
|
|
26
|
+
// SECURITY(medium): X-BractJS-Action acts as a CSRF token by relying on CORS
|
|
27
|
+
// preflight blocking custom headers cross-origin. This is safe only while the
|
|
28
|
+
// 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.
|
|
8
32
|
export function isAllowedMutation(request: Request): boolean {
|
|
33
|
+
// (1) Browser-enforced signal. If present, it vetoes cross-origin requests
|
|
34
|
+
// regardless of what the Origin/custom headers claim.
|
|
35
|
+
const fetchSite = request.headers.get("Sec-Fetch-Site");
|
|
36
|
+
if (fetchSite && fetchSite !== "same-origin" && fetchSite !== "none") {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// (2) Client-issued custom header (blocked cross-origin by CORS preflight).
|
|
9
41
|
if (request.headers.get("X-BractJS-Action")) return true;
|
|
42
|
+
|
|
43
|
+
// (3) Same-origin Origin header.
|
|
10
44
|
const origin = request.headers.get("Origin");
|
|
11
|
-
if (!origin)
|
|
45
|
+
if (!origin) {
|
|
46
|
+
// No Origin header. Allow only when the browser explicitly told us this is
|
|
47
|
+
// a same-origin / direct request via Sec-Fetch-Site; otherwise reject.
|
|
48
|
+
return fetchSite === "same-origin" || fetchSite === "none";
|
|
49
|
+
}
|
|
12
50
|
try {
|
|
13
51
|
return new URL(origin).origin === new URL(request.url).origin;
|
|
14
52
|
} catch {
|
package/src/server/layout.ts
CHANGED
|
@@ -96,10 +96,16 @@ export async function importRouteModule(filePath: string): Promise<RouteModule>
|
|
|
96
96
|
loader: mod.loader,
|
|
97
97
|
action: mod.action,
|
|
98
98
|
meta: mod.meta,
|
|
99
|
+
// SECURITY(high): beforeLoad is the auth/redirect gate and `context` is the
|
|
100
|
+
// per-route context factory. Both MUST be projected here — dropping them
|
|
101
|
+
// turns every beforeLoad() export into a silent no-op, bypassing auth on
|
|
102
|
+
// full-page GET, POST actions, and the /_data soft-nav endpoint alike.
|
|
103
|
+
beforeLoad: mod.beforeLoad,
|
|
104
|
+
context: mod.context,
|
|
99
105
|
handle: mod.handle,
|
|
100
106
|
ErrorBoundary: mod.ErrorBoundary,
|
|
101
107
|
default: mod.default,
|
|
102
|
-
};
|
|
108
|
+
} as RouteModule;
|
|
103
109
|
}
|
|
104
110
|
|
|
105
111
|
/**
|
|
@@ -114,10 +120,14 @@ function pickRouteModule(mod: Record<string, unknown> | RouteModule | undefined)
|
|
|
114
120
|
loader: m.loader as RouteModule["loader"],
|
|
115
121
|
action: m.action as RouteModule["action"],
|
|
116
122
|
meta: m.meta as RouteModule["meta"],
|
|
123
|
+
// SECURITY(high): keep beforeLoad + context in the projection — see the
|
|
124
|
+
// note in importRouteModule. The compiled-binary path goes through here.
|
|
125
|
+
beforeLoad: m.beforeLoad as RouteModule["beforeLoad"],
|
|
126
|
+
context: m.context as unknown,
|
|
117
127
|
handle: m.handle as RouteModule["handle"],
|
|
118
128
|
ErrorBoundary: m.ErrorBoundary as RouteModule["ErrorBoundary"],
|
|
119
129
|
default: m.default as RouteModule["default"],
|
|
120
|
-
};
|
|
130
|
+
} as RouteModule;
|
|
121
131
|
}
|
|
122
132
|
|
|
123
133
|
// ── resolveRouteChain ──────────────────────────────────────────────────────
|
package/src/server/loader.ts
CHANGED
|
@@ -80,21 +80,19 @@ export async function runLoaders(
|
|
|
80
80
|
args: LoaderArgs,
|
|
81
81
|
onError?: OnErrorHook,
|
|
82
82
|
): Promise<LoaderResults> {
|
|
83
|
+
// Run every loader in the chain concurrently — root, all layouts, and the
|
|
84
|
+
// route loader. The route loader is usually the slowest and most important
|
|
85
|
+
// one, so it must not be serialized behind the layout wave.
|
|
83
86
|
const layoutLoaders = chain.layouts.map((mod) =>
|
|
84
87
|
safeRun(mod.loader as ((a: LoaderArgs) => Promise<unknown>) | undefined, args, onError)
|
|
85
88
|
);
|
|
86
89
|
|
|
87
|
-
const [root, ...layoutResults] = await Promise.all([
|
|
90
|
+
const [root, route, ...layoutResults] = await Promise.all([
|
|
88
91
|
safeRun(chain.root.loader as ((a: LoaderArgs) => Promise<unknown>) | undefined, args, onError),
|
|
92
|
+
safeRun(chain.route.loader as ((a: LoaderArgs) => Promise<unknown>) | undefined, args, onError),
|
|
89
93
|
...layoutLoaders,
|
|
90
94
|
]);
|
|
91
95
|
|
|
92
|
-
const route = await safeRun(
|
|
93
|
-
chain.route.loader as ((a: LoaderArgs) => Promise<unknown>) | undefined,
|
|
94
|
-
args,
|
|
95
|
-
onError,
|
|
96
|
-
);
|
|
97
|
-
|
|
98
96
|
return { root, layouts: layoutResults, route };
|
|
99
97
|
}
|
|
100
98
|
|
package/src/server/render.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { renderToReadableStream } from "react-dom/server";
|
|
2
|
-
import type
|
|
2
|
+
import { createElement, Fragment, type ReactNode } from "react";
|
|
3
3
|
import type { MetaDescriptor } from "../shared/route-types.ts";
|
|
4
4
|
import { safeStringify, isDevRuntime } from "./env.ts";
|
|
5
5
|
import { errorOverlayScript } from "../dev/error-overlay.ts";
|
|
6
|
-
import { mergeMeta
|
|
6
|
+
import { mergeMeta } from "./meta.ts";
|
|
7
|
+
import { MetaTags } from "../shared/meta-tags.tsx";
|
|
7
8
|
|
|
8
9
|
export interface ServerManifest {
|
|
9
10
|
clientEntry: string;
|
|
@@ -22,6 +23,8 @@ export interface RenderOptions {
|
|
|
22
23
|
status?: number;
|
|
23
24
|
/** Path of the matched route file (e.g. "routes/_index.tsx"), used by the client to pre-import the module before hydration. */
|
|
24
25
|
routeFile?: string;
|
|
26
|
+
/** Per-request CSP nonce (set by the opt-in `csp()` middleware). Applied to the inline bootstrap script + client entry module tags. */
|
|
27
|
+
nonce?: string;
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
export async function renderRoute(options: RenderOptions): Promise<Response> {
|
|
@@ -38,17 +41,32 @@ export async function renderRoute(options: RenderOptions): Promise<Response> {
|
|
|
38
41
|
const devFlag = isDevRuntime() ? "window.__BRACT_DEV__=true;" : "";
|
|
39
42
|
const devOverlay = isDevRuntime() ? devFlag + errorOverlayScript + "\n" : "";
|
|
40
43
|
const mergedMeta = mergeMeta(options.meta ?? []);
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
// reads — keep it shaped, not stringified HTML.
|
|
44
|
+
// The merged descriptor array is what the client reads to keep the document
|
|
45
|
+
// head in sync on soft navigation — keep it shaped, not stringified HTML.
|
|
44
46
|
const bootstrapScriptContent =
|
|
45
47
|
devOverlay + `window.__BRACTJS_DATA__=${safeStringify({ loaderData, actionData, params, pathname, manifest, routeFile: options.routeFile, meta: mergedMeta })};`;
|
|
46
48
|
|
|
49
|
+
// Render <title>/<meta> elements alongside the app shell. React 19 hoists
|
|
50
|
+
// document-metadata elements into <head> during streaming SSR, so crawlers
|
|
51
|
+
// and no-JS clients receive real meta tags. The client renders the same
|
|
52
|
+
// <MetaTags> inside ClientRouter, so hydration matches and soft navigation
|
|
53
|
+
// re-renders the head.
|
|
54
|
+
const tree = createElement(
|
|
55
|
+
Fragment,
|
|
56
|
+
null,
|
|
57
|
+
createElement(MetaTags, { meta: mergedMeta }),
|
|
58
|
+
shell,
|
|
59
|
+
);
|
|
60
|
+
|
|
47
61
|
let renderError: unknown;
|
|
48
62
|
|
|
49
|
-
const stream = await renderToReadableStream(
|
|
63
|
+
const stream = await renderToReadableStream(tree, {
|
|
50
64
|
bootstrapScriptContent,
|
|
51
65
|
bootstrapModules: [manifest.clientEntry],
|
|
66
|
+
// When the opt-in csp() middleware ran, React stamps this nonce onto the
|
|
67
|
+
// inline bootstrap script and the client entry <script type=module>, so
|
|
68
|
+
// they satisfy a strict `script-src 'nonce-…'` policy.
|
|
69
|
+
nonce: options.nonce,
|
|
52
70
|
onError(error) {
|
|
53
71
|
renderError = error;
|
|
54
72
|
console.error("[bract] renderToReadableStream error:", error);
|
|
@@ -62,10 +80,11 @@ export async function renderRoute(options: RenderOptions): Promise<Response> {
|
|
|
62
80
|
headers: {
|
|
63
81
|
"Content-Type": "text/html; charset=utf-8",
|
|
64
82
|
"Transfer-Encoding": "chunked",
|
|
65
|
-
// SECURITY(medium): baseline hardening headers.
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
83
|
+
// SECURITY(medium): baseline hardening headers. For a Content-Security-
|
|
84
|
+
// Policy, opt into the nonce-based `csp()` middleware — it generates a
|
|
85
|
+
// per-request nonce, applies it to the inline bootstrap script + client
|
|
86
|
+
// entry module here (via renderToReadableStream's `nonce` option), and
|
|
87
|
+
// sets the CSP response header.
|
|
69
88
|
"X-Content-Type-Options": "nosniff",
|
|
70
89
|
"X-Frame-Options": "SAMEORIGIN",
|
|
71
90
|
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
@@ -5,12 +5,13 @@ import { resolveRouteChain, type ModuleRegistry } from "./layout.ts";
|
|
|
5
5
|
import { runLoaders, runAction, buildLoaderArgs, runRouteContext, runBeforeLoad } from "./loader.ts";
|
|
6
6
|
import { renderRoute, type ServerManifest } from "./render.ts";
|
|
7
7
|
import { resolveMeta } from "./meta.ts";
|
|
8
|
-
import { json, error } from "./response.ts";
|
|
8
|
+
import { json, error, sanitizeRedirect } from "./response.ts";
|
|
9
9
|
import { isRedirect, isHttpError } from "../shared/errors.ts";
|
|
10
10
|
import { isExplicitDev } from "./env.ts";
|
|
11
11
|
import { pipeline, type MiddlewareContext } from "./middleware.ts";
|
|
12
12
|
import { BractJSProvider } from "../shared/context.ts";
|
|
13
13
|
import { isAllowedMutation } from "./csrf.ts";
|
|
14
|
+
import { getCspNonce } from "./csp.ts";
|
|
14
15
|
import { fireOnError, type OnErrorHook } from "./lifecycle.ts";
|
|
15
16
|
|
|
16
17
|
export interface HandlerConfig {
|
|
@@ -101,7 +102,7 @@ async function route(
|
|
|
101
102
|
const results = await runLoaders(chain, args, onError);
|
|
102
103
|
return json({ root: results.root, layouts: results.layouts, route: results.route, params: match.params });
|
|
103
104
|
} catch (err) {
|
|
104
|
-
if (isRedirect(err)) return err as Response;
|
|
105
|
+
if (isRedirect(err)) return sanitizeRedirect(err as Response, request.url);
|
|
105
106
|
if (isHttpError(err)) return json({ error: err.message }, { status: err.status });
|
|
106
107
|
console.error("[bractjs] /_data error:", err);
|
|
107
108
|
await fireOnError(onError, err, request);
|
|
@@ -145,13 +146,21 @@ async function route(
|
|
|
145
146
|
const formData = isFormLike ? await request.formData() : new FormData();
|
|
146
147
|
actionData = await runAction(chain.route, { ...args, formData });
|
|
147
148
|
} catch (err) {
|
|
148
|
-
if (isRedirect(err)) return err as Response;
|
|
149
|
+
if (isRedirect(err)) return sanitizeRedirect(err as Response, request.url);
|
|
149
150
|
if (isHttpError(err)) return error(err.message, err.status);
|
|
150
151
|
await fireOnError(onError, err, request);
|
|
151
152
|
if (isExplicitDev()) return error(err instanceof Error ? err.message : String(err), 500);
|
|
152
153
|
return error("Internal Server Error", 500);
|
|
153
154
|
}
|
|
154
155
|
|
|
156
|
+
// An action may *return* (not just throw) a redirect or any Response —
|
|
157
|
+
// the documented pattern is `return redirect("/")`. Propagate it verbatim
|
|
158
|
+
// so the browser/`<Form>` sees a real 3xx (and follows it) instead of a
|
|
159
|
+
// 200 with the Response serialized into a JSON body. sanitizeRedirect()
|
|
160
|
+
// neutralizes an off-origin Location that didn't go through redirect()'s
|
|
161
|
+
// allowExternal opt-in (e.g. a raw `new Response(…,{Location:"//evil"})`).
|
|
162
|
+
if (actionData instanceof Response) return sanitizeRedirect(actionData, request.url);
|
|
163
|
+
|
|
155
164
|
// Client-side Form submits with this header — return JSON, not HTML.
|
|
156
165
|
if (request.headers.get("X-BractJS-Action")) {
|
|
157
166
|
return json(actionData ?? null);
|
|
@@ -163,7 +172,7 @@ async function route(
|
|
|
163
172
|
try {
|
|
164
173
|
loaderResults = await runLoaders(chain, args, onError);
|
|
165
174
|
} catch (err) {
|
|
166
|
-
if (isRedirect(err)) return err as Response;
|
|
175
|
+
if (isRedirect(err)) return sanitizeRedirect(err as Response, request.url);
|
|
167
176
|
if (isHttpError(err)) return error(err.message, err.status);
|
|
168
177
|
await fireOnError(onError, err, request);
|
|
169
178
|
if (isExplicitDev()) return error(err instanceof Error ? err.message : String(err), 500);
|
|
@@ -208,5 +217,7 @@ async function route(
|
|
|
208
217
|
manifest,
|
|
209
218
|
meta,
|
|
210
219
|
routeFile: match.routeFile.filePath,
|
|
220
|
+
// Set by the opt-in csp() middleware; undefined otherwise.
|
|
221
|
+
nonce: getCspNonce(context),
|
|
211
222
|
});
|
|
212
223
|
}
|
package/src/server/response.ts
CHANGED
|
@@ -3,12 +3,32 @@ export interface RedirectOptions {
|
|
|
3
3
|
allowExternal?: boolean;
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
// Brand stamped onto redirect Responses the app explicitly opted into sending
|
|
7
|
+
// off-origin via `redirect(url, …, { allowExternal: true })`. sanitizeRedirect()
|
|
8
|
+
// (below) lets branded Responses through untouched but neutralizes any *other*
|
|
9
|
+
// 3xx whose Location escapes the request origin — e.g. a loader/action that
|
|
10
|
+
// throws a raw `new Response(null,{status:302,headers:{Location:"//evil"}})`,
|
|
11
|
+
// which would otherwise bypass this helper's guard entirely.
|
|
12
|
+
const ALLOW_EXTERNAL = Symbol.for("bractjs.redirect.allowExternal");
|
|
13
|
+
|
|
14
|
+
export function isSafeInternalRedirect(url: string): boolean {
|
|
15
|
+
// Must be path-only: a single leading "/" that does not begin an authority
|
|
16
|
+
// ("//host", "/\\host") or a scheme. Rejects, raw OR percent-encoded:
|
|
17
|
+
// "//evil.com", "/\\evil.com", "/%2f%2fevil.com", "/%5cevil.com",
|
|
18
|
+
// "https://…", "javascript:…", "" — plus any control/whitespace char that
|
|
19
|
+
// browsers strip or normalize ("/\t//evil", "/\n/evil") before resolving.
|
|
9
20
|
if (url.length === 0) return false;
|
|
21
|
+
// Reject any C0 control char, space, or DEL — browsers strip/normalize these
|
|
22
|
+
// and can turn "/\t//evil" into a protocol-relative escape. Checked via char
|
|
23
|
+
// code (not a regex with control-char literals) to keep the source portable.
|
|
24
|
+
for (let i = 0; i < url.length; i++) {
|
|
25
|
+
const c = url.charCodeAt(i);
|
|
26
|
+
if (c <= 0x20 || c === 0x7f) return false;
|
|
27
|
+
}
|
|
10
28
|
if (url[0] !== "/") return false;
|
|
11
|
-
|
|
29
|
+
const rest = url.slice(1).toLowerCase();
|
|
30
|
+
if (rest.startsWith("/") || rest.startsWith("\\")) return false;
|
|
31
|
+
if (rest.startsWith("%2f") || rest.startsWith("%5c")) return false;
|
|
12
32
|
return true;
|
|
13
33
|
}
|
|
14
34
|
|
|
@@ -26,7 +46,40 @@ export function redirect(
|
|
|
26
46
|
}
|
|
27
47
|
const h = new Headers(headers);
|
|
28
48
|
h.set("Location", url);
|
|
29
|
-
|
|
49
|
+
const res = new Response(null, { status, headers: h });
|
|
50
|
+
// Brand opt-in external redirects so the global sanitizer trusts them.
|
|
51
|
+
if (options?.allowExternal) (res as { [ALLOW_EXTERNAL]?: true })[ALLOW_EXTERNAL] = true;
|
|
52
|
+
return res;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Last-line guard applied to every redirect Response the request handler is
|
|
57
|
+
* about to emit. Returns the Response untouched unless it is a 3xx whose
|
|
58
|
+
* `Location` escapes `requestUrl`'s origin AND it was not produced by
|
|
59
|
+
* `redirect(..., { allowExternal: true })`. In that case the off-origin
|
|
60
|
+
* Location is treated as an open-redirect attempt: it is logged and replaced
|
|
61
|
+
* with a 500 so the client never follows it.
|
|
62
|
+
*/
|
|
63
|
+
export function sanitizeRedirect(res: Response, requestUrl: string): Response {
|
|
64
|
+
if (res.status < 300 || res.status >= 400) return res;
|
|
65
|
+
if ((res as { [ALLOW_EXTERNAL]?: true })[ALLOW_EXTERNAL]) return res;
|
|
66
|
+
const loc = res.headers.get("Location");
|
|
67
|
+
if (loc === null) return res;
|
|
68
|
+
// Same-origin absolute Locations are fine; reduce to a path and re-check.
|
|
69
|
+
let safe = isSafeInternalRedirect(loc);
|
|
70
|
+
if (!safe) {
|
|
71
|
+
try {
|
|
72
|
+
safe = new URL(loc, requestUrl).origin === new URL(requestUrl).origin;
|
|
73
|
+
} catch {
|
|
74
|
+
safe = false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (safe) return res;
|
|
78
|
+
console.error(
|
|
79
|
+
`[bractjs] blocked off-origin redirect Location "${loc}". ` +
|
|
80
|
+
`Use redirect(url, status, headers, { allowExternal: true }) to opt in.`,
|
|
81
|
+
);
|
|
82
|
+
return error("Internal Server Error", 500);
|
|
30
83
|
}
|
|
31
84
|
|
|
32
85
|
export function json<T>(data: T, init?: ResponseInit): Response {
|
package/src/server/serve.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { BunAdapter, type BractAdapter } from "./adapter.ts";
|
|
|
12
12
|
import type { ModuleRegistry } from "./layout.ts";
|
|
13
13
|
import { resolve, join } from "node:path";
|
|
14
14
|
import { fireOnError, type OnErrorHook } from "./lifecycle.ts";
|
|
15
|
+
import { installUseClientServerStub } from "./use-client-runtime.ts";
|
|
15
16
|
|
|
16
17
|
export interface I18nConfig {
|
|
17
18
|
locales: string[];
|
|
@@ -107,6 +108,15 @@ export function buildFetchHandler(config: Partial<BractJSConfig>) {
|
|
|
107
108
|
}))
|
|
108
109
|
: Promise.resolve(config.manifest ?? DEFAULT_MANIFEST);
|
|
109
110
|
|
|
111
|
+
// When routes are imported from SOURCE at runtime (dev server AND
|
|
112
|
+
// `bractjs start`, which fall back to scanRoutes + dynamic import rather than
|
|
113
|
+
// a pre-stubbed compiled bundle), a `"use client"` route component would
|
|
114
|
+
// execute during SSR and crash on browser-only hooks. Install the runtime
|
|
115
|
+
// stub that null-renders such modules on the server — parity with the
|
|
116
|
+
// compiled bundle's useClientStubPlugin. Skipped on the compiled path, which
|
|
117
|
+
// supplies a pre-built moduleRegistry.
|
|
118
|
+
if (!config.moduleRegistry) installUseClientServerStub(appDir);
|
|
119
|
+
|
|
110
120
|
// Codegen / compiled-binary path: when the caller supplies pre-scanned
|
|
111
121
|
// routes, skip the runtime `Bun.Glob` scan that `bun build --compile`
|
|
112
122
|
// can't satisfy (the routes/ directory isn't on the filesystem in a
|
package/src/server/static.ts
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
import { join, resolve, sep } from "node:path";
|
|
2
2
|
import { realpath } from "node:fs/promises";
|
|
3
|
+
import { isDevRuntime } from "./env.ts";
|
|
3
4
|
|
|
4
5
|
const IMMUTABLE = "public, max-age=31536000, immutable";
|
|
5
6
|
const NO_CACHE = "no-cache";
|
|
6
7
|
|
|
8
|
+
// Hashed client chunks are safe to cache forever in production (a content change
|
|
9
|
+
// yields a new filename). In DEV, however, the dev rebuilder can reuse a chunk
|
|
10
|
+
// filename across rebuilds while its contents change, so marking them immutable
|
|
11
|
+
// makes the browser pin stale JS (e.g. an old route matcher) for a year. Serve
|
|
12
|
+
// them no-cache in dev so each rebuild is picked up.
|
|
13
|
+
function clientAssetCacheControl(): string {
|
|
14
|
+
return isDevRuntime() ? NO_CACHE : IMMUTABLE;
|
|
15
|
+
}
|
|
16
|
+
|
|
7
17
|
/**
|
|
8
18
|
* Resolve to a canonical path that follows symlinks, with a fallback for
|
|
9
19
|
* `bun build --compile` binaries.
|
|
@@ -66,7 +76,7 @@ export async function serveStatic(
|
|
|
66
76
|
if (!full) return null;
|
|
67
77
|
const file = Bun.file(full);
|
|
68
78
|
if (!(await file.exists())) return null;
|
|
69
|
-
return new Response(file, { headers: { "Cache-Control":
|
|
79
|
+
return new Response(file, { headers: { "Cache-Control": clientAssetCacheControl() } });
|
|
70
80
|
}
|
|
71
81
|
|
|
72
82
|
if (pathname.startsWith("/public/")) {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { resolveAction } from "./action-registry.ts";
|
|
2
2
|
import { isExplicitDev } from "./env.ts";
|
|
3
|
-
import { isAllowedMutation } from "./csrf.ts";
|
|
4
3
|
|
|
5
4
|
// ── SSE helpers ────────────────────────────────────────────────────────────
|
|
6
5
|
|
|
@@ -24,12 +23,14 @@ export async function handleStreamRequest(request: Request): Promise<Response |
|
|
|
24
23
|
// SECURITY(medium): exact-match prevents URL confusion.
|
|
25
24
|
if (url.pathname !== "/_stream") return null;
|
|
26
25
|
|
|
27
|
-
// SECURITY(high): server actions
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
26
|
+
// SECURITY(high): /_stream invokes side-effecting server actions over GET.
|
|
27
|
+
// GET can't carry a body and browsers issue cross-origin GETs from
|
|
28
|
+
// <script>/<img>/<link rel=prefetch> *without* an Origin header, so a bare
|
|
29
|
+
// same-origin-Origin gate is not enough here. Require the client-issued
|
|
30
|
+
// X-BractJS-Action header outright: it's a custom header, so browsers block
|
|
31
|
+
// it cross-origin without a CORS preflight, and the real client (useFetcher)
|
|
32
|
+
// always sends it. This is strictly tighter than the /_action gate.
|
|
33
|
+
if (!request.headers.get("X-BractJS-Action")) {
|
|
33
34
|
return new Response(sseChunk("error", { message: "Forbidden" }), {
|
|
34
35
|
status: 403,
|
|
35
36
|
headers: {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { hasClientDirective, extractExports } from "../build/directives.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Runtime stubbing of `"use client"` modules during SSR, for the source-import
|
|
6
|
+
* code path.
|
|
7
|
+
*
|
|
8
|
+
* The compiled server bundle (`bun build --compile`) applies
|
|
9
|
+
* `useClientStubPlugin`, replacing every `"use client"` export with a
|
|
10
|
+
* `() => null` component so SSR never calls browser-only hooks/APIs. But both
|
|
11
|
+
* the dev server AND `bractjs start` render route modules via a raw `import()`
|
|
12
|
+
* of the SOURCE (they fall back to `scanRoutes` + dynamic import rather than the
|
|
13
|
+
* pre-stubbed bundle). Without this, a `"use client"` route component executes
|
|
14
|
+
* on the server and crashes on `useState`/`useRef` ("Invalid hook call").
|
|
15
|
+
*
|
|
16
|
+
* Registering this `Bun.plugin` makes the server process apply the same
|
|
17
|
+
* transform at module-load time. It only affects this process's `import()`
|
|
18
|
+
* (SSR) — the separately bundled client still ships the real component, so
|
|
19
|
+
* hydration restores interactivity in the browser.
|
|
20
|
+
*
|
|
21
|
+
* The filter is scoped to source files UNDER appDir (never node_modules): a
|
|
22
|
+
* runtime onLoad must always return an object, so any matched non-client file is
|
|
23
|
+
* passed through verbatim, and we must not re-transpile third-party packages
|
|
24
|
+
* (doing so breaks CJS interop shapes such as react/jsx-dev-runtime).
|
|
25
|
+
*
|
|
26
|
+
* Idempotent: safe to call more than once per process.
|
|
27
|
+
*/
|
|
28
|
+
let installed = false;
|
|
29
|
+
|
|
30
|
+
function escapeRegExp(s: string): string {
|
|
31
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function installUseClientServerStub(appDir = "./app"): void {
|
|
35
|
+
if (installed) return;
|
|
36
|
+
installed = true;
|
|
37
|
+
|
|
38
|
+
const absAppDir = resolve(appDir);
|
|
39
|
+
const filter = new RegExp(`^${escapeRegExp(absAppDir)}.*\\.(tsx?|jsx?)$`);
|
|
40
|
+
|
|
41
|
+
Bun.plugin({
|
|
42
|
+
name: "bractjs:use-client-server-stub",
|
|
43
|
+
setup(build) {
|
|
44
|
+
build.onLoad({ filter }, async ({ path }) => {
|
|
45
|
+
const src = await Bun.file(path).text();
|
|
46
|
+
const loader = path.endsWith(".tsx") ? "tsx" : path.endsWith(".jsx") ? "jsx" : path.endsWith(".ts") ? "ts" : "js";
|
|
47
|
+
if (!hasClientDirective(src)) {
|
|
48
|
+
// Runtime onLoad must return an object; pass app source through. Bun
|
|
49
|
+
// transpiles app TS/TSX anyway, so this is a no-op in practice.
|
|
50
|
+
return { contents: src, loader };
|
|
51
|
+
}
|
|
52
|
+
const names = extractExports(src).filter((n) => n !== "default");
|
|
53
|
+
const stubs = names.map((n) => `export const ${n} = () => null;`);
|
|
54
|
+
// Always provide a null default — a "use client" module rendered on the
|
|
55
|
+
// server should yield nothing, regardless of how default is declared
|
|
56
|
+
// (which `extractExports` can't always detect, e.g. `export default X`).
|
|
57
|
+
stubs.push("export default () => null;");
|
|
58
|
+
return { contents: stubs.join("\n"), loader: "ts" };
|
|
59
|
+
});
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
}
|