@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
|
@@ -3,7 +3,7 @@ import { createCookieSession } from "../server/session.ts";
|
|
|
3
3
|
|
|
4
4
|
const sessionStorage = createCookieSession({
|
|
5
5
|
name: "__test",
|
|
6
|
-
secrets: ["secret-one", "secret-two"],
|
|
6
|
+
secrets: ["secret-one-1234567890", "secret-two-1234567890"],
|
|
7
7
|
secure: false,
|
|
8
8
|
sameSite: "Lax",
|
|
9
9
|
});
|
|
@@ -71,7 +71,7 @@ describe("createCookieSession — commitSession + roundtrip", () => {
|
|
|
71
71
|
test("secret rotation: old secret still verifies", async () => {
|
|
72
72
|
const oldStorage = createCookieSession({
|
|
73
73
|
name: "__test",
|
|
74
|
-
secrets: ["secret-two"], // only the old secret
|
|
74
|
+
secrets: ["secret-two-1234567890"], // only the old secret
|
|
75
75
|
secure: false,
|
|
76
76
|
});
|
|
77
77
|
const s1 = await oldStorage.getSession(null);
|
|
@@ -81,7 +81,7 @@ describe("createCookieSession — commitSession + roundtrip", () => {
|
|
|
81
81
|
// New storage has new secret first, old secret second (rotation)
|
|
82
82
|
const newStorage = createCookieSession({
|
|
83
83
|
name: "__test",
|
|
84
|
-
secrets: ["secret-one", "secret-two"],
|
|
84
|
+
secrets: ["secret-one-1234567890", "secret-two-1234567890"],
|
|
85
85
|
secure: false,
|
|
86
86
|
});
|
|
87
87
|
const cookieValue = cookie.split(";")[0];
|
package/src/build/bundler.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { join } from "node:path";
|
|
1
|
+
import { join, basename, extname, resolve } from "node:path";
|
|
2
2
|
import { rename } from "node:fs/promises";
|
|
3
3
|
import type { BractJSConfig } from "../server/serve.ts";
|
|
4
4
|
import { scanRoutes } from "../server/scanner.ts";
|
|
@@ -47,6 +47,10 @@ export async function runBuild(config: BractJSConfig): Promise<void> {
|
|
|
47
47
|
const routeChunks = new Map<string, string>();
|
|
48
48
|
let clientEntry = "";
|
|
49
49
|
let rootChunk: string | undefined;
|
|
50
|
+
const outdirAbs = resolve("build/client");
|
|
51
|
+
const appDirClean = appDir.replace(/^\.\//, "");
|
|
52
|
+
const entryBase = basename("src/client/entry.tsx", extname("src/client/entry.tsx")); // "entry"
|
|
53
|
+
const rootBase = basename(rootFilePath, extname(rootFilePath)); // "root"
|
|
50
54
|
|
|
51
55
|
for (const artifact of clientResult.outputs) {
|
|
52
56
|
if (artifact.kind !== "chunk" && artifact.kind !== "entry-point") continue;
|
|
@@ -57,13 +61,19 @@ export async function runBuild(config: BractJSConfig): Promise<void> {
|
|
|
57
61
|
await rename(artifact.path, hashedPath);
|
|
58
62
|
|
|
59
63
|
const publicPath = "/" + hashedPath.replace(/^build\//, "build/");
|
|
60
|
-
|
|
64
|
+
const absPath = resolve(artifact.path);
|
|
65
|
+
const rel = absPath.startsWith(outdirAbs + "/") ? absPath.slice(outdirAbs.length + 1) : basename(artifact.path);
|
|
66
|
+
const outBase = basename(artifact.path, extname(artifact.path));
|
|
67
|
+
|
|
68
|
+
if (artifact.kind === "entry-point" && outBase === entryBase) {
|
|
61
69
|
clientEntry = publicPath;
|
|
62
|
-
} else if (artifact.kind === "entry-point" &&
|
|
70
|
+
} else if (artifact.kind === "entry-point" && outBase === rootBase) {
|
|
63
71
|
rootChunk = publicPath;
|
|
64
72
|
} else {
|
|
65
|
-
|
|
66
|
-
|
|
73
|
+
const matched = routes.find((r) => {
|
|
74
|
+
const expected = join(appDirClean, r.filePath).replace(/\.[^.]+$/, ".js");
|
|
75
|
+
return rel === expected;
|
|
76
|
+
});
|
|
67
77
|
if (matched) routeChunks.set(matched.urlPattern, publicPath);
|
|
68
78
|
}
|
|
69
79
|
}
|
package/src/build/directives.ts
CHANGED
|
@@ -3,10 +3,37 @@ import type { BunPlugin } from "bun";
|
|
|
3
3
|
const CLIENT_RE = /^["']use client["']/m;
|
|
4
4
|
const SERVER_RE = /^["']use server["']/m;
|
|
5
5
|
|
|
6
|
+
// Strip a UTF-8 BOM and any leading ASCII whitespace before testing the
|
|
7
|
+
// directive regex. Editors that save files with BOM otherwise let "use server"
|
|
8
|
+
// fall through and ship server code to the client bundle.
|
|
9
|
+
function normalizeForDirectiveCheck(src: string): string {
|
|
10
|
+
return src.replace(/^/, "").replace(/^\s+/, "");
|
|
11
|
+
}
|
|
12
|
+
function hasClientDirective(src: string): boolean {
|
|
13
|
+
return CLIENT_RE.test(normalizeForDirectiveCheck(src));
|
|
14
|
+
}
|
|
15
|
+
function hasServerDirective(src: string): boolean {
|
|
16
|
+
return SERVER_RE.test(normalizeForDirectiveCheck(src));
|
|
17
|
+
}
|
|
18
|
+
|
|
6
19
|
function extractExports(src: string): string[] {
|
|
7
20
|
const names: string[] = [];
|
|
8
21
|
for (const m of src.matchAll(/^export\s+(?:async\s+)?function\s+(\w+)/gm)) names.push(m[1]);
|
|
9
|
-
for (const m of src.matchAll(/^export\s+(?:let|const)\s+(\w+)\s*=/gm)) names.push(m[1]);
|
|
22
|
+
for (const m of src.matchAll(/^export\s+(?:let|const|var)\s+(\w+)\s*=/gm)) names.push(m[1]);
|
|
23
|
+
for (const m of src.matchAll(/^export\s+default\s+(?:async\s+)?function\s+(\w+)/gm)) names.push(m[1]);
|
|
24
|
+
for (const m of src.matchAll(/^export\s+class\s+(\w+)/gm)) names.push(m[1]);
|
|
25
|
+
for (const m of src.matchAll(/^export\s*\{([^}]+)\}/gm)) {
|
|
26
|
+
for (const part of m[1].split(",")) {
|
|
27
|
+
const trimmed = part.trim();
|
|
28
|
+
if (!trimmed) continue;
|
|
29
|
+
const asMatch = trimmed.match(/\bas\s+(\w+)$/);
|
|
30
|
+
if (asMatch) names.push(asMatch[1]);
|
|
31
|
+
else {
|
|
32
|
+
const idMatch = trimmed.match(/^(\w+)/);
|
|
33
|
+
if (idMatch) names.push(idMatch[1]);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
10
37
|
return names;
|
|
11
38
|
}
|
|
12
39
|
|
|
@@ -25,7 +52,7 @@ export const useClientStubPlugin: BunPlugin = {
|
|
|
25
52
|
setup(build) {
|
|
26
53
|
build.onLoad({ filter: /\.(tsx?|jsx?)$/ }, async ({ path }) => {
|
|
27
54
|
const src = await Bun.file(path).text();
|
|
28
|
-
if (!
|
|
55
|
+
if (!hasClientDirective(src)) return undefined;
|
|
29
56
|
const stubs = extractExports(src).map((n) => `export const ${n} = () => null;`).join("\n");
|
|
30
57
|
return { contents: stubs || "export {};", loader: "ts" };
|
|
31
58
|
});
|
|
@@ -52,7 +79,7 @@ export const useServerProxyPlugin: BunPlugin = {
|
|
|
52
79
|
setup(build) {
|
|
53
80
|
build.onLoad({ filter: /\.(tsx?|jsx?)$/ }, async ({ path }) => {
|
|
54
81
|
const src = await Bun.file(path).text();
|
|
55
|
-
if (!
|
|
82
|
+
if (!hasServerDirective(src)) return undefined;
|
|
56
83
|
const names = extractExports(src);
|
|
57
84
|
if (names.length === 0) return { contents: "export {};", loader: "ts" };
|
|
58
85
|
const proxies = await Promise.all(
|
package/src/build/env-plugin.ts
CHANGED
|
@@ -41,6 +41,7 @@ export function clientEnvPlugin(
|
|
|
41
41
|
name: "bractjs-client-env",
|
|
42
42
|
setup(build) {
|
|
43
43
|
build.onLoad({ filter: /\.(ts|tsx|js|jsx)$/ }, async (args) => {
|
|
44
|
+
if (args.path.includes("/node_modules/")) return undefined;
|
|
44
45
|
const src = await Bun.file(args.path).text();
|
|
45
46
|
const contents = src.replace(
|
|
46
47
|
/process\.env\.([A-Z_][A-Z0-9_]*)/g,
|
package/src/build/hash.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { extname, basename, dirname, join } from "node:path";
|
|
2
|
-
import { test, expect } from "bun:test";
|
|
3
2
|
|
|
4
3
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
5
4
|
|
|
@@ -35,22 +34,3 @@ export async function renameWithHash(filePath: string): Promise<string> {
|
|
|
35
34
|
const base = basename(filePath, ext);
|
|
36
35
|
return join(dirname(filePath), `${base}.${hash}${ext}`);
|
|
37
36
|
}
|
|
38
|
-
|
|
39
|
-
// ── Tests ──────────────────────────────────────────────────────────────────
|
|
40
|
-
|
|
41
|
-
test("same content → same hash", async () => {
|
|
42
|
-
const a = await hashString("hello world");
|
|
43
|
-
const b = await hashString("hello world");
|
|
44
|
-
expect(a).toBe(b);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
test("different content → different hash", async () => {
|
|
48
|
-
const a = await hashString("foo");
|
|
49
|
-
const b = await hashString("bar");
|
|
50
|
-
expect(a).not.toBe(b);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test("hash is 8 hex chars", async () => {
|
|
54
|
-
const h = await hashString("bractjs");
|
|
55
|
-
expect(h).toMatch(/^[0-9a-f]{8}$/);
|
|
56
|
-
});
|
|
@@ -68,8 +68,10 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
|
|
|
68
68
|
setPathname(to);
|
|
69
69
|
setCurrentModule(routeModule);
|
|
70
70
|
});
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
const metaList = data.meta as Array<Record<string, unknown>> | undefined;
|
|
72
|
+
const titleEntry = metaList?.find((m) => "title" in m);
|
|
73
|
+
if (titleEntry && typeof titleEntry.title === "string") {
|
|
74
|
+
document.title = titleEntry.title;
|
|
73
75
|
}
|
|
74
76
|
} catch (err) {
|
|
75
77
|
console.error("[bractjs] loadRoute error:", err);
|
|
@@ -93,9 +95,11 @@ export function ClientRouter({ children, initialData, initialModule = null }: Cl
|
|
|
93
95
|
// Module-level HMR: swap the current route module without a full reload.
|
|
94
96
|
// The injected HMR client script calls window.__BRACTJS_HMR_ACCEPT__(pattern, mod)
|
|
95
97
|
// after importing the freshly-built chunk from /_hmr/module.
|
|
98
|
+
// Dev gate: prod builds inject __BRACT_DEV__ = false; absence in browser also
|
|
99
|
+
// counts as prod since we never reference `process` here.
|
|
96
100
|
useEffect(() => {
|
|
97
|
-
|
|
98
|
-
|
|
101
|
+
const w = window as unknown as { __BRACT_DEV__?: boolean; __BRACTJS_HMR_ACCEPT__?: unknown };
|
|
102
|
+
if (w.__BRACT_DEV__ !== true) return;
|
|
99
103
|
w.__BRACTJS_HMR_ACCEPT__ = (pattern: string, mod: RouteModuleClient) => {
|
|
100
104
|
const current = matchPatternForPath(pathname, manifest);
|
|
101
105
|
if (current === pattern) startTransition(() => setCurrentModule(mod));
|
|
@@ -29,23 +29,44 @@ function substituteParams(pattern: string, params: string[]): string {
|
|
|
29
29
|
).join("/");
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// Allowed pattern: "/" + (segment | ":ident") (segments are filename-derived).
|
|
33
|
+
// Restricting upfront removes any chance that a hostile filename injects a
|
|
34
|
+
// backtick, ${ }, or quote into the generated TS source.
|
|
35
|
+
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_]*))*$|^\/$/;
|
|
36
|
+
const SAFE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
37
|
+
|
|
38
|
+
function assertSafePattern(pattern: string): void {
|
|
39
|
+
if (!SAFE_PATTERN_RE.test(pattern)) {
|
|
40
|
+
throw new Error(`[bractjs] codegen: refusing to emit unsafe route pattern: ${JSON.stringify(pattern)}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function assertSafeParam(name: string): void {
|
|
44
|
+
if (!SAFE_IDENT_RE.test(name)) {
|
|
45
|
+
throw new Error(`[bractjs] codegen: refusing to emit unsafe param name: ${JSON.stringify(name)}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
32
49
|
function builderEntry(pattern: string, params: string[]): string {
|
|
33
|
-
|
|
34
|
-
|
|
50
|
+
assertSafePattern(pattern);
|
|
51
|
+
params.forEach(assertSafeParam);
|
|
52
|
+
const key = JSON.stringify(pattern);
|
|
53
|
+
if (params.length === 0) return " " + key + ": () => " + key + " as const,";
|
|
35
54
|
const paramType = params.map((p) => p + ": string").join("; ");
|
|
36
55
|
const body = substituteParams(pattern, params);
|
|
37
|
-
return "
|
|
56
|
+
return " " + key + ": (params: { " + paramType + " }) => `" + body + "` as const,";
|
|
38
57
|
}
|
|
39
58
|
|
|
40
59
|
function paramsTypeLines(routes: Array<{ pattern: string; params: string[] }>): string {
|
|
41
60
|
const dynamic = routes.filter((r) => r.params.length > 0);
|
|
42
61
|
if (dynamic.length === 0) return "export type RouteParams<_T extends AppRoutes> = Record<never, never>;";
|
|
43
62
|
const branches = dynamic
|
|
44
|
-
.map((r) =>
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
+ "
|
|
48
|
-
|
|
63
|
+
.map((r) => {
|
|
64
|
+
assertSafePattern(r.pattern);
|
|
65
|
+
r.params.forEach(assertSafeParam);
|
|
66
|
+
return " T extends " + JSON.stringify(r.pattern) + " ? { "
|
|
67
|
+
+ r.params.map((p) => p + ": string").join("; ")
|
|
68
|
+
+ " } :";
|
|
69
|
+
})
|
|
49
70
|
.join("\n");
|
|
50
71
|
return "export type RouteParams<T extends AppRoutes> =\n" + branches + "\n Record<never, never>;";
|
|
51
72
|
}
|
|
@@ -65,7 +86,10 @@ export async function generateRouteTypes(appDir: string): Promise<string> {
|
|
|
65
86
|
}));
|
|
66
87
|
|
|
67
88
|
const union = routes.length > 0
|
|
68
|
-
? routes.map((r) =>
|
|
89
|
+
? routes.map((r) => {
|
|
90
|
+
assertSafePattern(r.pattern);
|
|
91
|
+
return " | " + JSON.stringify(r.pattern);
|
|
92
|
+
}).join("\n")
|
|
69
93
|
: " never";
|
|
70
94
|
|
|
71
95
|
const builderEntries = routes.map((r) => builderEntry(r.pattern, r.params)).join("\n");
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { resolve, join } from "node:path";
|
|
1
|
+
import { resolve, join, sep } from "node:path";
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Dev-only HTTP handler for /_hmr/module?file=routes/about.tsx
|
|
@@ -17,10 +18,19 @@ export async function handleHmrModuleRequest(
|
|
|
17
18
|
return new Response("Missing file param", { status: 400 });
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
// Resolve and guard against path traversal
|
|
21
|
+
// Resolve and guard against path traversal AND symlink escape.
|
|
21
22
|
const rootDir = resolve(appDir);
|
|
22
|
-
const
|
|
23
|
-
if (!
|
|
23
|
+
const candidate = resolve(join(rootDir, file));
|
|
24
|
+
if (!candidate.startsWith(rootDir + sep) && candidate !== rootDir) {
|
|
25
|
+
return new Response("Forbidden", { status: 403 });
|
|
26
|
+
}
|
|
27
|
+
let fullPath: string;
|
|
28
|
+
try {
|
|
29
|
+
fullPath = await realpath(candidate);
|
|
30
|
+
} catch {
|
|
31
|
+
return new Response("Not Found", { status: 404 });
|
|
32
|
+
}
|
|
33
|
+
if (!fullPath.startsWith(rootDir + sep) && fullPath !== rootDir) {
|
|
24
34
|
return new Response("Forbidden", { status: 403 });
|
|
25
35
|
}
|
|
26
36
|
|
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,51 @@
|
|
|
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
17
|
// src must be a /public/ path with no traversal sequences
|
|
16
18
|
if (!src || !src.startsWith("/public/") || src.includes("..")) return null;
|
|
17
19
|
|
|
18
20
|
const rel = src.slice("/public/".length);
|
|
19
21
|
const root = resolve(publicDir);
|
|
20
|
-
const
|
|
21
|
-
if (!
|
|
22
|
+
const candidate = resolve(join(root, rel));
|
|
23
|
+
if (!candidate.startsWith(root + sep) && candidate !== root) return null;
|
|
24
|
+
// Re-check after symlink resolution. If the file doesn't exist yet, realpath
|
|
25
|
+
// throws — fall through and let the existence check below handle it.
|
|
26
|
+
let filePath = candidate;
|
|
27
|
+
try {
|
|
28
|
+
const real = await realpath(candidate);
|
|
29
|
+
if (!real.startsWith(root + sep) && real !== root) return null;
|
|
30
|
+
filePath = real;
|
|
31
|
+
} catch {
|
|
32
|
+
// missing file: defer to Bun.file(...).exists() below
|
|
33
|
+
}
|
|
22
34
|
|
|
23
35
|
const wRaw = sp.get("w");
|
|
24
36
|
const hRaw = sp.get("h");
|
|
25
37
|
const w = wRaw ? parseInt(wRaw, 10) : undefined;
|
|
26
38
|
const h = hRaw ? parseInt(hRaw, 10) : undefined;
|
|
27
|
-
if (w !== undefined && (isNaN(w) || w
|
|
28
|
-
if (h !== undefined && (isNaN(h) || h
|
|
39
|
+
if (w !== undefined && (isNaN(w) || !ALLOWED_DIMS.has(w))) return null;
|
|
40
|
+
if (h !== undefined && (isNaN(h) || !ALLOWED_DIMS.has(h))) return null;
|
|
41
|
+
if (w !== undefined && h !== undefined && w * h > MAX_AREA) return null;
|
|
29
42
|
|
|
30
43
|
const q = Math.min(100, Math.max(1, parseInt(sp.get("q") ?? String(QUALITY_DEFAULT), 10)));
|
|
31
44
|
const fmt = (sp.get("format") ?? FORMAT_DEFAULT) as ImageFormat;
|
|
32
|
-
const
|
|
45
|
+
const fitRaw = sp.get("fit") ?? FIT_DEFAULT;
|
|
33
46
|
if (!MIME[fmt]) return null;
|
|
47
|
+
if (!ALLOWED_FITS.has(fitRaw as ImageFit)) return null;
|
|
48
|
+
const fit = fitRaw as ImageFit;
|
|
34
49
|
|
|
35
50
|
return { src, filePath, params: { w, h, q, format: fmt, fit } };
|
|
36
51
|
}
|
|
@@ -53,7 +68,7 @@ export async function handleImageRequest(
|
|
|
53
68
|
const url = new URL(request.url);
|
|
54
69
|
if (url.pathname !== "/_image") return null;
|
|
55
70
|
|
|
56
|
-
const parsed = parseParams(url.searchParams, publicDir);
|
|
71
|
+
const parsed = await parseParams(url.searchParams, publicDir);
|
|
57
72
|
if (!parsed) return new Response("Bad Request", { status: 400 });
|
|
58
73
|
|
|
59
74
|
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
|
|
|
@@ -57,20 +81,28 @@ export async function transformImage(
|
|
|
57
81
|
return { data, contentType: MIME[fmt] ?? "image/jpeg", format: fmt };
|
|
58
82
|
}
|
|
59
83
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
84
|
+
await acquireSlot();
|
|
85
|
+
try {
|
|
86
|
+
const proc = Bun.spawn(buildArgs(binary, filePath, params), {
|
|
87
|
+
stdout: "pipe",
|
|
88
|
+
stderr: "ignore",
|
|
89
|
+
timeout: SPAWN_TIMEOUT_MS,
|
|
90
|
+
killSignal: "SIGKILL",
|
|
91
|
+
});
|
|
64
92
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
]);
|
|
93
|
+
const [data, exitCode] = await Promise.all([
|
|
94
|
+
new Response(proc.stdout!).arrayBuffer(),
|
|
95
|
+
proc.exited,
|
|
96
|
+
]);
|
|
70
97
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
98
|
+
if (exitCode !== 0) {
|
|
99
|
+
// Non-zero exit covers normal failures AND timeout-induced SIGKILL,
|
|
100
|
+
// since Bun reports the signal as a non-zero exit code.
|
|
101
|
+
throw new Error(`[bractjs] ImageMagick exited ${exitCode} for ${filePath}`);
|
|
102
|
+
}
|
|
74
103
|
|
|
75
|
-
|
|
104
|
+
return { data, contentType: MIME[params.format], format: params.format };
|
|
105
|
+
} finally {
|
|
106
|
+
releaseSlot();
|
|
107
|
+
}
|
|
76
108
|
}
|
package/src/image/types.ts
CHANGED
package/src/middleware/cors.ts
CHANGED
|
@@ -3,25 +3,39 @@ 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
|
-
const allowed = allowedOrigins.includes("*") || allowedOrigins.includes(origin);
|
|
20
28
|
const corsHeaders: Record<string, string> = {
|
|
21
29
|
"Access-Control-Allow-Methods": allowedMethods,
|
|
22
30
|
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
31
|
+
"Vary": "Origin",
|
|
23
32
|
};
|
|
24
|
-
if (
|
|
33
|
+
if (wildcard) {
|
|
34
|
+
corsHeaders["Access-Control-Allow-Origin"] = "*";
|
|
35
|
+
} else if (origin && allowedOrigins.includes(origin)) {
|
|
36
|
+
corsHeaders["Access-Control-Allow-Origin"] = origin;
|
|
37
|
+
}
|
|
38
|
+
if (credentials) corsHeaders["Access-Control-Allow-Credentials"] = "true";
|
|
25
39
|
|
|
26
40
|
// Preflight
|
|
27
41
|
if (ctx.request.method === "OPTIONS") {
|
|
@@ -29,8 +43,10 @@ export function cors(options: CorsOptions): MiddlewareFn {
|
|
|
29
43
|
}
|
|
30
44
|
|
|
31
45
|
const response = await next();
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
46
|
+
// Mutate headers in place rather than wrapping body. Wrapping with
|
|
47
|
+
// `new Response(response.body, response)` makes the original Response
|
|
48
|
+
// unusable to anyone holding a reference (single-shot stream).
|
|
49
|
+
for (const [k, v] of Object.entries(corsHeaders)) response.headers.set(k, v);
|
|
50
|
+
return response;
|
|
35
51
|
};
|
|
36
52
|
}
|
|
@@ -1,10 +1,25 @@
|
|
|
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
|
+
function hasForbiddenKey(value: unknown): boolean {
|
|
11
|
+
if (!value || typeof value !== "object") return false;
|
|
12
|
+
for (const key of Object.keys(value as Record<string, unknown>)) {
|
|
13
|
+
if (FORBIDDEN_KEYS.has(key)) return true;
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
3
17
|
|
|
4
18
|
export async function handleActionRequest(request: Request): Promise<Response | null> {
|
|
5
19
|
const url = new URL(request.url);
|
|
6
20
|
if (!url.pathname.startsWith("/_action")) return null;
|
|
7
21
|
if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405 });
|
|
22
|
+
if (!isAllowedMutation(request)) return new Response("Forbidden", { status: 403 });
|
|
8
23
|
|
|
9
24
|
const id = url.searchParams.get("id");
|
|
10
25
|
if (!id) return new Response("Bad Request: missing action id", { status: 400 });
|
|
@@ -18,8 +33,32 @@ export async function handleActionRequest(request: Request): Promise<Response |
|
|
|
18
33
|
if (ct.includes("multipart/form-data") || ct.includes("application/x-www-form-urlencoded")) {
|
|
19
34
|
args = [await request.formData()];
|
|
20
35
|
} else {
|
|
36
|
+
// Cheap pre-check: trust Content-Length if the client sent one.
|
|
37
|
+
const clRaw = request.headers.get("Content-Length");
|
|
38
|
+
if (clRaw) {
|
|
39
|
+
const cl = Number(clRaw);
|
|
40
|
+
if (Number.isFinite(cl) && cl > MAX_JSON_BODY_BYTES) {
|
|
41
|
+
return new Response("Payload Too Large", { status: 413 });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
21
44
|
const text = await request.text();
|
|
22
|
-
|
|
45
|
+
// Defense in depth: clients can lie about Content-Length, so verify the
|
|
46
|
+
// actual decoded text length too.
|
|
47
|
+
if (text.length > MAX_JSON_BODY_BYTES) {
|
|
48
|
+
return new Response("Payload Too Large", { status: 413 });
|
|
49
|
+
}
|
|
50
|
+
if (!text) {
|
|
51
|
+
args = [];
|
|
52
|
+
} else {
|
|
53
|
+
const parsed: unknown = JSON.parse(text);
|
|
54
|
+
if (!Array.isArray(parsed)) {
|
|
55
|
+
return new Response("Bad Request: args must be array", { status: 400 });
|
|
56
|
+
}
|
|
57
|
+
if (parsed.some(hasForbiddenKey)) {
|
|
58
|
+
return new Response("Bad Request: forbidden keys", { status: 400 });
|
|
59
|
+
}
|
|
60
|
+
args = parsed;
|
|
61
|
+
}
|
|
23
62
|
}
|
|
24
63
|
} catch {
|
|
25
64
|
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; }
|