@bractjs/bractjs 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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__/loader.test.ts +5 -2
- 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/adapters/cloudflare.ts +65 -0
- package/src/build/bundler.ts +17 -6
- package/src/build/directives.ts +30 -3
- package/src/build/env-plugin.ts +8 -0
- package/src/build/hash.ts +0 -20
- package/src/build/plugins/css-modules.ts +110 -0
- package/src/client/ClientRouter.tsx +121 -13
- package/src/client/cache.ts +69 -0
- package/src/client/components/Link.tsx +16 -2
- package/src/client/components/LiveReload.tsx +4 -0
- package/src/client/hooks/useBlocker.ts +44 -0
- package/src/client/hooks/useFetcher.ts +66 -6
- package/src/client/hooks/useLocale.ts +12 -0
- package/src/client/hooks/useLocalizedLink.ts +18 -0
- package/src/client/hooks/useSearchParams.ts +74 -0
- package/src/client/rpc.ts +70 -0
- package/src/codegen/route-codegen.ts +96 -10
- package/src/dev/devtools.ts +144 -0
- package/src/dev/hmr-client.ts +14 -0
- package/src/dev/hmr-module-handler.ts +31 -5
- package/src/dev/hmr-server.ts +16 -0
- package/src/image/cache.ts +28 -8
- package/src/image/handler.ts +31 -13
- package/src/image/optimizer.ts +51 -14
- package/src/image/types.ts +1 -0
- package/src/index.ts +27 -0
- package/src/middleware/cors.ts +28 -8
- package/src/middleware/requestLogger.ts +4 -0
- package/src/server/action-handler.ts +45 -2
- package/src/server/action-registry.ts +14 -1
- package/src/server/adapter.ts +57 -0
- package/src/server/api-route.ts +127 -0
- package/src/server/context.ts +22 -0
- package/src/server/csrf.ts +17 -0
- package/src/server/env.ts +26 -4
- package/src/server/i18n.ts +63 -0
- package/src/server/loader.ts +61 -1
- package/src/server/middleware.ts +11 -7
- package/src/server/render.ts +14 -5
- package/src/server/request-handler.ts +77 -18
- package/src/server/response.ts +29 -5
- package/src/server/scanner.ts +6 -2
- package/src/server/serve.ts +102 -55
- package/src/server/session.ts +17 -5
- package/src/server/static.ts +31 -8
- package/src/server/stream-handler.ts +111 -0
- package/src/server/validate.ts +89 -0
- package/src/shared/route-types.ts +11 -0
- package/types/index.d.ts +94 -1
- package/types/route.d.ts +11 -0
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import { resolve, join } from "node:path";
|
|
1
|
+
import { resolve, join, sep } from "node:path";
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
3
|
+
import { serverOnlyPlugin } from "../build/env-plugin.ts";
|
|
4
|
+
import { useServerProxyPlugin } from "../build/directives.ts";
|
|
2
5
|
|
|
3
6
|
/**
|
|
4
7
|
* Dev-only HTTP handler for /_hmr/module?file=routes/about.tsx
|
|
@@ -17,19 +20,42 @@ export async function handleHmrModuleRequest(
|
|
|
17
20
|
return new Response("Missing file param", { status: 400 });
|
|
18
21
|
}
|
|
19
22
|
|
|
20
|
-
//
|
|
23
|
+
// SECURITY(high): restrict to JS/TS source files. Without this, /_hmr/module
|
|
24
|
+
// would build and ship the contents of any file inside appDir (e.g. .env,
|
|
25
|
+
// .json, .md) as JavaScript to the browser — useful only for compiling
|
|
26
|
+
// route modules, so allowlist their extensions.
|
|
27
|
+
if (!/\.(tsx?|jsx?)$/.test(file)) {
|
|
28
|
+
return new Response("Forbidden", { status: 403 });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Resolve and guard against path traversal AND symlink escape.
|
|
21
32
|
const rootDir = resolve(appDir);
|
|
22
|
-
const
|
|
23
|
-
if (!
|
|
33
|
+
const candidate = resolve(join(rootDir, file));
|
|
34
|
+
if (!candidate.startsWith(rootDir + sep) && candidate !== rootDir) {
|
|
35
|
+
return new Response("Forbidden", { status: 403 });
|
|
36
|
+
}
|
|
37
|
+
let fullPath: string;
|
|
38
|
+
try {
|
|
39
|
+
fullPath = await realpath(candidate);
|
|
40
|
+
} catch {
|
|
41
|
+
return new Response("Not Found", { status: 404 });
|
|
42
|
+
}
|
|
43
|
+
if (!fullPath.startsWith(rootDir + sep) && fullPath !== rootDir) {
|
|
24
44
|
return new Response("Forbidden", { status: 403 });
|
|
25
45
|
}
|
|
26
46
|
|
|
27
|
-
//
|
|
47
|
+
// SECURITY(high): apply the same client-bundle guard plugins the production
|
|
48
|
+
// build uses. Without these, a route module that imports `*.server.ts` or
|
|
49
|
+
// contains "use server" exports would have that server source compiled and
|
|
50
|
+
// shipped to the browser as JavaScript over /_hmr/module — leaking
|
|
51
|
+
// credentials, DB code, etc. The serverOnlyPlugin hard-fails such imports
|
|
52
|
+
// and useServerProxyPlugin rewrites "use server" exports to fetch stubs.
|
|
28
53
|
const result = await Bun.build({
|
|
29
54
|
entrypoints: [fullPath],
|
|
30
55
|
target: "browser",
|
|
31
56
|
minify: false,
|
|
32
57
|
sourcemap: "inline",
|
|
58
|
+
plugins: [serverOnlyPlugin, useServerProxyPlugin],
|
|
33
59
|
});
|
|
34
60
|
|
|
35
61
|
if (!result.success || result.outputs.length === 0) {
|
package/src/dev/hmr-server.ts
CHANGED
|
@@ -18,6 +18,22 @@ export function createHmrServer(port = 3001): {
|
|
|
18
18
|
const server = Bun.serve({
|
|
19
19
|
port,
|
|
20
20
|
fetch(req, srv) {
|
|
21
|
+
// SECURITY(medium): reject WebSocket upgrades that don't come from a
|
|
22
|
+
// loopback Origin. Without this, any website the developer visits could
|
|
23
|
+
// open a WS to ws://localhost:<port> and receive file paths from HMR
|
|
24
|
+
// broadcasts (a passive leak of project structure). Same-origin /
|
|
25
|
+
// missing Origin (curl, native ws clients) are allowed for dev DX.
|
|
26
|
+
const origin = req.headers.get("Origin");
|
|
27
|
+
if (origin) {
|
|
28
|
+
try {
|
|
29
|
+
const host = new URL(origin).hostname;
|
|
30
|
+
if (host !== "localhost" && host !== "127.0.0.1" && host !== "[::1]" && host !== "::1") {
|
|
31
|
+
return new Response("Forbidden", { status: 403 });
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
return new Response("Forbidden", { status: 403 });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
21
37
|
if (srv.upgrade(req)) return undefined;
|
|
22
38
|
return new Response("HMR WebSocket endpoint", { status: 426 });
|
|
23
39
|
},
|
package/src/image/cache.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
-
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { mkdir, rename, unlink } from "node:fs/promises";
|
|
3
3
|
import type { ImageTransformParams, TransformResult, ImageFormat } from "./types.ts";
|
|
4
4
|
|
|
5
5
|
const MAX_MEM = 200;
|
|
@@ -50,10 +50,14 @@ export async function getFromDisk(
|
|
|
50
50
|
const key = await cacheKey(src, params);
|
|
51
51
|
const metaFile = Bun.file(join(dir, `${key}.json`));
|
|
52
52
|
const dataFile = Bun.file(join(dir, `${key}.bin`));
|
|
53
|
-
|
|
53
|
+
// No existence pre-check: it would create a TOCTOU race where the file is
|
|
54
|
+
// deleted between exists() and read(). Just attempt the reads and let either
|
|
55
|
+
// a missing file or invalid JSON fall through to MISS.
|
|
54
56
|
try {
|
|
55
|
-
const meta = await
|
|
56
|
-
|
|
57
|
+
const [meta, data] = await Promise.all([
|
|
58
|
+
metaFile.json() as Promise<{ contentType: string; format: ImageFormat }>,
|
|
59
|
+
dataFile.arrayBuffer(),
|
|
60
|
+
]);
|
|
57
61
|
return { data, contentType: meta.contentType, format: meta.format };
|
|
58
62
|
} catch {
|
|
59
63
|
return null;
|
|
@@ -68,8 +72,24 @@ export async function setOnDisk(
|
|
|
68
72
|
): Promise<void> {
|
|
69
73
|
await mkdir(dir, { recursive: true });
|
|
70
74
|
const key = await cacheKey(src, params);
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
+
const jsonFinal = join(dir, `${key}.json`);
|
|
76
|
+
const binFinal = join(dir, `${key}.bin`);
|
|
77
|
+
const jsonTmp = `${jsonFinal}.tmp`;
|
|
78
|
+
const binTmp = `${binFinal}.tmp`;
|
|
79
|
+
// Write both temp files, then atomically rename. Readers see either both
|
|
80
|
+
// files present or neither — never a half-written pair.
|
|
81
|
+
try {
|
|
82
|
+
await Promise.all([
|
|
83
|
+
Bun.write(jsonTmp, JSON.stringify({ contentType: result.contentType, format: result.format })),
|
|
84
|
+
Bun.write(binTmp, result.data),
|
|
85
|
+
]);
|
|
86
|
+
await Promise.all([rename(jsonTmp, jsonFinal), rename(binTmp, binFinal)]);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
// Best-effort cleanup so failed writes don't leak .tmp files indefinitely.
|
|
89
|
+
await Promise.all([
|
|
90
|
+
unlink(jsonTmp).catch(() => {}),
|
|
91
|
+
unlink(binTmp).catch(() => {}),
|
|
92
|
+
]);
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
75
95
|
}
|
package/src/image/handler.ts
CHANGED
|
@@ -1,36 +1,54 @@
|
|
|
1
|
-
import { join, resolve } from "node:path";
|
|
1
|
+
import { join, resolve, sep } from "node:path";
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
2
3
|
import type { ImageTransformParams, ImageFormat, ImageFit } from "./types.ts";
|
|
3
|
-
import { QUALITY_DEFAULT, FORMAT_DEFAULT, FIT_DEFAULT, MIME } from "./types.ts";
|
|
4
|
+
import { QUALITY_DEFAULT, FORMAT_DEFAULT, FIT_DEFAULT, MIME, ALLOWED_FITS } from "./types.ts";
|
|
4
5
|
import { transformImage } from "./optimizer.ts";
|
|
5
6
|
import { getFromMemory, setInMemory, getFromDisk, setOnDisk } from "./cache.ts";
|
|
6
7
|
|
|
7
|
-
const
|
|
8
|
+
const ALLOWED_DIMS = new Set([320, 640, 768, 1024, 1280, 1536, 1920, 3840]);
|
|
9
|
+
const MAX_AREA = 4_000_000;
|
|
8
10
|
const CACHE_CTRL = "public, max-age=31536000, immutable";
|
|
9
11
|
|
|
10
|
-
function parseParams(
|
|
12
|
+
async function parseParams(
|
|
11
13
|
sp: URLSearchParams,
|
|
12
14
|
publicDir: string,
|
|
13
|
-
): { src: string; filePath: string; params: ImageTransformParams } | null {
|
|
15
|
+
): Promise<{ src: string; filePath: string; params: ImageTransformParams } | null> {
|
|
14
16
|
const src = sp.get("src");
|
|
15
|
-
// src must be a /public/ path with no
|
|
16
|
-
|
|
17
|
+
// src must be a /public/ path with no ".." path segment. We check segments
|
|
18
|
+
// (not substring) so filenames like "foo..bar.jpg" are still allowed —
|
|
19
|
+
// realpath()/prefix check below is the authoritative escape guard.
|
|
20
|
+
if (!src || !src.startsWith("/public/")) return null;
|
|
21
|
+
if (src.split("/").includes("..")) return null;
|
|
17
22
|
|
|
18
23
|
const rel = src.slice("/public/".length);
|
|
19
24
|
const root = resolve(publicDir);
|
|
20
|
-
const
|
|
21
|
-
if (!
|
|
25
|
+
const candidate = resolve(join(root, rel));
|
|
26
|
+
if (!candidate.startsWith(root + sep) && candidate !== root) return null;
|
|
27
|
+
// Re-check after symlink resolution. If the file doesn't exist yet, realpath
|
|
28
|
+
// throws — fall through and let the existence check below handle it.
|
|
29
|
+
let filePath = candidate;
|
|
30
|
+
try {
|
|
31
|
+
const real = await realpath(candidate);
|
|
32
|
+
if (!real.startsWith(root + sep) && real !== root) return null;
|
|
33
|
+
filePath = real;
|
|
34
|
+
} catch {
|
|
35
|
+
// missing file: defer to Bun.file(...).exists() below
|
|
36
|
+
}
|
|
22
37
|
|
|
23
38
|
const wRaw = sp.get("w");
|
|
24
39
|
const hRaw = sp.get("h");
|
|
25
40
|
const w = wRaw ? parseInt(wRaw, 10) : undefined;
|
|
26
41
|
const h = hRaw ? parseInt(hRaw, 10) : undefined;
|
|
27
|
-
if (w !== undefined && (isNaN(w) || w
|
|
28
|
-
if (h !== undefined && (isNaN(h) || h
|
|
42
|
+
if (w !== undefined && (isNaN(w) || !ALLOWED_DIMS.has(w))) return null;
|
|
43
|
+
if (h !== undefined && (isNaN(h) || !ALLOWED_DIMS.has(h))) return null;
|
|
44
|
+
if (w !== undefined && h !== undefined && w * h > MAX_AREA) return null;
|
|
29
45
|
|
|
30
46
|
const q = Math.min(100, Math.max(1, parseInt(sp.get("q") ?? String(QUALITY_DEFAULT), 10)));
|
|
31
47
|
const fmt = (sp.get("format") ?? FORMAT_DEFAULT) as ImageFormat;
|
|
32
|
-
const
|
|
48
|
+
const fitRaw = sp.get("fit") ?? FIT_DEFAULT;
|
|
33
49
|
if (!MIME[fmt]) return null;
|
|
50
|
+
if (!ALLOWED_FITS.has(fitRaw as ImageFit)) return null;
|
|
51
|
+
const fit = fitRaw as ImageFit;
|
|
34
52
|
|
|
35
53
|
return { src, filePath, params: { w, h, q, format: fmt, fit } };
|
|
36
54
|
}
|
|
@@ -53,7 +71,7 @@ export async function handleImageRequest(
|
|
|
53
71
|
const url = new URL(request.url);
|
|
54
72
|
if (url.pathname !== "/_image") return null;
|
|
55
73
|
|
|
56
|
-
const parsed = parseParams(url.searchParams, publicDir);
|
|
74
|
+
const parsed = await parseParams(url.searchParams, publicDir);
|
|
57
75
|
if (!parsed) return new Response("Bad Request", { status: 400 });
|
|
58
76
|
|
|
59
77
|
const { src, filePath, params } = parsed;
|
package/src/image/optimizer.ts
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
import type { ImageTransformParams, TransformResult, ImageFormat } from "./types.ts";
|
|
2
2
|
import { MIME } from "./types.ts";
|
|
3
3
|
|
|
4
|
+
// In-process semaphore (DoS guard): cap concurrent ImageMagick spawns so a
|
|
5
|
+
// burst of /_image requests can't fork-bomb the server.
|
|
6
|
+
const MAX_CONCURRENT = 4;
|
|
7
|
+
// Per-spawn timeout (ms). A pathological input must not hold a slot forever;
|
|
8
|
+
// without this, four hung spawns wedge the whole image pipeline.
|
|
9
|
+
const SPAWN_TIMEOUT_MS = 15_000;
|
|
10
|
+
let inFlight = 0;
|
|
11
|
+
const waiters: Array<() => void> = [];
|
|
12
|
+
|
|
13
|
+
async function acquireSlot(): Promise<void> {
|
|
14
|
+
if (inFlight < MAX_CONCURRENT) {
|
|
15
|
+
inFlight++;
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
await new Promise<void>((resolve) => waiters.push(resolve));
|
|
19
|
+
inFlight++;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function releaseSlot(): void {
|
|
23
|
+
inFlight--;
|
|
24
|
+
const next = waiters.shift();
|
|
25
|
+
if (next) next();
|
|
26
|
+
}
|
|
27
|
+
|
|
4
28
|
// Probe for an available ImageMagick binary once, then cache the result.
|
|
5
29
|
let _binary: string | null | undefined;
|
|
6
30
|
|
|
@@ -33,9 +57,14 @@ function resizeArgs(params: ImageTransformParams): string[] {
|
|
|
33
57
|
|
|
34
58
|
function buildArgs(binary: string, input: string, params: ImageTransformParams): string[] {
|
|
35
59
|
const base = binary === "magick" ? ["magick", "convert"] : ["convert"];
|
|
60
|
+
// SECURITY(low): prefix the input with `file:` so ImageMagick treats it as
|
|
61
|
+
// a filesystem path even if it contains a coder prefix like `mvg:` or
|
|
62
|
+
// `https:` (e.g. an attacker who plants a file named "https:evil.txt"
|
|
63
|
+
// inside publicDir). Defense in depth — realpath() already constrains the
|
|
64
|
+
// path to publicDir, so this prevents the "format coder hijack" class only.
|
|
36
65
|
return [
|
|
37
66
|
...base,
|
|
38
|
-
input
|
|
67
|
+
`file:${input}`,
|
|
39
68
|
...resizeArgs(params),
|
|
40
69
|
"-quality", String(params.q),
|
|
41
70
|
"-strip",
|
|
@@ -57,20 +86,28 @@ export async function transformImage(
|
|
|
57
86
|
return { data, contentType: MIME[fmt] ?? "image/jpeg", format: fmt };
|
|
58
87
|
}
|
|
59
88
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
89
|
+
await acquireSlot();
|
|
90
|
+
try {
|
|
91
|
+
const proc = Bun.spawn(buildArgs(binary, filePath, params), {
|
|
92
|
+
stdout: "pipe",
|
|
93
|
+
stderr: "ignore",
|
|
94
|
+
timeout: SPAWN_TIMEOUT_MS,
|
|
95
|
+
killSignal: "SIGKILL",
|
|
96
|
+
});
|
|
64
97
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
]);
|
|
98
|
+
const [data, exitCode] = await Promise.all([
|
|
99
|
+
new Response(proc.stdout!).arrayBuffer(),
|
|
100
|
+
proc.exited,
|
|
101
|
+
]);
|
|
70
102
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
103
|
+
if (exitCode !== 0) {
|
|
104
|
+
// Non-zero exit covers normal failures AND timeout-induced SIGKILL,
|
|
105
|
+
// since Bun reports the signal as a non-zero exit code.
|
|
106
|
+
throw new Error(`[bractjs] ImageMagick exited ${exitCode} for ${filePath}`);
|
|
107
|
+
}
|
|
74
108
|
|
|
75
|
-
|
|
109
|
+
return { data, contentType: MIME[params.format], format: params.format };
|
|
110
|
+
} finally {
|
|
111
|
+
releaseSlot();
|
|
112
|
+
}
|
|
76
113
|
}
|
package/src/image/types.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
// Server
|
|
2
2
|
export { createServer, renderRoute, redirect, json, error } from "./server/index.ts";
|
|
3
|
+
export { buildFetchHandler } from "./server/serve.ts";
|
|
4
|
+
export { defineContext } from "./server/context.ts";
|
|
5
|
+
export type { ContextFactory } from "./server/context.ts";
|
|
6
|
+
export { route } from "./server/api-route.ts";
|
|
7
|
+
export type { ApiRouteDefinition, AppApiRoutes } from "./server/api-route.ts";
|
|
8
|
+
export { validate } from "./server/validate.ts";
|
|
9
|
+
export type { FieldErrors, ValidationError } from "./server/validate.ts";
|
|
10
|
+
export type { BractAdapter } from "./server/adapter.ts";
|
|
11
|
+
export { BunAdapter } from "./server/adapter.ts";
|
|
12
|
+
|
|
13
|
+
// Adapters
|
|
14
|
+
export { createCloudflareAdapter, makeCloudflareHandler } from "./adapters/cloudflare.ts";
|
|
15
|
+
|
|
16
|
+
// Build plugins
|
|
17
|
+
export { cssModulesPlugin, transformCssModule } from "./build/plugins/css-modules.ts";
|
|
18
|
+
|
|
19
|
+
// Client RPC
|
|
20
|
+
export { createClient } from "./client/rpc.ts";
|
|
3
21
|
export type { BractJSConfig, RenderOptions, ServerManifest } from "./server/index.ts";
|
|
4
22
|
|
|
5
23
|
// Shared types
|
|
@@ -49,3 +67,12 @@ export { useActionData } from "./client/hooks/useActionData.ts";
|
|
|
49
67
|
export { useParams } from "./client/hooks/useParams.ts";
|
|
50
68
|
export { useNavigation } from "./client/hooks/useNavigation.ts";
|
|
51
69
|
export { useFetcher } from "./client/hooks/useFetcher.ts";
|
|
70
|
+
export { useSearchParams } from "./client/hooks/useSearchParams.ts";
|
|
71
|
+
export type { SearchParamsResult } from "./client/hooks/useSearchParams.ts";
|
|
72
|
+
export { useBlocker } from "./client/hooks/useBlocker.ts";
|
|
73
|
+
export { useLocale } from "./client/hooks/useLocale.ts";
|
|
74
|
+
export { useLocalizedLink } from "./client/hooks/useLocalizedLink.ts";
|
|
75
|
+
|
|
76
|
+
// i18n utilities (server-side)
|
|
77
|
+
export { wrapRoutesWithLocale, stripLocale, localizedDataPath } from "./server/i18n.ts";
|
|
78
|
+
export type { I18nConfig } from "./server/serve.ts";
|
package/src/middleware/cors.ts
CHANGED
|
@@ -3,25 +3,43 @@ import type { MiddlewareFn } from "../server/middleware.ts";
|
|
|
3
3
|
export interface CorsOptions {
|
|
4
4
|
origin: string | string[];
|
|
5
5
|
methods?: string[];
|
|
6
|
+
credentials?: boolean;
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Sets CORS headers. Handles OPTIONS preflight with 204.
|
|
11
|
+
*
|
|
12
|
+
* Never reflects the Origin header when "*" is configured — emits literal "*".
|
|
13
|
+
* Refuses to combine credentials:true with "*" (browsers reject it anyway).
|
|
14
|
+
* Always sets `Vary: Origin` so caches don't serve a cross-origin response to
|
|
15
|
+
* the wrong site.
|
|
10
16
|
*/
|
|
11
17
|
export function cors(options: CorsOptions): MiddlewareFn {
|
|
12
|
-
const allowedOrigins = Array.isArray(options.origin)
|
|
13
|
-
? options.origin
|
|
14
|
-
: [options.origin];
|
|
18
|
+
const allowedOrigins = Array.isArray(options.origin) ? options.origin : [options.origin];
|
|
15
19
|
const allowedMethods = options.methods?.join(", ") ?? "GET, POST, PUT, DELETE, PATCH, OPTIONS";
|
|
20
|
+
const wildcard = allowedOrigins.includes("*");
|
|
21
|
+
const credentials = options.credentials === true;
|
|
22
|
+
if (wildcard && credentials) {
|
|
23
|
+
throw new Error("cors: credentials=true cannot be combined with origin='*'");
|
|
24
|
+
}
|
|
16
25
|
|
|
17
26
|
return async (ctx, next) => {
|
|
18
27
|
const origin = ctx.request.headers.get("Origin") ?? "";
|
|
19
|
-
|
|
28
|
+
// SECURITY(high): Access-Control-Allow-Headers MUST NOT list
|
|
29
|
+
// `X-BractJS-Action`. That header is the CSRF gate in csrf.ts — its
|
|
30
|
+
// protection relies on browsers blocking non-allowlisted custom headers
|
|
31
|
+
// cross-origin. Adding it here would let any origin forge mutations.
|
|
20
32
|
const corsHeaders: Record<string, string> = {
|
|
21
33
|
"Access-Control-Allow-Methods": allowedMethods,
|
|
22
34
|
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
35
|
+
"Vary": "Origin",
|
|
23
36
|
};
|
|
24
|
-
if (
|
|
37
|
+
if (wildcard) {
|
|
38
|
+
corsHeaders["Access-Control-Allow-Origin"] = "*";
|
|
39
|
+
} else if (origin && allowedOrigins.includes(origin)) {
|
|
40
|
+
corsHeaders["Access-Control-Allow-Origin"] = origin;
|
|
41
|
+
}
|
|
42
|
+
if (credentials) corsHeaders["Access-Control-Allow-Credentials"] = "true";
|
|
25
43
|
|
|
26
44
|
// Preflight
|
|
27
45
|
if (ctx.request.method === "OPTIONS") {
|
|
@@ -29,8 +47,10 @@ export function cors(options: CorsOptions): MiddlewareFn {
|
|
|
29
47
|
}
|
|
30
48
|
|
|
31
49
|
const response = await next();
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
50
|
+
// Mutate headers in place rather than wrapping body. Wrapping with
|
|
51
|
+
// `new Response(response.body, response)` makes the original Response
|
|
52
|
+
// unusable to anyone holding a reference (single-shot stream).
|
|
53
|
+
for (const [k, v] of Object.entries(corsHeaders)) response.headers.set(k, v);
|
|
54
|
+
return response;
|
|
35
55
|
};
|
|
36
56
|
}
|
|
@@ -3,6 +3,10 @@ import type { MiddlewareFn } from "../server/middleware.ts";
|
|
|
3
3
|
/**
|
|
4
4
|
* Logs "[METHOD] /path → status in Xms" for every request.
|
|
5
5
|
*/
|
|
6
|
+
// SECURITY(medium): only the pathname is logged — query string is intentionally
|
|
7
|
+
// omitted because it may carry tokens (e.g. password-reset links, OAuth codes,
|
|
8
|
+
// signed share URLs). Do not extend this to log searchParams without a redaction
|
|
9
|
+
// allowlist.
|
|
6
10
|
export function requestLogger(): MiddlewareFn {
|
|
7
11
|
return async (ctx, next) => {
|
|
8
12
|
const start = Date.now();
|
|
@@ -1,10 +1,29 @@
|
|
|
1
1
|
import { resolveAction } from "./action-registry.ts";
|
|
2
2
|
import { json } from "./response.ts";
|
|
3
|
+
import { isAllowedMutation } from "./csrf.ts";
|
|
4
|
+
|
|
5
|
+
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
6
|
+
// Cap action JSON bodies. Anything over this looks like an abuse attempt;
|
|
7
|
+
// FormData uploads (large files) take the multipart branch and bypass this.
|
|
8
|
+
const MAX_JSON_BODY_BYTES = 1_048_576; // 1 MiB
|
|
9
|
+
|
|
10
|
+
// Deep scan: nested objects can carry __proto__ pollution vectors too.
|
|
11
|
+
function hasForbiddenKey(value: unknown, depth = 0): boolean {
|
|
12
|
+
if (depth > 20 || !value || typeof value !== "object") return false;
|
|
13
|
+
for (const key of Object.keys(value as Record<string, unknown>)) {
|
|
14
|
+
if (FORBIDDEN_KEYS.has(key)) return true;
|
|
15
|
+
if (hasForbiddenKey((value as Record<string, unknown>)[key], depth + 1)) return true;
|
|
16
|
+
}
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
3
19
|
|
|
4
20
|
export async function handleActionRequest(request: Request): Promise<Response | null> {
|
|
5
21
|
const url = new URL(request.url);
|
|
6
|
-
|
|
22
|
+
// SECURITY(medium): exact-match prevents URL confusion (e.g. "/_actionfoo"
|
|
23
|
+
// would otherwise also reach this handler).
|
|
24
|
+
if (url.pathname !== "/_action") return null;
|
|
7
25
|
if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405 });
|
|
26
|
+
if (!isAllowedMutation(request)) return new Response("Forbidden", { status: 403 });
|
|
8
27
|
|
|
9
28
|
const id = url.searchParams.get("id");
|
|
10
29
|
if (!id) return new Response("Bad Request: missing action id", { status: 400 });
|
|
@@ -18,8 +37,32 @@ export async function handleActionRequest(request: Request): Promise<Response |
|
|
|
18
37
|
if (ct.includes("multipart/form-data") || ct.includes("application/x-www-form-urlencoded")) {
|
|
19
38
|
args = [await request.formData()];
|
|
20
39
|
} else {
|
|
40
|
+
// Cheap pre-check: trust Content-Length if the client sent one.
|
|
41
|
+
const clRaw = request.headers.get("Content-Length");
|
|
42
|
+
if (clRaw) {
|
|
43
|
+
const cl = Number(clRaw);
|
|
44
|
+
if (Number.isFinite(cl) && cl > MAX_JSON_BODY_BYTES) {
|
|
45
|
+
return new Response("Payload Too Large", { status: 413 });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
21
48
|
const text = await request.text();
|
|
22
|
-
|
|
49
|
+
// Defense in depth: clients can lie about Content-Length, so verify the
|
|
50
|
+
// actual decoded text length too.
|
|
51
|
+
if (text.length > MAX_JSON_BODY_BYTES) {
|
|
52
|
+
return new Response("Payload Too Large", { status: 413 });
|
|
53
|
+
}
|
|
54
|
+
if (!text) {
|
|
55
|
+
args = [];
|
|
56
|
+
} else {
|
|
57
|
+
const parsed: unknown = JSON.parse(text);
|
|
58
|
+
if (!Array.isArray(parsed)) {
|
|
59
|
+
return new Response("Bad Request: args must be array", { status: 400 });
|
|
60
|
+
}
|
|
61
|
+
if (parsed.some((v) => hasForbiddenKey(v))) {
|
|
62
|
+
return new Response("Bad Request: forbidden keys", { status: 400 });
|
|
63
|
+
}
|
|
64
|
+
args = parsed;
|
|
65
|
+
}
|
|
23
66
|
}
|
|
24
67
|
} catch {
|
|
25
68
|
return new Response("Bad Request: invalid body", { status: 400 });
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// Anchored at start-of-file. Allow whitespace and line/block comments before
|
|
4
|
+
// the "use server" string literal. This prevents false matches from a "use
|
|
5
|
+
// server" found inside template literals or runtime strings.
|
|
6
|
+
const SERVER_RE = /^(?:\s|\/\/[^\n]*\n|\/\*[\s\S]*?\*\/)*["']use server["']/;
|
|
4
7
|
const registry = new Map<string, (...args: unknown[]) => Promise<unknown>>();
|
|
5
8
|
|
|
6
9
|
async function computeId(filePath: string, name: string): Promise<string> {
|
|
@@ -16,9 +19,19 @@ export function resolveAction(id: string): ((...args: unknown[]) => Promise<unkn
|
|
|
16
19
|
return registry.get(id) ?? null;
|
|
17
20
|
}
|
|
18
21
|
|
|
22
|
+
function isEligible(rel: string): boolean {
|
|
23
|
+
return (
|
|
24
|
+
rel.endsWith(".server.ts") ||
|
|
25
|
+
rel.endsWith(".server.tsx") ||
|
|
26
|
+
rel.startsWith("routes/") ||
|
|
27
|
+
rel.startsWith("routes\\")
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
19
31
|
export async function loadServerActions(appDir: string): Promise<void> {
|
|
20
32
|
const glob = new Bun.Glob("**/*.{ts,tsx}");
|
|
21
33
|
for await (const rel of glob.scan(appDir)) {
|
|
34
|
+
if (!isEligible(rel)) continue;
|
|
22
35
|
const filePath = join(appDir, rel);
|
|
23
36
|
let src: string;
|
|
24
37
|
try { src = await Bun.file(filePath).text(); } catch { continue; }
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// ── BractAdapter ──────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal interface that adapters must implement.
|
|
5
|
+
*
|
|
6
|
+
* `fetch` is the standard WinterCG-compatible fetch handler — it receives a
|
|
7
|
+
* Request and returns a Response. The server core calls this for every
|
|
8
|
+
* incoming HTTP request after routing special endpoints.
|
|
9
|
+
*
|
|
10
|
+
* `listen` starts the adapter's underlying server on the given port.
|
|
11
|
+
* It is optional for environments that do not control port binding (e.g.
|
|
12
|
+
* Cloudflare Workers).
|
|
13
|
+
*/
|
|
14
|
+
export interface BractAdapter {
|
|
15
|
+
fetch(request: Request): Promise<Response>;
|
|
16
|
+
listen?(port: number): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ── BunAdapter ────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Default adapter — wraps `Bun.serve()`.
|
|
23
|
+
* Created internally by `createServer()` when no adapter is provided.
|
|
24
|
+
*/
|
|
25
|
+
export class BunAdapter implements BractAdapter {
|
|
26
|
+
private server: ReturnType<typeof Bun.serve> | null = null;
|
|
27
|
+
private handler: ((request: Request) => Promise<Response>) | null = null;
|
|
28
|
+
|
|
29
|
+
setHandler(handler: (request: Request) => Promise<Response>): void {
|
|
30
|
+
this.handler = handler;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async fetch(request: Request): Promise<Response> {
|
|
34
|
+
if (!this.handler) throw new Error("BunAdapter: handler not set");
|
|
35
|
+
return this.handler(request);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
listen(port: number): void {
|
|
39
|
+
if (!this.handler) throw new Error("BunAdapter: handler not set before listen()");
|
|
40
|
+
const handler = this.handler;
|
|
41
|
+
this.server = Bun.serve({
|
|
42
|
+
port,
|
|
43
|
+
fetch: handler,
|
|
44
|
+
error(err: Error) {
|
|
45
|
+
console.error("[bractjs] unhandled server error:", err);
|
|
46
|
+
return new Response(JSON.stringify({ error: err.message }), {
|
|
47
|
+
status: 500,
|
|
48
|
+
headers: { "Content-Type": "application/json; charset=utf-8" },
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
stop(): void {
|
|
55
|
+
this.server?.stop();
|
|
56
|
+
}
|
|
57
|
+
}
|