@teamscala/service-boot 0.23.358 → 0.23.360

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 CHANGED
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.358",
9
+ "version": "0.23.360",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -62,6 +62,16 @@ export async function attachTransfersUpload(app: Hono): Promise<void> {
62
62
  const userId = session?.user?.id;
63
63
  if (!userId) return c.json({ error: "Not authenticated" }, 401);
64
64
 
65
+ // Reject on Content-Length BEFORE formData() buffers the body — a
66
+ // file.size check after buffering is a report, not a limit (an attacker
67
+ // sends the bytes either way). The global 30MB boot-level bodyLimit
68
+ // remains the real bound for lying/absent Content-Length; this early
69
+ // return bounds the honest case without parsing a megabyte stream.
70
+ const declaredLength = Number(c.req.header("content-length") ?? 0);
71
+ if (declaredLength > MAX_FILE_SIZE) {
72
+ return c.json({ error: "file too large (max 25MB)" }, 413);
73
+ }
74
+
65
75
  const form = await c.req.formData();
66
76
  const file = form.get("file");
67
77
  const type = String(form.get("type") ?? "uploads");
@@ -0,0 +1,177 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Behavior proofs for the two boot-chain security invariants:
7
+ * - security-csrf-protection: a state-changing form-element POST from an
8
+ * untrusted Origin is refused 403; the managed-base-domain Origin (the
9
+ * derived trusted set) is allowed; same-origin sec-fetch-site is allowed.
10
+ * - security-body-limit: a body over the global limit is refused 413 BEFORE
11
+ * any handler runs, and the transfers upload rejects an over-sized
12
+ * Content-Length without buffering the body.
13
+ * Proxy-is-not-behaviour guards: these exercise the real middleware through
14
+ * real Hono requests.
15
+ */
16
+ import { describe, expect, it } from "bun:test";
17
+ import { Hono } from "hono";
18
+ import { bodyLimit } from "hono/body-limit";
19
+ import { csrf } from "hono/csrf";
20
+ import { csrfProtection } from "./csrf-protection.ts";
21
+
22
+ type CacheGlobal = typeof globalThis & {
23
+ __registryCache?: Map<string, { type: string; slug: string; config: unknown }>;
24
+ };
25
+
26
+ function seedPortalBaseDomain(base: string): void {
27
+ const g = globalThis as CacheGlobal;
28
+ if (!g.__registryCache) g.__registryCache = new Map();
29
+ g.__registryCache.set("config:portal-domains", {
30
+ type: "config",
31
+ slug: "portal-domains",
32
+ config: { base },
33
+ });
34
+ }
35
+
36
+ describe("csrfProtection", () => {
37
+ it("refuses a cross-site form POST from an untrusted origin (403)", async () => {
38
+ seedPortalBaseDomain("scala.business");
39
+ const app = new Hono();
40
+ app.use("*", csrfProtection());
41
+ app.post("/api/settings", (c) => c.text("ok"));
42
+ const res = await app.request("https://portal.scala.business/api/settings", {
43
+ method: "POST",
44
+ headers: {
45
+ "content-type": "application/x-www-form-urlencoded",
46
+ origin: "https://evil.example",
47
+ },
48
+ body: "x=1",
49
+ });
50
+ expect(res.status).toBe(403);
51
+ });
52
+
53
+ it("allows a form POST whose origin is under the managed base domain", async () => {
54
+ seedPortalBaseDomain("scala.business");
55
+ const app = new Hono();
56
+ app.use("*", csrfProtection());
57
+ app.post("/api/settings", (c) => c.text("ok"));
58
+ const res = await app.request("https://portal.scala.business/api/settings", {
59
+ method: "POST",
60
+ headers: {
61
+ "content-type": "application/x-www-form-urlencoded",
62
+ // cross-SERVICE, same platform: the proposals → sign redirect-chain shape
63
+ origin: "https://sign.scala.business",
64
+ },
65
+ body: "x=1",
66
+ });
67
+ expect(res.status).toBe(200);
68
+ });
69
+
70
+ it("allows a same-origin browser post via sec-fetch-site", async () => {
71
+ seedPortalBaseDomain("scala.business");
72
+ const app = new Hono();
73
+ app.use("*", csrfProtection());
74
+ app.post("/api/settings", (c) => c.text("ok"));
75
+ const res = await app.request("https://portal.scala.business/api/settings", {
76
+ method: "POST",
77
+ headers: {
78
+ "content-type": "application/x-www-form-urlencoded",
79
+ "sec-fetch-site": "same-origin",
80
+ },
81
+ body: "x=1",
82
+ });
83
+ expect(res.status).toBe(200);
84
+ });
85
+
86
+ it("does not arm on application/json (cross-site JSON is not formable)", async () => {
87
+ seedPortalBaseDomain("scala.business");
88
+ const app = new Hono();
89
+ app.use("*", csrfProtection());
90
+ app.post("/api/rpc/x", (c) => c.text("ok"));
91
+ const res = await app.request("https://portal.scala.business/api/rpc/x", {
92
+ method: "POST",
93
+ headers: { "content-type": "application/json", origin: "https://evil.example" },
94
+ body: JSON.stringify({ x: 1 }),
95
+ });
96
+ expect(res.status).toBe(200);
97
+ });
98
+ });
99
+
100
+ describe("global body limit (security-body-limit)", () => {
101
+ const limit = 1024;
102
+
103
+ function appWithLimit(): Hono {
104
+ const app = new Hono();
105
+ let handlerRan = false;
106
+ app.use(
107
+ "*",
108
+ bodyLimit({
109
+ maxSize: limit,
110
+ onError: (c) => c.text("Payload Too Large", 413),
111
+ }),
112
+ );
113
+ app.post("/api/upload", async (c) => {
114
+ handlerRan = true;
115
+ return c.text("ok");
116
+ });
117
+ // expose the flag after the request via a closure read below
118
+ (app as unknown as Record<string, unknown>).__handlerRan = () => handlerRan;
119
+ return app;
120
+ }
121
+
122
+ it("refuses an oversized body with 413 before the handler runs", async () => {
123
+ const app = appWithLimit();
124
+ const res = await app.request("https://host.example/api/upload", {
125
+ method: "POST",
126
+ headers: { "content-type": "application/octet-stream" },
127
+ body: "x".repeat(limit + 1),
128
+ });
129
+ expect(res.status).toBe(413);
130
+ expect(
131
+ (app as unknown as { __handlerRan: () => boolean }).__handlerRan(),
132
+ ).toBe(false);
133
+ });
134
+
135
+ it("allows a body within the limit", async () => {
136
+ const app = appWithLimit();
137
+ const res = await app.request("https://host.example/api/upload", {
138
+ method: "POST",
139
+ headers: { "content-type": "application/octet-stream" },
140
+ body: "x".repeat(limit - 1),
141
+ });
142
+ expect(res.status).toBe(200);
143
+ });
144
+
145
+ it("a route-level smaller limit still refuses what the global limit allows", async () => {
146
+ const app = new Hono();
147
+ app.use(
148
+ "*",
149
+ bodyLimit({
150
+ maxSize: 4 * limit,
151
+ onError: (c) => c.text("Payload Too Large", 413),
152
+ }),
153
+ );
154
+ app.post(
155
+ "/api/rpc/*",
156
+ bodyLimit({
157
+ maxSize: limit,
158
+ onError: (c) => c.text("Request body too large", 413),
159
+ }),
160
+ (c) => c.text("ok"),
161
+ );
162
+ const res = await app.request("https://host.example/api/rpc/x", {
163
+ method: "POST",
164
+ headers: { "content-type": "application/json" },
165
+ body: "x".repeat(2 * limit),
166
+ });
167
+ expect(res.status).toBe(413);
168
+ });
169
+ });
170
+
171
+ describe("csrf import wiring", () => {
172
+ it("csrfProtection() composes hono csrf without options drift", () => {
173
+ const mw = csrfProtection();
174
+ expect(typeof mw).toBe("function");
175
+ expect(typeof csrf).toBe("function");
176
+ });
177
+ });
@@ -0,0 +1,100 @@
1
+ /**
2
+ * @system server-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * csrf-protection.ts — platform CSRF middleware (`security-csrf-protection`).
7
+ *
8
+ * Hono's csrf() with a DERIVED trusted-origin set, never a hand-authored
9
+ * allowlist: every trusted origin comes from ONE source per
10
+ * `offered-is-derived` —
11
+ * - the managed base domain (registry portal-domains) covers the platform's
12
+ * own `*.scala.business` origins, INCLUDING the cross-service form POST
13
+ * redirect chains CSP form-action allowlists (proposals → sign etc.), so
14
+ * the CSRF origin set and the CSP form-action set cannot disagree about
15
+ * what "one of ours" means;
16
+ * - customer custom domains resolve through `isTrustedAuthOrigin`
17
+ * (builda_websites lookup) — the SAME lookup better-auth's trustedOrigins
18
+ * uses in @teamscala/auth, so a new custom domain is trusted everywhere at
19
+ * once or nowhere.
20
+ *
21
+ * hono csrf() arms only on form-element content types (urlencoded, multipart,
22
+ * text/plain) — browsers cannot produce a cross-site application/json request
23
+ * from a form, so the JSON API surface (ORPC) is protected by cookie
24
+ * SameSite=Lax + this middleware's text/plain coverage.
25
+ *
26
+ * Custom-domain lookups are cached per-hostname with a TTL: a POSITIVE result
27
+ * is cached for the full TTL (a domain that stopped being ours keeps working
28
+ * for at most the TTL), a NEGATIVE result for a short window (a brand-new
29
+ * custom domain becomes trusted within seconds). The cache is a request
30
+ * accelerator over a DB fact, never the source of truth.
31
+ */
32
+
33
+ import { getManagedBaseDomain } from "@teamscala/auth/auth-env-resolution";
34
+ import { isTrustedAuthOrigin } from "@teamscala/auth/is-trusted-auth-origin";
35
+ import { csrf } from "hono/csrf";
36
+ import type { Context, MiddlewareHandler } from "hono";
37
+
38
+ const POSITIVE_TTL_MS = 5 * 60_000;
39
+ const NEGATIVE_TTL_MS = 10_000;
40
+
41
+ type CacheEntry = { trusted: boolean; expiresAt: number };
42
+ const customDomainCache = new Map<string, CacheEntry>();
43
+
44
+ function isPlatformOrigin(origin: string, baseDomain: string): boolean {
45
+ let host: string;
46
+ try {
47
+ host = new URL(origin).hostname.toLowerCase();
48
+ } catch {
49
+ return false;
50
+ }
51
+ if (!host) return false;
52
+ return host === baseDomain || host.endsWith(`.${baseDomain}`);
53
+ }
54
+
55
+ async function isTrustedCustomOrigin(origin: string): Promise<boolean> {
56
+ let host: string;
57
+ try {
58
+ host = new URL(origin).hostname.toLowerCase();
59
+ } catch {
60
+ return false;
61
+ }
62
+ if (!host) return false;
63
+
64
+ const now = Date.now();
65
+ const cached = customDomainCache.get(host);
66
+ if (cached && cached.expiresAt > now) return cached.trusted;
67
+
68
+ const trusted = await (async () => {
69
+ try {
70
+ const { getPrisma } = await import("@teamscala/db/prisma-registry");
71
+ const prisma = getPrisma();
72
+ return await isTrustedAuthOrigin(
73
+ prisma as unknown as Parameters<typeof isTrustedAuthOrigin>[0],
74
+ host,
75
+ );
76
+ } catch {
77
+ // A lookup that cannot run is a lookup that trusts nothing: an
78
+ // unavailable DB degrades to platform-origin-only CSRF, never to
79
+ // allow-everything.
80
+ return false;
81
+ }
82
+ })();
83
+
84
+ customDomainCache.set(host, {
85
+ trusted,
86
+ expiresAt: now + (trusted ? POSITIVE_TTL_MS : NEGATIVE_TTL_MS),
87
+ });
88
+ if (customDomainCache.size > 512) customDomainCache.clear();
89
+ return trusted;
90
+ }
91
+
92
+ export function csrfProtection(): MiddlewareHandler {
93
+ return csrf({
94
+ origin: async (origin: string, _c: Context) => {
95
+ const baseDomain = getManagedBaseDomain();
96
+ if (isPlatformOrigin(origin, baseDomain)) return true;
97
+ return isTrustedCustomOrigin(origin);
98
+ },
99
+ });
100
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Behavior proof for the `security-content-type-nosniff` +
7
+ * `security-permissions-policy` invariants: exercise injectSecurityHeaders
8
+ * through a real Hono request and assert the headers on the RESPONSE —
9
+ * a registry row naming a header is not evidence the header is served.
10
+ * Covers both modes: the seeded-registry path (config row applied, CSP
11
+ * {{nonce}} substituted) and the DB-less fallback (secureHeaders defaults).
12
+ */
13
+ import { describe, expect, it } from "bun:test";
14
+ import { Hono } from "hono";
15
+ import { injectSecurityHeaders } from "./security-headers.ts";
16
+
17
+ type CacheGlobal = typeof globalThis & {
18
+ __registryCache?: Map<string, { type: string; slug: string; config: unknown }>;
19
+ };
20
+
21
+ function seedRegistryHeaders(config: Record<string, string>): void {
22
+ const g = globalThis as CacheGlobal;
23
+ if (!g.__registryCache) g.__registryCache = new Map();
24
+ g.__registryCache.set("config:security-headers", {
25
+ type: "config",
26
+ slug: "security-headers",
27
+ config,
28
+ });
29
+ }
30
+
31
+ function clearRegistryHeaders(): void {
32
+ (globalThis as CacheGlobal).__registryCache?.delete("config:security-headers");
33
+ }
34
+
35
+ function appWithMiddleware(): Hono {
36
+ const app = new Hono();
37
+ app.use("*", injectSecurityHeaders);
38
+ app.get("/", (c) => c.text("ok"));
39
+ return app;
40
+ }
41
+
42
+ describe("injectSecurityHeaders (registry mode)", () => {
43
+ it("serves X-Content-Type-Options: nosniff and the configured Permissions-Policy", async () => {
44
+ seedRegistryHeaders({
45
+ "X-Content-Type-Options": "nosniff",
46
+ "Permissions-Policy": "camera=(), microphone=(self), geolocation=()",
47
+ "Content-Security-Policy": "script-src 'nonce-{{nonce}}' 'strict-dynamic'",
48
+ });
49
+ try {
50
+ const res = await appWithMiddleware().request("https://host.example/");
51
+ expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
52
+ expect(res.headers.get("Permissions-Policy")).toBe(
53
+ "camera=(), microphone=(self), geolocation=()",
54
+ );
55
+ } finally {
56
+ clearRegistryHeaders();
57
+ }
58
+ });
59
+
60
+ it("substitutes the CSP {{nonce}} placeholder — the served policy carries a real nonce", async () => {
61
+ seedRegistryHeaders({
62
+ "Content-Security-Policy": "script-src 'nonce-{{nonce}}' 'strict-dynamic'",
63
+ });
64
+ try {
65
+ const res = await appWithMiddleware().request("https://host.example/");
66
+ const csp = res.headers.get("Content-Security-Policy") ?? "";
67
+ expect(csp).not.toContain("{{nonce}}");
68
+ expect(csp).toMatch(/'nonce-[^']+'/);
69
+ } finally {
70
+ clearRegistryHeaders();
71
+ }
72
+ });
73
+ });
74
+
75
+ describe("injectSecurityHeaders (fallback mode, no registry)", () => {
76
+ it("still serves X-Content-Type-Options: nosniff from secureHeaders defaults", async () => {
77
+ clearRegistryHeaders();
78
+ const res = await appWithMiddleware().request("https://host.example/");
79
+ expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
80
+ expect((res.headers.get("Content-Security-Policy") ?? "")).not.toContain("{{nonce}}");
81
+ });
82
+ });
@@ -18,6 +18,7 @@ import { buildWakingPageHtml } from "@teamscala/os/brand/waking-page-html";
18
18
  import { BootFatalError } from "@teamscala/os/errors/boot-error";
19
19
  import { createTimer } from "@teamscala/timing/create-timer";
20
20
  import { Hono, type Context } from "hono";
21
+ import { bodyLimit } from "hono/body-limit";
21
22
  import { compress } from "hono/compress";
22
23
  import { cors } from "hono/cors";
23
24
  import { timing } from "hono/timing";
@@ -33,6 +34,7 @@ import { attachProposalMagic } from "./attach-proposal-magic.ts";
33
34
  import { attachPortalMembers } from "./attach-portal-members.ts";
34
35
  import { attachTransfersUpload } from "./attach-transfers-upload.ts";
35
36
  import { attachAuthGate } from "./auth-gate.ts";
37
+ import { csrfProtection } from "./csrf-protection.ts";
36
38
  import { attachRequestLogger } from "./install-logging.ts";
37
39
  import type { ServiceInstallConfig } from "./load-service-install-row.ts";
38
40
  import { mountOrpcRouter } from "./mount-orpc-router.ts";
@@ -276,6 +278,26 @@ export async function buildMinimalApp(
276
278
  );
277
279
  app.use("*", compress());
278
280
  app.use("*", timing());
281
+ // CSRF (`security-csrf-protection`): state-changing form-element requests
282
+ // must carry a trusted Origin — derived set (managed base domain +
283
+ // builda_websites custom domains via isTrustedAuthOrigin), never an
284
+ // allowlist beside better-auth's. Registered after security headers, before
285
+ // every route + gate.
286
+ app.use("*", csrfProtection());
287
+ // GLOBAL body limit (`security-body-limit`): every request body is bounded
288
+ // BEFORE any handler buffers it — a per-route file.size check AFTER
289
+ // formData() has buffered is a report, not a limit. 30MB sits above the
290
+ // 25MB transfers-upload maximum; routes wanting a smaller bound mount their
291
+ // own bodyLimit BEFORE this one (Hono runs matching middleware in
292
+ // registration order, and /api/rpc registers its 10MB limit in
293
+ // mount-orpc-router).
294
+ app.use(
295
+ "*",
296
+ bodyLimit({
297
+ maxSize: 30 * 1024 * 1024,
298
+ onError: (c) => c.text("Payload Too Large", 413),
299
+ }),
300
+ );
279
301
  attachRequestLogger(app, serviceSlug);
280
302
 
281
303
  // Fire independent I/O + compilation NOW, overlapping with the gate chain