@bractjs/bractjs 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/action-handler.test.ts +47 -0
- package/src/__tests__/action-registry.test.ts +73 -0
- package/src/__tests__/codegen.test.ts +50 -0
- package/src/__tests__/deferred.test.ts +96 -0
- package/src/__tests__/directives.test.ts +52 -0
- package/src/__tests__/env.test.ts +73 -0
- package/src/__tests__/errors.test.ts +113 -0
- package/src/__tests__/hash.test.ts +19 -0
- package/src/__tests__/integration.test.ts +1 -1
- package/src/__tests__/manifest.test.ts +60 -0
- package/src/__tests__/middleware.test.ts +216 -0
- package/src/__tests__/response.test.ts +106 -0
- package/src/__tests__/security.test.ts +348 -0
- package/src/__tests__/session.test.ts +3 -3
- package/src/build/bundler.ts +15 -5
- package/src/build/directives.ts +30 -3
- package/src/build/env-plugin.ts +1 -0
- package/src/build/hash.ts +0 -20
- package/src/client/ClientRouter.tsx +8 -4
- package/src/codegen/route-codegen.ts +33 -9
- package/src/dev/hmr-module-handler.ts +14 -4
- package/src/image/cache.ts +28 -8
- package/src/image/handler.ts +26 -11
- package/src/image/optimizer.ts +45 -13
- package/src/image/types.ts +1 -0
- package/src/middleware/cors.ts +24 -8
- package/src/server/action-handler.ts +40 -1
- package/src/server/action-registry.ts +14 -1
- package/src/server/csrf.ts +16 -0
- package/src/server/env.ts +10 -4
- package/src/server/middleware.ts +11 -7
- package/src/server/render.ts +7 -5
- package/src/server/request-handler.ts +14 -13
- package/src/server/response.ts +29 -5
- package/src/server/scanner.ts +6 -2
- package/src/server/session.ts +16 -5
- package/src/server/static.ts +23 -7
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-origin POST/PUT/DELETE/PATCH protection.
|
|
3
|
+
* Allow when: request carries X-BractJS-Action header (client-issued, blocked
|
|
4
|
+
* cross-origin by CORS for non-simple requests), OR the Origin header matches
|
|
5
|
+
* the request URL's origin.
|
|
6
|
+
*/
|
|
7
|
+
export function isAllowedMutation(request: Request): boolean {
|
|
8
|
+
if (request.headers.get("X-BractJS-Action")) return true;
|
|
9
|
+
const origin = request.headers.get("Origin");
|
|
10
|
+
if (!origin) return false;
|
|
11
|
+
try {
|
|
12
|
+
return new URL(origin).origin === new URL(request.url).origin;
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/server/env.ts
CHANGED
|
@@ -10,6 +10,11 @@ export function requireEnv(key: string): string {
|
|
|
10
10
|
return value;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
// Build LS/PS at runtime so the source contains no raw U+2028/U+2029
|
|
14
|
+
// (which would break JS parsing as LineTerminators).
|
|
15
|
+
const LS = String.fromCharCode(0x2028);
|
|
16
|
+
const PS = String.fromCharCode(0x2029);
|
|
17
|
+
|
|
13
18
|
export function safeStringify(data: unknown): string {
|
|
14
19
|
const seen = new WeakSet();
|
|
15
20
|
const json = JSON.stringify(data, (_key, value) => {
|
|
@@ -19,11 +24,12 @@ export function safeStringify(data: unknown): string {
|
|
|
19
24
|
}
|
|
20
25
|
return value;
|
|
21
26
|
});
|
|
22
|
-
// Escape HTML-sensitive
|
|
23
|
-
//
|
|
24
|
-
// JSON.parse on the client decodes them transparently.
|
|
27
|
+
// Escape HTML-sensitive chars + JS LineTerminators (U+2028 / U+2029) so this
|
|
28
|
+
// JSON is safe to embed inside a <script> tag.
|
|
25
29
|
return json
|
|
26
30
|
.replace(/</g, "\\u003c")
|
|
27
31
|
.replace(/>/g, "\\u003e")
|
|
28
|
-
.replace(/&/g, "\\u0026")
|
|
32
|
+
.replace(/&/g, "\\u0026")
|
|
33
|
+
.replaceAll(LS, "\\u2028")
|
|
34
|
+
.replaceAll(PS, "\\u2029");
|
|
29
35
|
}
|
package/src/server/middleware.ts
CHANGED
|
@@ -30,16 +30,20 @@ export class MiddlewarePipeline {
|
|
|
30
30
|
ctx: MiddlewareContext,
|
|
31
31
|
handler: () => Promise<Response>,
|
|
32
32
|
): Promise<Response> {
|
|
33
|
-
let index = 0;
|
|
34
33
|
const fns = this.fns;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
let lastCalled = -1;
|
|
35
|
+
|
|
36
|
+
const dispatch = (i: number): Promise<Response> => {
|
|
37
|
+
if (i <= lastCalled) {
|
|
38
|
+
return Promise.reject(new Error("middleware: next() called more than once"));
|
|
39
|
+
}
|
|
40
|
+
lastCalled = i;
|
|
41
|
+
if (i >= fns.length) return handler();
|
|
42
|
+
const fn = fns[i];
|
|
43
|
+
return fn(ctx, () => dispatch(i + 1));
|
|
40
44
|
};
|
|
41
45
|
|
|
42
|
-
return dispatch();
|
|
46
|
+
return dispatch(0);
|
|
43
47
|
}
|
|
44
48
|
}
|
|
45
49
|
|
package/src/server/render.ts
CHANGED
|
@@ -35,12 +35,14 @@ export async function renderRoute(options: RenderOptions): Promise<Response> {
|
|
|
35
35
|
status = 200,
|
|
36
36
|
} = options;
|
|
37
37
|
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
//
|
|
38
|
+
const devFlag = isDev() ? "window.__BRACT_DEV__=true;" : "";
|
|
39
|
+
const devOverlay = isDev() ? devFlag + errorOverlayScript + "\n" : "";
|
|
40
|
+
const mergedMeta = mergeMeta(options.meta ?? []);
|
|
41
|
+
// metaHtml is injected into <head> via React (the renderToReadableStream tree
|
|
42
|
+
// is expected to use it). The merged descriptor array is what the client
|
|
43
|
+
// reads — keep it shaped, not stringified HTML.
|
|
42
44
|
const bootstrapScriptContent =
|
|
43
|
-
devOverlay + `window.__BRACTJS_DATA__=${safeStringify({ loaderData, actionData, params, pathname, manifest, routeFile: options.routeFile, meta:
|
|
45
|
+
devOverlay + `window.__BRACTJS_DATA__=${safeStringify({ loaderData, actionData, params, pathname, manifest, routeFile: options.routeFile, meta: mergedMeta })};`;
|
|
44
46
|
|
|
45
47
|
let renderError: unknown;
|
|
46
48
|
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { join } from "node:path";
|
|
2
1
|
import { createElement } from "react";
|
|
3
2
|
import type { TrieNode } from "./matcher.ts";
|
|
4
3
|
import { matchRoute } from "./matcher.ts";
|
|
@@ -7,9 +6,11 @@ import { runLoaders, runAction, buildLoaderArgs } from "./loader.ts";
|
|
|
7
6
|
import { renderRoute, type ServerManifest } from "./render.ts";
|
|
8
7
|
import { resolveMeta } from "./meta.ts";
|
|
9
8
|
import { json, error } from "./response.ts";
|
|
10
|
-
import { isRedirect } from "../shared/errors.ts";
|
|
9
|
+
import { isRedirect, isHttpError } from "../shared/errors.ts";
|
|
10
|
+
import { isDev } from "./env.ts";
|
|
11
11
|
import { pipeline, type MiddlewareContext } from "./middleware.ts";
|
|
12
12
|
import { BractJSProvider } from "../shared/context.ts";
|
|
13
|
+
import { isAllowedMutation } from "./csrf.ts";
|
|
13
14
|
|
|
14
15
|
export interface HandlerConfig {
|
|
15
16
|
appDir: string;
|
|
@@ -39,17 +40,10 @@ async function route(
|
|
|
39
40
|
config: HandlerConfig,
|
|
40
41
|
context: Record<string, unknown>,
|
|
41
42
|
): Promise<Response> {
|
|
42
|
-
const { appDir,
|
|
43
|
+
const { appDir, manifest } = config;
|
|
43
44
|
const url = new URL(request.url);
|
|
44
45
|
const { pathname, searchParams } = url;
|
|
45
46
|
|
|
46
|
-
// ── Static public assets ──────────────────────────────────────────────
|
|
47
|
-
if (pathname.startsWith("/public/")) {
|
|
48
|
-
const file = Bun.file(join(publicDir, pathname.slice("/public/".length)));
|
|
49
|
-
if (await file.exists()) return new Response(file);
|
|
50
|
-
return error("Not Found", 404);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
47
|
// ── /_data soft-nav JSON endpoint ─────────────────────────────────────
|
|
54
48
|
if (pathname.startsWith("/_data")) {
|
|
55
49
|
const targetPath = searchParams.get("path") ?? "/";
|
|
@@ -77,12 +71,17 @@ async function route(
|
|
|
77
71
|
// ── Action (mutating methods) ─────────────────────────────────────────
|
|
78
72
|
let actionData: unknown = null;
|
|
79
73
|
if (MUTATING_METHODS.has(request.method)) {
|
|
74
|
+
if (!isAllowedMutation(request)) return error("Forbidden", 403);
|
|
80
75
|
try {
|
|
81
|
-
const
|
|
76
|
+
const ct = request.headers.get("Content-Type") ?? "";
|
|
77
|
+
const isFormLike = ct.includes("multipart/form-data") || ct.includes("application/x-www-form-urlencoded");
|
|
78
|
+
const formData = isFormLike ? await request.formData() : new FormData();
|
|
82
79
|
actionData = await runAction(chain.route, { ...args, formData });
|
|
83
80
|
} catch (err) {
|
|
84
81
|
if (isRedirect(err)) return err as Response;
|
|
85
|
-
|
|
82
|
+
if (isHttpError(err)) return error(err.message, err.status);
|
|
83
|
+
if (isDev()) return error(err instanceof Error ? err.message : String(err), 500);
|
|
84
|
+
return error("Internal Server Error", 500);
|
|
86
85
|
}
|
|
87
86
|
|
|
88
87
|
// Client-side Form submits with this header — return JSON, not HTML.
|
|
@@ -97,7 +96,9 @@ async function route(
|
|
|
97
96
|
loaderResults = await runLoaders(chain, args);
|
|
98
97
|
} catch (err) {
|
|
99
98
|
if (isRedirect(err)) return err as Response;
|
|
100
|
-
|
|
99
|
+
if (isHttpError(err)) return error(err.message, err.status);
|
|
100
|
+
if (isDev()) return error(err instanceof Error ? err.message : String(err), 500);
|
|
101
|
+
return error("Internal Server Error", 500);
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
const loaderData = {
|
package/src/server/response.ts
CHANGED
|
@@ -1,8 +1,32 @@
|
|
|
1
|
-
export
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
export interface RedirectOptions {
|
|
2
|
+
/** Allow absolute URLs to other origins. Default false. */
|
|
3
|
+
allowExternal?: boolean;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function isSafeInternalRedirect(url: string): boolean {
|
|
7
|
+
// Must be path-only: single leading "/" not followed by "/" or "\".
|
|
8
|
+
// Rejects: "//evil.com", "/\\evil.com", "https://...", "javascript:...", "".
|
|
9
|
+
if (url.length === 0) return false;
|
|
10
|
+
if (url[0] !== "/") return false;
|
|
11
|
+
if (url[1] === "/" || url[1] === "\\") return false;
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function redirect(
|
|
16
|
+
url: string,
|
|
17
|
+
status: number = 302,
|
|
18
|
+
headers?: HeadersInit,
|
|
19
|
+
options?: RedirectOptions,
|
|
20
|
+
): Response {
|
|
21
|
+
if (!options?.allowExternal && !isSafeInternalRedirect(url)) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
`[bractjs] redirect: unsafe Location "${url}". ` +
|
|
24
|
+
`Pass { allowExternal: true } to redirect off-origin.`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
const h = new Headers(headers);
|
|
28
|
+
h.set("Location", url);
|
|
29
|
+
return new Response(null, { status, headers: h });
|
|
6
30
|
}
|
|
7
31
|
|
|
8
32
|
export function json<T>(data: T, init?: ResponseInit): Response {
|
package/src/server/scanner.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
|
|
1
3
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
2
4
|
|
|
3
5
|
export type Segment = string | { param: string } | { catchAll: string };
|
|
@@ -54,8 +56,10 @@ export async function scanRoutes(appDir: string): Promise<RouteFile[]> {
|
|
|
54
56
|
const routes: RouteFile[] = [];
|
|
55
57
|
|
|
56
58
|
for await (const filePath of glob.scan(appDir)) {
|
|
57
|
-
// Skip layout files — handled separately
|
|
58
|
-
|
|
59
|
+
// Skip layout files — handled separately. Use basename so this also
|
|
60
|
+
// skips top-level "routes/layout.tsx" on any OS.
|
|
61
|
+
const base = basename(filePath);
|
|
62
|
+
if (base === "layout.tsx" || base === "layout.ts") {
|
|
59
63
|
continue;
|
|
60
64
|
}
|
|
61
65
|
|
package/src/server/session.ts
CHANGED
|
@@ -52,15 +52,20 @@ async function sign(data: string, secret: string): Promise<string> {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
async function verify(data: string, sig: string, secrets: string[]): Promise<boolean> {
|
|
55
|
+
// Iterate ALL secrets without short-circuit, and do full-length constant-time
|
|
56
|
+
// compare against every candidate to avoid leaking which secret matched (or
|
|
57
|
+
// whether a length mismatch occurred) via timing.
|
|
58
|
+
let ok = false;
|
|
55
59
|
for (const secret of secrets) {
|
|
56
60
|
const expected = await sign(data, secret);
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
+
const len = Math.max(expected.length, sig.length);
|
|
62
|
+
let diff = expected.length ^ sig.length;
|
|
63
|
+
for (let i = 0; i < len; i++) {
|
|
64
|
+
diff |= (expected.charCodeAt(i) || 0) ^ (sig.charCodeAt(i) || 0);
|
|
61
65
|
}
|
|
66
|
+
if (diff === 0) ok = true;
|
|
62
67
|
}
|
|
63
|
-
return
|
|
68
|
+
return ok;
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
function makeSession(data: SessionData): InternalSession {
|
|
@@ -77,6 +82,12 @@ function makeSession(data: SessionData): InternalSession {
|
|
|
77
82
|
|
|
78
83
|
export function createCookieSession(options: CookieSessionOptions): SessionStorage {
|
|
79
84
|
const { name, secrets, maxAge, secure = true, sameSite = "Lax" } = options;
|
|
85
|
+
if (!Array.isArray(secrets) || secrets.length === 0) {
|
|
86
|
+
throw new Error("createCookieSession: secrets must be a non-empty array");
|
|
87
|
+
}
|
|
88
|
+
if (!secrets.every((s) => typeof s === "string" && s.length >= 16)) {
|
|
89
|
+
throw new Error("createCookieSession: each secret must be a string of length >= 16");
|
|
90
|
+
}
|
|
80
91
|
|
|
81
92
|
return {
|
|
82
93
|
async getSession(cookie?: string | null): Promise<Session> {
|
package/src/server/static.ts
CHANGED
|
@@ -1,26 +1,42 @@
|
|
|
1
|
-
import { join, resolve } from "node:path";
|
|
1
|
+
import { join, resolve, sep } from "node:path";
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
2
3
|
|
|
3
4
|
const IMMUTABLE = "public, max-age=31536000, immutable";
|
|
4
5
|
const NO_CACHE = "no-cache";
|
|
5
6
|
|
|
7
|
+
// Resolve to a canonical path that follows symlinks. Returns null if the
|
|
8
|
+
// target doesn't exist OR escapes the given root after symlink expansion.
|
|
9
|
+
async function safeRealpath(root: string, requested: string): Promise<string | null> {
|
|
10
|
+
const candidate = resolve(join(root, requested));
|
|
11
|
+
// Cheap structural reject before touching the FS.
|
|
12
|
+
if (!candidate.startsWith(root + sep) && candidate !== root) return null;
|
|
13
|
+
let real: string;
|
|
14
|
+
try {
|
|
15
|
+
real = await realpath(candidate);
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
if (!real.startsWith(root + sep) && real !== root) return null;
|
|
20
|
+
return real;
|
|
21
|
+
}
|
|
22
|
+
|
|
6
23
|
/**
|
|
7
24
|
* Serve hashed client assets or public/ files.
|
|
8
25
|
* Returns null if the path doesn't match or the file isn't found.
|
|
9
|
-
* Guards against path traversal
|
|
26
|
+
* Guards against path traversal AND symlink escape.
|
|
10
27
|
*/
|
|
11
28
|
export async function serveStatic(
|
|
12
29
|
pathname: string,
|
|
13
30
|
buildDir: string,
|
|
14
31
|
publicDir: string,
|
|
15
32
|
): Promise<Response | null> {
|
|
16
|
-
// Security: reject traversal sequences before any path resolution
|
|
17
33
|
if (pathname.includes("..")) return null;
|
|
18
34
|
|
|
19
35
|
if (pathname.startsWith("/build/client/")) {
|
|
20
36
|
const rel = pathname.slice("/build/client/".length);
|
|
21
37
|
const root = resolve(join(buildDir, "client"));
|
|
22
|
-
const full =
|
|
23
|
-
if (!full
|
|
38
|
+
const full = await safeRealpath(root, rel);
|
|
39
|
+
if (!full) return null;
|
|
24
40
|
const file = Bun.file(full);
|
|
25
41
|
if (!(await file.exists())) return null;
|
|
26
42
|
return new Response(file, { headers: { "Cache-Control": IMMUTABLE } });
|
|
@@ -29,8 +45,8 @@ export async function serveStatic(
|
|
|
29
45
|
if (pathname.startsWith("/public/")) {
|
|
30
46
|
const rel = pathname.slice("/public/".length);
|
|
31
47
|
const root = resolve(publicDir);
|
|
32
|
-
const full =
|
|
33
|
-
if (!full
|
|
48
|
+
const full = await safeRealpath(root, rel);
|
|
49
|
+
if (!full) return null;
|
|
34
50
|
const file = Bun.file(full);
|
|
35
51
|
if (!(await file.exists())) return null;
|
|
36
52
|
return new Response(file, { headers: { "Cache-Control": NO_CACHE } });
|