@cosmicdrift/kumiko-server-runtime 0.157.1 → 0.158.2
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 +3 -3
- package/src/__tests__/security-headers.test.ts +88 -0
- package/src/index.ts +1 -0
- package/src/run-prod-app.ts +37 -12
- package/src/security-headers.ts +67 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-server-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.158.2",
|
|
4
4
|
"description": "Production server-boot runtime for Kumiko apps: connections, schema-drift-gate, seeds, lifecycle, graceful shutdown. Symmetric to kumiko-dev-server's runDevApp, without dev/scaffold/codegen tooling.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -72,8 +72,8 @@
|
|
|
72
72
|
}
|
|
73
73
|
},
|
|
74
74
|
"dependencies": {
|
|
75
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
76
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
75
|
+
"@cosmicdrift/kumiko-bundled-features": "0.158.2",
|
|
76
|
+
"@cosmicdrift/kumiko-framework": "0.158.2",
|
|
77
77
|
"temporal-polyfill": "^0.3.2"
|
|
78
78
|
},
|
|
79
79
|
"publishConfig": {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { resolveSecurityHeaders, withSecurityHeaders } from "../security-headers";
|
|
3
|
+
|
|
4
|
+
const okHandler = (_req: Request) => new Response("ok");
|
|
5
|
+
const req = new Request("http://localhost/");
|
|
6
|
+
|
|
7
|
+
describe("resolveSecurityHeaders", () => {
|
|
8
|
+
it("returns the four defaults without csp when unconfigured", () => {
|
|
9
|
+
const headers = new Map(resolveSecurityHeaders(undefined));
|
|
10
|
+
expect(headers.get("strict-transport-security")).toBe("max-age=31536000; includeSubDomains");
|
|
11
|
+
expect(headers.get("x-frame-options")).toBe("DENY");
|
|
12
|
+
expect(headers.get("x-content-type-options")).toBe("nosniff");
|
|
13
|
+
expect(headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin");
|
|
14
|
+
expect(headers.has("content-security-policy")).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("returns nothing when disabled", () => {
|
|
18
|
+
expect(resolveSecurityHeaders(false)).toEqual([]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("applies per-header overrides and opt-outs", () => {
|
|
22
|
+
const headers = new Map(
|
|
23
|
+
resolveSecurityHeaders({
|
|
24
|
+
hsts: "max-age=60",
|
|
25
|
+
frameOptions: false,
|
|
26
|
+
referrerPolicy: "no-referrer",
|
|
27
|
+
csp: "default-src 'self'",
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
expect(headers.get("strict-transport-security")).toBe("max-age=60");
|
|
31
|
+
expect(headers.has("x-frame-options")).toBe(false);
|
|
32
|
+
expect(headers.get("x-content-type-options")).toBe("nosniff");
|
|
33
|
+
expect(headers.get("referrer-policy")).toBe("no-referrer");
|
|
34
|
+
expect(headers.get("content-security-policy")).toBe("default-src 'self'");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("withSecurityHeaders", () => {
|
|
39
|
+
it("sets defaults on every response", async () => {
|
|
40
|
+
const res = await withSecurityHeaders(okHandler, undefined)(req);
|
|
41
|
+
expect(res.headers.get("strict-transport-security")).toBe(
|
|
42
|
+
"max-age=31536000; includeSubDomains",
|
|
43
|
+
);
|
|
44
|
+
expect(res.headers.get("x-frame-options")).toBe("DENY");
|
|
45
|
+
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
|
|
46
|
+
expect(res.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin");
|
|
47
|
+
expect(await res.text()).toBe("ok");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns the handler unwrapped when disabled", () => {
|
|
51
|
+
expect(withSecurityHeaders(okHandler, false)).toBe(okHandler);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("never overrides a header the response already set", async () => {
|
|
55
|
+
const handler = (_req: Request) =>
|
|
56
|
+
new Response("ok", {
|
|
57
|
+
headers: {
|
|
58
|
+
"content-security-policy": "default-src 'none'",
|
|
59
|
+
"x-frame-options": "SAMEORIGIN",
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
const res = await withSecurityHeaders(handler, { csp: "default-src 'self'" })(req);
|
|
63
|
+
expect(res.headers.get("content-security-policy")).toBe("default-src 'none'");
|
|
64
|
+
expect(res.headers.get("x-frame-options")).toBe("SAMEORIGIN");
|
|
65
|
+
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("re-wraps responses with immutable headers", async () => {
|
|
69
|
+
const immutable = new Response("ok", { status: 201, statusText: "Created" });
|
|
70
|
+
Object.defineProperty(immutable, "headers", {
|
|
71
|
+
value: new Proxy(immutable.headers, {
|
|
72
|
+
get(target, prop) {
|
|
73
|
+
if (prop === "set") {
|
|
74
|
+
return () => {
|
|
75
|
+
throw new TypeError("immutable");
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const value = Reflect.get(target, prop);
|
|
79
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
});
|
|
83
|
+
const res = await withSecurityHeaders(() => immutable, undefined)(req);
|
|
84
|
+
expect(res.status).toBe(201);
|
|
85
|
+
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
|
|
86
|
+
expect(await res.text()).toBe("ok");
|
|
87
|
+
});
|
|
88
|
+
});
|
package/src/index.ts
CHANGED
package/src/run-prod-app.ts
CHANGED
|
@@ -53,8 +53,9 @@ import {
|
|
|
53
53
|
} from "@cosmicdrift/kumiko-bundled-features/tenant-lifecycle";
|
|
54
54
|
import { UserQueries } from "@cosmicdrift/kumiko-bundled-features/user";
|
|
55
55
|
import {
|
|
56
|
-
|
|
56
|
+
createRedisLoginRateLimiter,
|
|
57
57
|
createSseBroker,
|
|
58
|
+
type LoginRateLimiter,
|
|
58
59
|
type SseBroker,
|
|
59
60
|
} from "@cosmicdrift/kumiko-framework/api";
|
|
60
61
|
import {
|
|
@@ -126,6 +127,7 @@ import {
|
|
|
126
127
|
resolveAuthMail,
|
|
127
128
|
} from "./run-prod-app-boot-context";
|
|
128
129
|
import { buildStaticFallback } from "./run-prod-app-static-files";
|
|
130
|
+
import { type SecurityHeadersOption, withSecurityHeaders } from "./security-headers";
|
|
129
131
|
import {
|
|
130
132
|
type ProdSessionsOption,
|
|
131
133
|
resolveProdSessionsConfig,
|
|
@@ -569,6 +571,12 @@ export type RunProdAppOptions = {
|
|
|
569
571
|
* dieses Feld wird kein L1/L2 verdrahtet (L3 Handler-`rateLimit:`
|
|
570
572
|
* funktioniert unabhängig davon bereits). */
|
|
571
573
|
readonly rateLimit?: import("@cosmicdrift/kumiko-framework/api").ServerOptions["rateLimit"];
|
|
574
|
+
/** Default security headers on every response (HSTS, X-Frame-Options,
|
|
575
|
+
* X-Content-Type-Options, Referrer-Policy; CSP opt-in). Headers already
|
|
576
|
+
* set by a response (e.g. hostDispatch's per-host CSP) are never
|
|
577
|
+
* overridden. `false` disables the whole block; per-header overrides
|
|
578
|
+
* via the object form — see SecurityHeadersOption. */
|
|
579
|
+
readonly securityHeaders?: SecurityHeadersOption;
|
|
572
580
|
};
|
|
573
581
|
|
|
574
582
|
export type ProdAppHandle = {
|
|
@@ -856,14 +864,14 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
856
864
|
let patAuthFragment:
|
|
857
865
|
| {
|
|
858
866
|
patResolver: ReturnType<typeof createPatResolver>;
|
|
859
|
-
patRateLimiter:
|
|
867
|
+
patRateLimiter: LoginRateLimiter;
|
|
860
868
|
}
|
|
861
869
|
| undefined;
|
|
862
870
|
if (effectiveAuth && patFeature) {
|
|
863
871
|
const rl = patRateLimitFromFeature(patFeature);
|
|
864
872
|
patAuthFragment = {
|
|
865
873
|
patResolver: createPatResolver({ db, scopes: patScopesFromFeature(patFeature) }),
|
|
866
|
-
patRateLimiter:
|
|
874
|
+
patRateLimiter: createRedisLoginRateLimiter(redis, rl.maxRequests, rl.windowMs, "pat"),
|
|
867
875
|
};
|
|
868
876
|
}
|
|
869
877
|
|
|
@@ -908,6 +916,12 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
908
916
|
[AuthErrors.invalidCredentials]: 401,
|
|
909
917
|
[AuthErrors.noMembership]: 403,
|
|
910
918
|
},
|
|
919
|
+
// Redis-backed, not the in-memory default createAuthRoutes falls
|
|
920
|
+
// back to — an in-process limiter only rate-limits within a single
|
|
921
|
+
// replica, so a multi-instance prod deployment would silently give
|
|
922
|
+
// each replica its own bucket (#1262/#1274). Redis is required infra
|
|
923
|
+
// here already (REDIS_URL), so this is free.
|
|
924
|
+
loginRateLimit: createRedisLoginRateLimiter(redis),
|
|
911
925
|
...(effectiveAuth.cookieDomain !== undefined && {
|
|
912
926
|
cookieDomain: effectiveAuth.cookieDomain,
|
|
913
927
|
}),
|
|
@@ -920,7 +934,15 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
920
934
|
...sessionAuthFragment,
|
|
921
935
|
...patAuthFragment,
|
|
922
936
|
...tenantLifecycleAuthFragment,
|
|
923
|
-
...(mfaFeature && {
|
|
937
|
+
...(mfaFeature && {
|
|
938
|
+
mfaVerifyHandler: AuthMfaHandlers.verify,
|
|
939
|
+
mfaVerifyRateLimit: createRedisLoginRateLimiter(
|
|
940
|
+
redis,
|
|
941
|
+
undefined,
|
|
942
|
+
undefined,
|
|
943
|
+
"mfa-verify",
|
|
944
|
+
),
|
|
945
|
+
}),
|
|
924
946
|
...(effectiveAuth.passwordReset && {
|
|
925
947
|
passwordReset: {
|
|
926
948
|
requestHandler: AuthHandlers.requestPasswordReset,
|
|
@@ -1066,14 +1088,17 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
1066
1088
|
// wired via a wrapper so Hono owns /api/* + extraRoutes and disk
|
|
1067
1089
|
// owns the rest. Tests use this directly; listen() wraps it in
|
|
1068
1090
|
// Bun.serve.
|
|
1069
|
-
const fetchHandler =
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1091
|
+
const fetchHandler = withSecurityHeaders(
|
|
1092
|
+
options.staticDir
|
|
1093
|
+
? buildStaticFallback(
|
|
1094
|
+
entrypoint.app.fetch.bind(entrypoint.app),
|
|
1095
|
+
options.staticDir,
|
|
1096
|
+
appSchemaJson,
|
|
1097
|
+
options.hostDispatch,
|
|
1098
|
+
)
|
|
1099
|
+
: entrypoint.app.fetch.bind(entrypoint.app),
|
|
1100
|
+
options.securityHeaders,
|
|
1101
|
+
);
|
|
1077
1102
|
|
|
1078
1103
|
// 11. Mark lifecycle ready — health/ready flips to 200 after this.
|
|
1079
1104
|
entrypoint.lifecycle.markReady();
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export type SecurityHeadersOption =
|
|
2
|
+
| false
|
|
3
|
+
| {
|
|
4
|
+
/** `Strict-Transport-Security` value, or `false` to omit.
|
|
5
|
+
* Default: `max-age=31536000; includeSubDomains`. */
|
|
6
|
+
readonly hsts?: string | false;
|
|
7
|
+
/** `X-Frame-Options` value, or `false` to omit. Default: `DENY`.
|
|
8
|
+
* Apps that must be embeddable (iframe widgets) set `false` and
|
|
9
|
+
* scope framing via a `csp` frame-ancestors directive instead. */
|
|
10
|
+
readonly frameOptions?: string | false;
|
|
11
|
+
/** `false` omits `X-Content-Type-Options: nosniff`. */
|
|
12
|
+
readonly contentTypeOptions?: false;
|
|
13
|
+
/** `Referrer-Policy` value, or `false` to omit.
|
|
14
|
+
* Default: `strict-origin-when-cross-origin`. */
|
|
15
|
+
readonly referrerPolicy?: string | false;
|
|
16
|
+
/** `Content-Security-Policy` default for ALL responses. No built-in
|
|
17
|
+
* default — a wrong CSP breaks app assets, so it stays opt-in.
|
|
18
|
+
* A per-host CSP from `hostDispatch` wins over this value. */
|
|
19
|
+
readonly csp?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const DEFAULT_HSTS = "max-age=31536000; includeSubDomains";
|
|
23
|
+
const DEFAULT_FRAME_OPTIONS = "DENY";
|
|
24
|
+
const DEFAULT_REFERRER_POLICY = "strict-origin-when-cross-origin";
|
|
25
|
+
|
|
26
|
+
export function resolveSecurityHeaders(
|
|
27
|
+
option: SecurityHeadersOption | undefined,
|
|
28
|
+
): ReadonlyArray<readonly [string, string]> {
|
|
29
|
+
if (option === false) return [];
|
|
30
|
+
const opt = option ?? {};
|
|
31
|
+
const headers: Array<readonly [string, string]> = [];
|
|
32
|
+
const hsts = opt.hsts ?? DEFAULT_HSTS;
|
|
33
|
+
if (hsts !== false) headers.push(["strict-transport-security", hsts]);
|
|
34
|
+
const frameOptions = opt.frameOptions ?? DEFAULT_FRAME_OPTIONS;
|
|
35
|
+
if (frameOptions !== false) headers.push(["x-frame-options", frameOptions]);
|
|
36
|
+
if (opt.contentTypeOptions !== false) headers.push(["x-content-type-options", "nosniff"]);
|
|
37
|
+
const referrerPolicy = opt.referrerPolicy ?? DEFAULT_REFERRER_POLICY;
|
|
38
|
+
if (referrerPolicy !== false) headers.push(["referrer-policy", referrerPolicy]);
|
|
39
|
+
if (opt.csp) headers.push(["content-security-policy", opt.csp]);
|
|
40
|
+
return headers;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Sets each header only when absent so per-response values (e.g. the
|
|
44
|
+
// per-host CSP from hostDispatch) always win over the runtime default.
|
|
45
|
+
export function withSecurityHeaders(
|
|
46
|
+
handler: (req: Request) => Response | Promise<Response>,
|
|
47
|
+
option: SecurityHeadersOption | undefined,
|
|
48
|
+
): (req: Request) => Response | Promise<Response> {
|
|
49
|
+
const defaults = resolveSecurityHeaders(option);
|
|
50
|
+
if (defaults.length === 0) return handler;
|
|
51
|
+
return async (req: Request): Promise<Response> => {
|
|
52
|
+
const res = await handler(req);
|
|
53
|
+
try {
|
|
54
|
+
for (const [name, value] of defaults) {
|
|
55
|
+
if (!res.headers.has(name)) res.headers.set(name, value);
|
|
56
|
+
}
|
|
57
|
+
return res;
|
|
58
|
+
} catch {
|
|
59
|
+
// Immutable-headers Response (e.g. proxied fetch) — re-wrap.
|
|
60
|
+
const headers = new Headers(res.headers);
|
|
61
|
+
for (const [name, value] of defaults) {
|
|
62
|
+
if (!headers.has(name)) headers.set(name, value);
|
|
63
|
+
}
|
|
64
|
+
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|