@cosmicdrift/kumiko-framework 0.48.1 → 0.51.0
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 +2 -2
- package/src/api/__tests__/origin-middleware.integration.test.ts +132 -0
- package/src/api/__tests__/origin-middleware.test.ts +191 -0
- package/src/api/anonymous-cookie.ts +1 -0
- package/src/api/api-constants.ts +9 -0
- package/src/api/auth-middleware.ts +2 -0
- package/src/api/auth-routes.ts +29 -4
- package/src/api/csrf-middleware.ts +1 -4
- package/src/api/origin-middleware.ts +98 -0
- package/src/api/server.ts +20 -0
- package/src/bun-db/__tests__/_helpers.ts +1 -0
- package/src/bun-db/query.ts +1 -0
- package/src/db/__tests__/migrate-generator.test.ts +11 -0
- package/src/db/collect-table-metas.ts +2 -1
- package/src/db/dialect.ts +3 -1
- package/src/db/migrate-generator.ts +6 -2
- package/src/db/queries/event-store.ts +1 -0
- package/src/engine/__tests__/boot-validator.test.ts +43 -0
- package/src/engine/__tests__/build-app-schema-settings-hub.test.ts +118 -0
- package/src/engine/__tests__/build-config-feature-schema.test.ts +174 -0
- package/src/engine/__tests__/config-helpers.test.ts +57 -0
- package/src/engine/boot-validator/config-deps.ts +24 -0
- package/src/engine/boot-validator/index.ts +11 -4
- package/src/engine/build-app-schema.ts +45 -0
- package/src/engine/build-config-feature-schema.ts +228 -0
- package/src/engine/config-helpers.ts +31 -0
- package/src/engine/define-handler.ts +1 -0
- package/src/engine/entity-handlers.ts +7 -1
- package/src/engine/feature-manifest.ts +6 -7
- package/src/engine/index.ts +9 -0
- package/src/engine/pipeline.ts +1 -0
- package/src/engine/qualified-name.ts +1 -0
- package/src/engine/state-machine.ts +1 -0
- package/src/engine/steps/compute.ts +1 -1
- package/src/engine/steps/return.ts +1 -0
- package/src/engine/types/config.ts +65 -1
- package/src/engine/types/index.ts +3 -0
- package/src/engine/types/screen.ts +13 -0
- package/src/env/index.ts +1 -0
- package/src/errors/write-error-info.ts +2 -0
- package/src/es-ops/__tests__/context.integration.test.ts +33 -24
- package/src/es-ops/__tests__/runner.integration.test.ts +130 -86
- package/src/es-ops/context.ts +6 -4
- package/src/event-store/event-store.ts +3 -0
- package/src/files/file-handle.ts +1 -1
- package/src/jobs/job-runner.ts +4 -0
- package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +84 -1
- package/src/migrations/index.ts +5 -0
- package/src/migrations/pending-rebuilds.ts +84 -10
- package/src/pipeline/dispatcher.ts +15 -7
- package/src/pipeline/lifecycle-pipeline.ts +4 -4
- package/src/stack/table-helpers.ts +1 -0
- package/src/time/tz-context.ts +3 -3
- package/src/utils/compare.ts +10 -0
- package/src/utils/ids.ts +1 -0
- package/src/utils/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.51.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -181,7 +181,7 @@
|
|
|
181
181
|
"zod": "^4.4.3"
|
|
182
182
|
},
|
|
183
183
|
"devDependencies": {
|
|
184
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
184
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.50.0",
|
|
185
185
|
"@types/uuid": "^11.0.0",
|
|
186
186
|
"bun-types": "^1.3.13",
|
|
187
187
|
"pino-pretty": "^13.1.3"
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// origin-middleware wired into the real buildServer pipeline. Proves that the
|
|
2
|
+
// registration fires when authConfig.allowedOrigins is set, that the guard sits
|
|
3
|
+
// BEFORE the CSRF guard (a disallowed cross-site POST surfaces as
|
|
4
|
+
// `origin_not_allowed`, not `csrf_token_mismatch`), that cookie-vs-bearer
|
|
5
|
+
// transport detection works end-to-end, and that the explicit opt-out disables
|
|
6
|
+
// the guard without breaking boot. Cookie-auth is forged with a minted JWT — no
|
|
7
|
+
// full login flow needed to exercise the guard. Passing requests hit the
|
|
8
|
+
// dispatcher and 404 on the unknown query, which positively proves no guard
|
|
9
|
+
// rejected them (guards return 403).
|
|
10
|
+
|
|
11
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
12
|
+
import { setupTestStack, type TestStack, TestUsers } from "../../stack";
|
|
13
|
+
import { AUTH_COOKIE_NAME, CSRF_COOKIE_NAME, CSRF_HEADER_NAME } from "../auth-middleware";
|
|
14
|
+
|
|
15
|
+
const ALLOWED = "https://admin.example.eu";
|
|
16
|
+
const DISALLOWED = "https://tenant.example.eu";
|
|
17
|
+
const QUERY_BODY = { type: "noop:query:noop", payload: {} };
|
|
18
|
+
|
|
19
|
+
async function errorCode(res: Response): Promise<string | undefined> {
|
|
20
|
+
const body = (await res.json().catch(() => ({}))) as { error?: { code?: string } };
|
|
21
|
+
return body.error?.code;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("origin-middleware (integration)", () => {
|
|
25
|
+
let stack: TestStack;
|
|
26
|
+
let authCookie: string;
|
|
27
|
+
|
|
28
|
+
beforeAll(async () => {
|
|
29
|
+
stack = await setupTestStack({
|
|
30
|
+
features: [],
|
|
31
|
+
authConfig: {
|
|
32
|
+
// Never dispatched here — present only so options.auth is wired and the
|
|
33
|
+
// Origin guard registers. switch-tenant is the only consumer.
|
|
34
|
+
membershipQuery: "tenant:query:memberships",
|
|
35
|
+
allowedOrigins: [ALLOWED],
|
|
36
|
+
cookieDomain: "example.eu",
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
const token = await stack.jwt.sign(TestUsers.user);
|
|
40
|
+
authCookie = `${AUTH_COOKIE_NAME}=${token}`;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterAll(async () => {
|
|
44
|
+
await stack.cleanup();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("disallowed origin + simple text/plain POST → 403 origin_not_allowed (before CSRF)", async () => {
|
|
48
|
+
const res = await stack.http.raw("POST", "/api/query", QUERY_BODY, {
|
|
49
|
+
Cookie: authCookie,
|
|
50
|
+
Origin: DISALLOWED,
|
|
51
|
+
"Content-Type": "text/plain",
|
|
52
|
+
});
|
|
53
|
+
expect(res.status).toBe(403);
|
|
54
|
+
// No CSRF token sent — proves the Origin guard runs FIRST (else this would
|
|
55
|
+
// be csrf_token_mismatch).
|
|
56
|
+
expect(await errorCode(res)).toBe("origin_not_allowed");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("allowed origin (no CSRF token) → falls through to the CSRF guard", async () => {
|
|
60
|
+
const res = await stack.http.raw("POST", "/api/query", QUERY_BODY, {
|
|
61
|
+
Cookie: authCookie,
|
|
62
|
+
Origin: ALLOWED,
|
|
63
|
+
});
|
|
64
|
+
expect(res.status).toBe(403);
|
|
65
|
+
expect(await errorCode(res)).toBe("csrf_token_mismatch");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("no Origin + valid CSRF token → reaches dispatcher (Safari same-origin POST)", async () => {
|
|
69
|
+
const csrf = "csrf-fixed-integration-token";
|
|
70
|
+
const res = await stack.http.raw("POST", "/api/query", QUERY_BODY, {
|
|
71
|
+
Cookie: `${authCookie}; ${CSRF_COOKIE_NAME}=${csrf}`,
|
|
72
|
+
[CSRF_HEADER_NAME]: csrf,
|
|
73
|
+
});
|
|
74
|
+
// Both guards passed → the dispatcher 404s the unknown query. A positive
|
|
75
|
+
// assertion proves no guard (403) rejected the request.
|
|
76
|
+
expect(res.status).toBe(404);
|
|
77
|
+
expect(await errorCode(res)).toBe("not_found");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("no Origin + Sec-Fetch-Site: cross-site → 403 origin_not_allowed", async () => {
|
|
81
|
+
const res = await stack.http.raw("POST", "/api/query", QUERY_BODY, {
|
|
82
|
+
Cookie: authCookie,
|
|
83
|
+
"Sec-Fetch-Site": "cross-site",
|
|
84
|
+
});
|
|
85
|
+
expect(res.status).toBe(403);
|
|
86
|
+
expect(await errorCode(res)).toBe("origin_not_allowed");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("bearer transport + disallowed origin → skips both guards, reaches dispatcher", async () => {
|
|
90
|
+
const token = await stack.jwt.sign(TestUsers.user);
|
|
91
|
+
const res = await stack.http.raw("POST", "/api/query", QUERY_BODY, {
|
|
92
|
+
Authorization: `Bearer ${token}`,
|
|
93
|
+
Origin: DISALLOWED,
|
|
94
|
+
});
|
|
95
|
+
expect(res.status).toBe(404);
|
|
96
|
+
expect(await errorCode(res)).toBe("not_found");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("origin-middleware opt-out (unsafeSkipOriginCheck)", () => {
|
|
101
|
+
let stack: TestStack;
|
|
102
|
+
let authCookie: string;
|
|
103
|
+
|
|
104
|
+
beforeAll(async () => {
|
|
105
|
+
// cookieDomain set WITHOUT allowedOrigins would normally fail-closed; the
|
|
106
|
+
// explicit opt-out must let it boot AND leave the guard unregistered.
|
|
107
|
+
stack = await setupTestStack({
|
|
108
|
+
features: [],
|
|
109
|
+
authConfig: {
|
|
110
|
+
membershipQuery: "tenant:query:memberships",
|
|
111
|
+
cookieDomain: "example.eu",
|
|
112
|
+
unsafeSkipOriginCheck: true,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
const token = await stack.jwt.sign(TestUsers.user);
|
|
116
|
+
authCookie = `${AUTH_COOKIE_NAME}=${token}`;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
afterAll(async () => {
|
|
120
|
+
await stack.cleanup();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("guard not registered → disallowed origin falls through to CSRF, not origin-blocked", async () => {
|
|
124
|
+
const res = await stack.http.raw("POST", "/api/query", QUERY_BODY, {
|
|
125
|
+
Cookie: authCookie,
|
|
126
|
+
Origin: DISALLOWED,
|
|
127
|
+
});
|
|
128
|
+
expect(res.status).toBe(403);
|
|
129
|
+
// CSRF fires (no token) — NOT the origin guard. Proves the opt-out disabled it.
|
|
130
|
+
expect(await errorCode(res)).toBe("csrf_token_mismatch");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// origin-middleware: server-side Origin-allowlist guard layered behind
|
|
2
|
+
// authMiddleware. Covers the production-relevant paths: cookie + state-
|
|
3
|
+
// changing (allowed/disallowed/simple-request/opaque, all four methods),
|
|
4
|
+
// cookie + safe method, bearer transport, the no-Origin Sec-Fetch-Site
|
|
5
|
+
// fallback, and the fail-closed boot check.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import { Hono } from "hono";
|
|
9
|
+
import { TestUsers } from "../../stack";
|
|
10
|
+
import { AUTH_COOKIE_NAME, authMiddleware } from "../auth-middleware";
|
|
11
|
+
import { createJwtHelper } from "../jwt";
|
|
12
|
+
import {
|
|
13
|
+
assertOriginGuardConfig,
|
|
14
|
+
isOriginAllowed,
|
|
15
|
+
normalizeOrigin,
|
|
16
|
+
originMiddleware,
|
|
17
|
+
} from "../origin-middleware";
|
|
18
|
+
|
|
19
|
+
const JWT_SECRET = "origin-middleware-test-secret-min-32-characters-long";
|
|
20
|
+
const ALLOWED = "https://admin.example.eu";
|
|
21
|
+
const DISALLOWED = "https://tenant.example.eu";
|
|
22
|
+
|
|
23
|
+
async function buildApp(): Promise<{ app: Hono; token: string }> {
|
|
24
|
+
const jwt = createJwtHelper(JWT_SECRET);
|
|
25
|
+
const token = await jwt.sign(TestUsers.user);
|
|
26
|
+
const app = new Hono();
|
|
27
|
+
app.use("/api/*", authMiddleware(jwt));
|
|
28
|
+
app.use("/api/*", originMiddleware([ALLOWED]));
|
|
29
|
+
app.get("/api/ping", (c) => c.json({ ok: true }));
|
|
30
|
+
app.post("/api/write", (c) => c.json({ ok: true }));
|
|
31
|
+
return { app, token };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("normalizeOrigin", () => {
|
|
35
|
+
test("lowercases and strips trailing slash + whitespace", () => {
|
|
36
|
+
expect(normalizeOrigin("HTTPS://Admin.Example.EU/")).toBe("https://admin.example.eu");
|
|
37
|
+
expect(normalizeOrigin(" https://admin.example.eu ")).toBe("https://admin.example.eu");
|
|
38
|
+
expect(normalizeOrigin("https://admin.example.eu")).toBe("https://admin.example.eu");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("isOriginAllowed", () => {
|
|
43
|
+
const allowlist = new Set([normalizeOrigin(ALLOWED)]);
|
|
44
|
+
test("matches normalized entry regardless of case/trailing slash", () => {
|
|
45
|
+
expect(isOriginAllowed("https://admin.example.eu", allowlist)).toBe(true);
|
|
46
|
+
expect(isOriginAllowed("HTTPS://ADMIN.EXAMPLE.EU/", allowlist)).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
test("rejects a non-listed origin and the opaque 'null' origin", () => {
|
|
49
|
+
expect(isOriginAllowed(DISALLOWED, allowlist)).toBe(false);
|
|
50
|
+
expect(isOriginAllowed("null", allowlist)).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("assertOriginGuardConfig", () => {
|
|
55
|
+
test("throws when cookieDomain is set without allowedOrigins or opt-out", () => {
|
|
56
|
+
expect(() => assertOriginGuardConfig({ cookieDomain: "example.eu" })).toThrow(/allowedOrigins/);
|
|
57
|
+
});
|
|
58
|
+
test("throws when allowedOrigins is an empty array", () => {
|
|
59
|
+
expect(() =>
|
|
60
|
+
assertOriginGuardConfig({ cookieDomain: "example.eu", allowedOrigins: [] }),
|
|
61
|
+
).toThrow();
|
|
62
|
+
});
|
|
63
|
+
test("passes when allowedOrigins is set", () => {
|
|
64
|
+
expect(() =>
|
|
65
|
+
assertOriginGuardConfig({ cookieDomain: "example.eu", allowedOrigins: [ALLOWED] }),
|
|
66
|
+
).not.toThrow();
|
|
67
|
+
});
|
|
68
|
+
test("passes when explicitly opted out", () => {
|
|
69
|
+
expect(() =>
|
|
70
|
+
assertOriginGuardConfig({ cookieDomain: "example.eu", unsafeSkipOriginCheck: true }),
|
|
71
|
+
).not.toThrow();
|
|
72
|
+
});
|
|
73
|
+
test("passes when no cookieDomain (host-only cookie) or no auth at all", () => {
|
|
74
|
+
expect(() => assertOriginGuardConfig({})).not.toThrow();
|
|
75
|
+
expect(() => assertOriginGuardConfig(undefined)).not.toThrow();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("originMiddleware", () => {
|
|
80
|
+
test("bearer transport skips the check even on a disallowed-origin POST", async () => {
|
|
81
|
+
const { app, token } = await buildApp();
|
|
82
|
+
const res = await app.request("/api/write", {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: { Authorization: `Bearer ${token}`, Origin: DISALLOWED },
|
|
85
|
+
});
|
|
86
|
+
expect(res.status).toBe(200);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("cookie transport + GET → no check (safe method)", async () => {
|
|
90
|
+
const { app, token } = await buildApp();
|
|
91
|
+
const res = await app.request("/api/ping", {
|
|
92
|
+
headers: { Cookie: `${AUTH_COOKIE_NAME}=${token}`, Origin: DISALLOWED },
|
|
93
|
+
});
|
|
94
|
+
expect(res.status).toBe(200);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("cookie transport + POST + allowed origin → ok", async () => {
|
|
98
|
+
const { app, token } = await buildApp();
|
|
99
|
+
const res = await app.request("/api/write", {
|
|
100
|
+
method: "POST",
|
|
101
|
+
headers: { Cookie: `${AUTH_COOKIE_NAME}=${token}`, Origin: ALLOWED },
|
|
102
|
+
});
|
|
103
|
+
expect(res.status).toBe(200);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("cookie transport + POST + disallowed origin → 403 origin_not_allowed", async () => {
|
|
107
|
+
const { app, token } = await buildApp();
|
|
108
|
+
const res = await app.request("/api/write", {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: { Cookie: `${AUTH_COOKIE_NAME}=${token}`, Origin: DISALLOWED },
|
|
111
|
+
});
|
|
112
|
+
expect(res.status).toBe(403);
|
|
113
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
114
|
+
expect(body.error.code).toBe("origin_not_allowed");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// The guard runs as /api/* middleware before routing, so a disallowed-origin
|
|
118
|
+
// request is rejected for every state-changing method even without a route.
|
|
119
|
+
test.each([
|
|
120
|
+
"PUT",
|
|
121
|
+
"PATCH",
|
|
122
|
+
"DELETE",
|
|
123
|
+
])("cookie transport + %s + disallowed origin → 403 (every state-changing method)", async (method) => {
|
|
124
|
+
const { app, token } = await buildApp();
|
|
125
|
+
const res = await app.request("/api/write", {
|
|
126
|
+
method,
|
|
127
|
+
headers: { Cookie: `${AUTH_COOKIE_NAME}=${token}`, Origin: DISALLOWED },
|
|
128
|
+
});
|
|
129
|
+
expect(res.status).toBe(403);
|
|
130
|
+
expect(((await res.json()) as { error: { code: string } }).error.code).toBe(
|
|
131
|
+
"origin_not_allowed",
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("disallowed origin is blocked even as a simple text/plain request", async () => {
|
|
136
|
+
// The real vector: a `text/plain` POST skips the CORS preflight and reaches
|
|
137
|
+
// the server, where only the Origin check stands between it and the handler.
|
|
138
|
+
const { app, token } = await buildApp();
|
|
139
|
+
const res = await app.request("/api/write", {
|
|
140
|
+
method: "POST",
|
|
141
|
+
headers: {
|
|
142
|
+
Cookie: `${AUTH_COOKIE_NAME}=${token}`,
|
|
143
|
+
Origin: DISALLOWED,
|
|
144
|
+
"Content-Type": "text/plain",
|
|
145
|
+
},
|
|
146
|
+
body: "type=x",
|
|
147
|
+
});
|
|
148
|
+
expect(res.status).toBe(403);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("cookie transport + POST + no Origin → passes (CSRF token is the next layer)", async () => {
|
|
152
|
+
const { app, token } = await buildApp();
|
|
153
|
+
const res = await app.request("/api/write", {
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: { Cookie: `${AUTH_COOKIE_NAME}=${token}` },
|
|
156
|
+
});
|
|
157
|
+
expect(res.status).toBe(200);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("no Origin + Sec-Fetch-Site: same-origin → passes", async () => {
|
|
161
|
+
const { app, token } = await buildApp();
|
|
162
|
+
const res = await app.request("/api/write", {
|
|
163
|
+
method: "POST",
|
|
164
|
+
headers: { Cookie: `${AUTH_COOKIE_NAME}=${token}`, "Sec-Fetch-Site": "same-origin" },
|
|
165
|
+
});
|
|
166
|
+
expect(res.status).toBe(200);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// same-site is passed through by design (the CSRF token is the next layer);
|
|
170
|
+
// the realistic same-site XSS attack carries an Origin header and is rejected
|
|
171
|
+
// by the allowlist branch before this fallback is reached.
|
|
172
|
+
test("no Origin + Sec-Fetch-Site: same-site → passes (intentional, CSRF is next layer)", async () => {
|
|
173
|
+
const { app, token } = await buildApp();
|
|
174
|
+
const res = await app.request("/api/write", {
|
|
175
|
+
method: "POST",
|
|
176
|
+
headers: { Cookie: `${AUTH_COOKIE_NAME}=${token}`, "Sec-Fetch-Site": "same-site" },
|
|
177
|
+
});
|
|
178
|
+
expect(res.status).toBe(200);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("no Origin + Sec-Fetch-Site: cross-site → 403", async () => {
|
|
182
|
+
const { app, token } = await buildApp();
|
|
183
|
+
const res = await app.request("/api/write", {
|
|
184
|
+
method: "POST",
|
|
185
|
+
headers: { Cookie: `${AUTH_COOKIE_NAME}=${token}`, "Sec-Fetch-Site": "cross-site" },
|
|
186
|
+
});
|
|
187
|
+
expect(res.status).toBe(403);
|
|
188
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
189
|
+
expect(body.error.code).toBe("origin_not_allowed");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -55,6 +55,7 @@ export function setTenantCookie(
|
|
|
55
55
|
// Removes the kumiko_tenant cookie. Use on switch-tenant flows or when
|
|
56
56
|
// the resolver no longer recognises the visitor's tenant — leaving a
|
|
57
57
|
// stale cookie behind would keep them pointed at a deleted tenant.
|
|
58
|
+
// @wrapper-known semantic-alias
|
|
58
59
|
export function deleteTenantCookie(c: Context): void {
|
|
59
60
|
deleteCookie(c, TENANT_COOKIE_NAME, { path: "/" });
|
|
60
61
|
}
|
package/src/api/api-constants.ts
CHANGED
|
@@ -53,6 +53,15 @@ export const PUBLIC_API_PATHS: ReadonlySet<string> = new Set([
|
|
|
53
53
|
`/api${Routes.version}`,
|
|
54
54
|
]);
|
|
55
55
|
|
|
56
|
+
// Methods that can mutate server state. GET/HEAD/OPTIONS are safe under
|
|
57
|
+
// CORS + SameSite-cookie semantics and skip the CSRF / Origin guards entirely.
|
|
58
|
+
export const STATE_CHANGING_METHODS: ReadonlySet<string> = new Set([
|
|
59
|
+
"POST",
|
|
60
|
+
"PUT",
|
|
61
|
+
"PATCH",
|
|
62
|
+
"DELETE",
|
|
63
|
+
]);
|
|
64
|
+
|
|
56
65
|
// Tenant transports for unauthenticated callers on public endpoints. JWT
|
|
57
66
|
// users carry tenantId in the signed token; anonymous callers must declare
|
|
58
67
|
// the tenant out-of-band — header for SPA/mobile, cookie for browser-direct
|
|
@@ -109,6 +109,7 @@ type MiddlewareRejectCode =
|
|
|
109
109
|
| "tenant_mismatch"
|
|
110
110
|
| "invalid_tenant_format";
|
|
111
111
|
|
|
112
|
+
// @wrapper-known error-helper
|
|
112
113
|
function middlewareReject(
|
|
113
114
|
c: Context,
|
|
114
115
|
opts: {
|
|
@@ -133,6 +134,7 @@ function middlewareReject(
|
|
|
133
134
|
);
|
|
134
135
|
}
|
|
135
136
|
|
|
137
|
+
// @wrapper-known error-helper
|
|
136
138
|
function sessionInvalid(c: Context, reason: AuthSessionStatus | "no_sid"): Response {
|
|
137
139
|
return middlewareReject(c, {
|
|
138
140
|
code: "session_invalid",
|
package/src/api/auth-routes.ts
CHANGED
|
@@ -257,8 +257,33 @@ export type AuthRoutesConfig = {
|
|
|
257
257
|
// live on DIFFERENT subdomains (login on apex, app on admin.) — the
|
|
258
258
|
// browser then sends the session to every subdomain. Careful: that
|
|
259
259
|
// includes ALL subdomains (tenant pages, previews); widen the scope
|
|
260
|
-
// only when the cross-subdomain session is actually required
|
|
260
|
+
// only when the cross-subdomain session is actually required — and pair
|
|
261
|
+
// it with `allowedOrigins` so the server-side Origin guard stays on (a
|
|
262
|
+
// wide cookie makes the JS-readable csrf cookie reachable from every
|
|
263
|
+
// subdomain, weakening the double-submit defence).
|
|
261
264
|
cookieDomain?: string;
|
|
265
|
+
// Origin-allowlist for the server-side CSRF-hardening guard (origin-
|
|
266
|
+
// middleware). When non-empty, every cookie-authenticated, state-changing
|
|
267
|
+
// /api request must carry an `Origin` header that exact-matches one of
|
|
268
|
+
// these entries (scheme+host+optional port, e.g. "https://example.eu", no
|
|
269
|
+
// trailing slash, no wildcards). Requests without an Origin fall back to
|
|
270
|
+
// Sec-Fetch-Site and then to the CSRF token — the guard is defense-in-
|
|
271
|
+
// depth, not a replacement.
|
|
272
|
+
//
|
|
273
|
+
// List ONLY the trusted entry hosts: the apex and the admin host. Do NOT
|
|
274
|
+
// list tenant/public subdomains — with a wide `cookieDomain` they share
|
|
275
|
+
// the session cookie, so an XSS there is exactly the threat this blocks.
|
|
276
|
+
// Empty/unset (default) disables the guard; required only when
|
|
277
|
+
// `cookieDomain` widens the cookie across subdomains.
|
|
278
|
+
allowedOrigins?: readonly string[];
|
|
279
|
+
// Explicit opt-out of the fail-closed Origin guard. When `cookieDomain` is
|
|
280
|
+
// set the framework REFUSES TO BOOT unless `allowedOrigins` is configured — a
|
|
281
|
+
// wide cookie without an Origin check is the unguarded-subdomain-XSS footgun.
|
|
282
|
+
// Set this true ONLY for a single-host deployment that shares no untrusted
|
|
283
|
+
// subdomains and genuinely needs the wide cookie anyway; you accept that any
|
|
284
|
+
// subdomain can then forge authenticated state-changing requests. Prefer
|
|
285
|
+
// setting `allowedOrigins`.
|
|
286
|
+
unsafeSkipOriginCheck?: boolean;
|
|
262
287
|
};
|
|
263
288
|
|
|
264
289
|
export type PasswordResetConfig = {
|
|
@@ -527,7 +552,7 @@ export function createAuthRoutes(
|
|
|
527
552
|
successKind: "reset-requested",
|
|
528
553
|
appBaseUrl: pr.appResetUrl,
|
|
529
554
|
sendEmail: ({ email, url, expiresAt }) =>
|
|
530
|
-
pr.sendResetEmail({ email, resetUrl: url, expiresAt }),
|
|
555
|
+
pr.sendResetEmail({ email, resetUrl: url, expiresAt }), // @wrapper-known semantic-alias
|
|
531
556
|
});
|
|
532
557
|
registerTokenConfirmRoute({
|
|
533
558
|
api,
|
|
@@ -549,7 +574,7 @@ export function createAuthRoutes(
|
|
|
549
574
|
successKind: "verification-requested",
|
|
550
575
|
appBaseUrl: ev.appVerifyUrl,
|
|
551
576
|
sendEmail: ({ email, url, expiresAt }) =>
|
|
552
|
-
ev.sendVerificationEmail({ email, verificationUrl: url, expiresAt }),
|
|
577
|
+
ev.sendVerificationEmail({ email, verificationUrl: url, expiresAt }), // @wrapper-known semantic-alias
|
|
553
578
|
});
|
|
554
579
|
registerTokenConfirmRoute({
|
|
555
580
|
api,
|
|
@@ -574,7 +599,7 @@ export function createAuthRoutes(
|
|
|
574
599
|
successKind: "signup-requested",
|
|
575
600
|
appBaseUrl: sg.appActivationUrl,
|
|
576
601
|
sendEmail: ({ email, url, expiresAt }) =>
|
|
577
|
-
sg.sendActivationEmail({ email, activationUrl: url, expiresAt }),
|
|
602
|
+
sg.sendActivationEmail({ email, activationUrl: url, expiresAt }), // @wrapper-known semantic-alias
|
|
578
603
|
});
|
|
579
604
|
|
|
580
605
|
api.post(Routes.authSignupConfirm, async (c) => {
|
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
import { timingSafeEqual } from "node:crypto";
|
|
2
2
|
import type { Context, Next } from "hono";
|
|
3
3
|
import { getCookie } from "hono/cookie";
|
|
4
|
+
import { STATE_CHANGING_METHODS } from "./api-constants";
|
|
4
5
|
import { CSRF_COOKIE_NAME, CSRF_HEADER_NAME, getAuthTransport } from "./auth-middleware";
|
|
5
6
|
|
|
6
|
-
// Methods that can mutate server state. GET/HEAD/OPTIONS are safe under
|
|
7
|
-
// CORS + SameSite-cookie semantics and skip the CSRF check entirely.
|
|
8
|
-
const STATE_CHANGING_METHODS: ReadonlySet<string> = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
9
|
-
|
|
10
7
|
// Constant-time byte compare. `a !== b` short-circuits at the first
|
|
11
8
|
// differing byte and leaks the common prefix length to anyone who can
|
|
12
9
|
// time requests — in principle exploitable against sufficiently small
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { Context, Next } from "hono";
|
|
2
|
+
import { STATE_CHANGING_METHODS } from "./api-constants";
|
|
3
|
+
import { getAuthTransport } from "./auth-middleware";
|
|
4
|
+
|
|
5
|
+
// Canonical comparable form for an Origin / allowlist entry: lowercased, no
|
|
6
|
+
// trailing slash. Origin headers are scheme+host(+port) without a path, but
|
|
7
|
+
// config entries are hand-written and may carry a stray slash or mixed case.
|
|
8
|
+
export function normalizeOrigin(value: string): string {
|
|
9
|
+
return value.trim().toLowerCase().replace(/\/+$/, "");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Exact-match against a pre-normalized allowlist. No wildcards by design:
|
|
13
|
+
// `*.example.eu` would re-admit the tenant subdomains this guard exists to
|
|
14
|
+
// shut out.
|
|
15
|
+
export function isOriginAllowed(origin: string, normalizedAllowlist: ReadonlySet<string>): boolean {
|
|
16
|
+
return normalizedAllowlist.has(normalizeOrigin(origin));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function rejectOrigin(c: Context): Response {
|
|
20
|
+
return c.json(
|
|
21
|
+
{
|
|
22
|
+
error: {
|
|
23
|
+
code: "origin_not_allowed",
|
|
24
|
+
httpStatus: 403,
|
|
25
|
+
message: "request origin is not allowed",
|
|
26
|
+
i18nKey: "auth.errors.originNotAllowed",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
403,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Server-side Origin-allowlist guard — an additional CSRF-hardening layer on
|
|
34
|
+
// top of the double-submit token (csrf-middleware) for deployments that widen
|
|
35
|
+
// the auth cookie across subdomains via AuthRoutesConfig.cookieDomain. A wide
|
|
36
|
+
// cookie means an XSS on ANY subdomain (e.g. a tenant status page) can read
|
|
37
|
+
// the JS-readable kumiko_csrf cookie and forge an authenticated state-changing
|
|
38
|
+
// request. Pinning the request Origin to the apex + admin host (never tenant
|
|
39
|
+
// subdomains) closes that vector even for "simple requests" (text/plain or
|
|
40
|
+
// form-encoded) that skip the CORS preflight and reach the server.
|
|
41
|
+
//
|
|
42
|
+
// Runs AFTER authMiddleware (reads the authTransport flag) with the same scope
|
|
43
|
+
// as the CSRF guard: only cookie-authenticated, state-changing requests.
|
|
44
|
+
// Bearer-auth requests skip — browsers cannot set Authorization cross-origin,
|
|
45
|
+
// and native clients send no/foreign Origin, so guarding them would be a
|
|
46
|
+
// false-positive with no CSRF vector to defend.
|
|
47
|
+
export function originMiddleware(allowedOrigins: readonly string[]) {
|
|
48
|
+
const allowlist: ReadonlySet<string> = new Set(allowedOrigins.map(normalizeOrigin));
|
|
49
|
+
return async (c: Context, next: Next) => {
|
|
50
|
+
const transport = getAuthTransport(c);
|
|
51
|
+
if (transport !== "cookie") return next();
|
|
52
|
+
if (!STATE_CHANGING_METHODS.has(c.req.method)) return next();
|
|
53
|
+
|
|
54
|
+
const origin = c.req.header("origin");
|
|
55
|
+
if (origin !== undefined) {
|
|
56
|
+
if (isOriginAllowed(origin, allowlist)) return next();
|
|
57
|
+
return rejectOrigin(c);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// No Origin header — older browsers, and some same-origin POSTs in Safari.
|
|
61
|
+
// Fall back to the Fetch-Metadata Sec-Fetch-Site signal: only an explicit
|
|
62
|
+
// cross-site marker is blocked here (it's a relation, not an origin, so it
|
|
63
|
+
// can't be matched against the allowlist). Everything else (same-site,
|
|
64
|
+
// same-origin, none, absent) falls through to the CSRF token. Note: the
|
|
65
|
+
// CSRF token alone does NOT stop a same-site subdomain XSS — it can read
|
|
66
|
+
// the wide cookie — but that attack uses fetch/XHR, which always sends an
|
|
67
|
+
// Origin header and is already rejected by the allowlist branch above. The
|
|
68
|
+
// residual is only the no-Origin-yet-Sec-Fetch-Site combo, which no current
|
|
69
|
+
// browser emits for state-changing requests.
|
|
70
|
+
if (c.req.header("sec-fetch-site") === "cross-site") return rejectOrigin(c);
|
|
71
|
+
|
|
72
|
+
return next();
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Fail-closed boot check: a wide cookieDomain shares the JS-readable
|
|
77
|
+
// kumiko_csrf cookie across every subdomain, so a subdomain XSS can read it and
|
|
78
|
+
// defeat the double-submit CSRF check. Without an Origin allowlist there is no
|
|
79
|
+
// server-side barrier left — refuse to boot in that configuration unless the
|
|
80
|
+
// operator opts out explicitly. Called once from buildServer.
|
|
81
|
+
export function assertOriginGuardConfig(
|
|
82
|
+
auth:
|
|
83
|
+
| { cookieDomain?: string; allowedOrigins?: readonly string[]; unsafeSkipOriginCheck?: boolean }
|
|
84
|
+
| undefined,
|
|
85
|
+
): void {
|
|
86
|
+
const widensCookieAcrossSubdomains = Boolean(auth?.cookieDomain);
|
|
87
|
+
const hasAllowlist = (auth?.allowedOrigins?.length ?? 0) > 0;
|
|
88
|
+
const optedOut = auth?.unsafeSkipOriginCheck === true;
|
|
89
|
+
if (widensCookieAcrossSubdomains && !hasAllowlist && !optedOut) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
"[kumiko:boot] auth.cookieDomain widens the session cookie across subdomains, but " +
|
|
92
|
+
"auth.allowedOrigins is empty — the JS-readable kumiko_csrf cookie would be reachable " +
|
|
93
|
+
"from every subdomain (e.g. tenant pages) with no server-side Origin check, defeating " +
|
|
94
|
+
"CSRF protection. Set auth.allowedOrigins to the apex + admin host, or set " +
|
|
95
|
+
"auth.unsafeSkipOriginCheck: true to accept the risk explicitly.",
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
package/src/api/server.ts
CHANGED
|
@@ -49,6 +49,7 @@ import { type AuthRoutesConfig, createAuthRoutes } from "./auth-routes";
|
|
|
49
49
|
import { csrfMiddleware } from "./csrf-middleware";
|
|
50
50
|
import { createJwtHelper, type JwtHelper } from "./jwt";
|
|
51
51
|
import { observabilityMiddleware } from "./observability-middleware";
|
|
52
|
+
import { assertOriginGuardConfig, originMiddleware } from "./origin-middleware";
|
|
52
53
|
import { requestIdMiddleware } from "./request-id-middleware";
|
|
53
54
|
import {
|
|
54
55
|
DEFAULT_MAX_REQUEST_BYTES,
|
|
@@ -543,6 +544,24 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
543
544
|
return jwtGuard(c, next);
|
|
544
545
|
});
|
|
545
546
|
|
|
547
|
+
// Origin-allowlist guard — additional CSRF-hardening for deployments that
|
|
548
|
+
// widen the auth cookie across subdomains (auth.cookieDomain). Registered
|
|
549
|
+
// only when allowedOrigins is non-empty, with the same /api/* + public-skip
|
|
550
|
+
// scope as the CSRF guard and BEFORE it, so a disallowed cross-site POST
|
|
551
|
+
// surfaces as `origin_not_allowed` rather than `csrf_token_mismatch`. Fails
|
|
552
|
+
// closed (assertOriginGuardConfig throws) when a wide cookieDomain is set
|
|
553
|
+
// without an allowlist and without an explicit opt-out — that config is the
|
|
554
|
+
// unguarded-subdomain-XSS footgun, not a warn-and-continue case.
|
|
555
|
+
assertOriginGuardConfig(options.auth);
|
|
556
|
+
const allowedOrigins = options.auth?.allowedOrigins;
|
|
557
|
+
if (allowedOrigins && allowedOrigins.length > 0) {
|
|
558
|
+
const originGuard = originMiddleware(allowedOrigins);
|
|
559
|
+
app.use("/api/*", async (c, next) => {
|
|
560
|
+
if (PUBLIC_API_PATHS.has(c.req.path)) return next();
|
|
561
|
+
return originGuard(c, next);
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
546
565
|
// Double-submit CSRF guard — runs only on cookie-authenticated,
|
|
547
566
|
// state-changing requests (POST/PUT/PATCH/DELETE). The guard reads the
|
|
548
567
|
// authTransport flag set by authMiddleware, so public paths (no auth)
|
|
@@ -584,6 +603,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
584
603
|
// Auth-Pfad wie ein echter HTTP-Call).
|
|
585
604
|
for (const feature of options.registry.features.values()) {
|
|
586
605
|
for (const route of Object.values(feature.httpRoutes)) {
|
|
606
|
+
// @wrapper-known semantic-alias
|
|
587
607
|
const honoHandler = async (c: import("hono").Context): Promise<Response> =>
|
|
588
608
|
route.handler(c, { app });
|
|
589
609
|
switch (route.method) {
|
|
@@ -24,6 +24,7 @@ let dbInstance: { db: unknown; close: () => Promise<void> } | undefined;
|
|
|
24
24
|
export async function getDb(): Promise<unknown> {
|
|
25
25
|
return ensureDb();
|
|
26
26
|
}
|
|
27
|
+
// @wrapper-known test-helper
|
|
27
28
|
export async function ensureDb(): Promise<unknown> {
|
|
28
29
|
if (!dbInstance) {
|
|
29
30
|
dbInstance = await createConnection(DATABASE_URL, { maxConnections: 4 });
|
package/src/bun-db/query.ts
CHANGED
|
@@ -153,6 +153,7 @@ function tenantDbDelegate(db: unknown): TenantDbDelegate | undefined {
|
|
|
153
153
|
// passing a TenantDb here would silently run unscoped against `.raw`, bypassing
|
|
154
154
|
// the tenant filter. Fail loudly so the caller picks `ctx.db.<method>` (scoped)
|
|
155
155
|
// or `ctx.db.raw` (explicit cross-tenant) on purpose.
|
|
156
|
+
// @wrapper-known semantic-alias
|
|
156
157
|
function assertNotTenantScoped(db: unknown, fnName: string): void {
|
|
157
158
|
if (tenantDbDelegate(db) !== undefined) {
|
|
158
159
|
throw new Error(
|
|
@@ -29,6 +29,17 @@ describe("snapshotFromMetas", () => {
|
|
|
29
29
|
expect(snap.tables.map((t) => t.tableName)).toEqual(["apples", "zebras"]);
|
|
30
30
|
expect(snap.version).toBe(1);
|
|
31
31
|
});
|
|
32
|
+
|
|
33
|
+
test("sorts by codepoint, not locale — deterministic across ICU locales (#367)", () => {
|
|
34
|
+
// localeCompare orders case-insensitively ("apple" < "Zebra"); codepoint
|
|
35
|
+
// puts uppercase (U+005A) before lowercase (U+0061) → "Zebra" < "apple".
|
|
36
|
+
// The snapshot JSON is byte-compared and the order carries into the
|
|
37
|
+
// generated migration SQL, so a revert to localeCompare would reorder the
|
|
38
|
+
// committed bytes depending on the runner's ICU locale. Fails the moment
|
|
39
|
+
// anyone swaps compareByCodepoint back to localeCompare.
|
|
40
|
+
const snap = snapshotFromMetas([meta("apple"), meta("Zebra")]);
|
|
41
|
+
expect(snap.tables.map((t) => t.tableName)).toEqual(["Zebra", "apple"]);
|
|
42
|
+
});
|
|
32
43
|
});
|
|
33
44
|
|
|
34
45
|
describe("diffSnapshots", () => {
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// crashte (#255).
|
|
8
8
|
|
|
9
9
|
import type { FeatureDefinition } from "../engine/types";
|
|
10
|
+
import { compareByCodepoint } from "../utils";
|
|
10
11
|
import {
|
|
11
12
|
assertBackingTableSuperset,
|
|
12
13
|
buildEntityTableMeta,
|
|
@@ -21,7 +22,7 @@ function canonicalColumnsKey(meta: EntityTableMeta): string {
|
|
|
21
22
|
// (buildEntityTable ohne relations) trägt legitim weniger FK-Indexes
|
|
22
23
|
// als das Entity-Meta derselben Tabelle.
|
|
23
24
|
return [...meta.columns]
|
|
24
|
-
.sort((a, b) => a.name
|
|
25
|
+
.sort((a, b) => compareByCodepoint(a.name, b.name))
|
|
25
26
|
.map(
|
|
26
27
|
(c) =>
|
|
27
28
|
`${c.name}|${c.pgType}|${c.notNull}|${c.defaultSql ?? ""}|${c.primaryKey ?? false}|${c.identity ?? false}|${c.bigintJsMode ?? ""}`,
|