create-ailk 0.11.1 → 0.11.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/dist/content-payloads.js +2 -2
- package/dist/module-architecture.js +11 -0
- package/package.json +1 -1
- package/templates/.env.example +13 -3
- package/templates/apps/api/.env.example +5 -0
- package/templates/apps/api/src/__tests__/cors.test.ts +91 -10
- package/templates/apps/api/src/__tests__/server.test.ts +5 -1
- package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +135 -0
- package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +238 -0
- package/templates/apps/api/src/config/index.ts +136 -0
- package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +2 -2
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +142 -0
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +1 -1
- package/templates/apps/api/src/middleware/auth.ts +19 -21
- package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +1 -1
- package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +1 -1
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +168 -39
- package/templates/apps/api/src/routes/checkout/sessions.ts +20 -34
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +1 -1
- package/templates/apps/api/src/server.ts +21 -16
- package/templates/apps/web/app/[locale]/layout.tsx +57 -56
- package/templates/apps/web/auth.ts +73 -13
- package/templates/apps/web/components/sign-out-trigger.tsx +3 -2
- package/templates/apps/web/components/signed-in-nav.tsx +58 -0
|
@@ -17,6 +17,16 @@ export interface Config {
|
|
|
17
17
|
PORT: number;
|
|
18
18
|
FRONTEND_URL: string;
|
|
19
19
|
|
|
20
|
+
// The browser origins this API trusts (#7395) — the one list CORS, the
|
|
21
|
+
// cookie-forwarded write check (middleware/auth.ts) and the checkout
|
|
22
|
+
// redirect lock (routes/checkout/sessions.ts) all read. Resolved once, at
|
|
23
|
+
// startup, by resolveTrustedOrigins() below from FRONTEND_URL's origin plus
|
|
24
|
+
// the opt-in AILK_TRUSTED_ORIGINS (a comma-separated list of exact origins,
|
|
25
|
+
// for a hub that serves its Admin app and spoke sites). Frozen; no request
|
|
26
|
+
// input can add to it, and no entry can be `*` or a wildcard host.
|
|
27
|
+
TRUSTED_ORIGINS: readonly string[];
|
|
28
|
+
AILK_TRUSTED_ORIGINS?: string;
|
|
29
|
+
|
|
20
30
|
// Local Database (for auth: User, Session, ApiToken, etc.). Required in
|
|
21
31
|
// production; unset elsewhere starts the API database-less (#6482).
|
|
22
32
|
DATABASE_URL?: string;
|
|
@@ -94,6 +104,103 @@ export interface Config {
|
|
|
94
104
|
CAL_WEBHOOK_SECRET?: string;
|
|
95
105
|
}
|
|
96
106
|
|
|
107
|
+
/** Why an AILK_TRUSTED_ORIGINS entry was refused (#7395 D2). */
|
|
108
|
+
export type TrustedOriginRefusal =
|
|
109
|
+
| 'wildcard'
|
|
110
|
+
| 'unparseable'
|
|
111
|
+
| 'scheme'
|
|
112
|
+
| 'not-canonical'
|
|
113
|
+
| 'trailing-dot';
|
|
114
|
+
|
|
115
|
+
/** The zero-config development origin (`apps/web`'s `pnpm dev`). */
|
|
116
|
+
const DEV_FRONTEND_ORIGIN = 'http://localhost:3000';
|
|
117
|
+
|
|
118
|
+
/** `FRONTEND_URL`'s origin, or null when it is unusable as one (#7395 D2). */
|
|
119
|
+
function primaryOrigin(raw: string): string | null {
|
|
120
|
+
// A comma means a list was put in the wrong variable:
|
|
121
|
+
// new URL('https://a.example,https://b.example').origin is 'https://a.example,https'.
|
|
122
|
+
if (raw.includes('*') || raw.includes(',')) return null;
|
|
123
|
+
let parsed: URL;
|
|
124
|
+
try {
|
|
125
|
+
parsed = new URL(raw);
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return null;
|
|
130
|
+
// WHATWG decodes a percent-encoded host: `https://%2A.example.com` has origin
|
|
131
|
+
// `https://*.example.com`, which the raw-string check above cannot see.
|
|
132
|
+
if (parsed.origin.includes('*')) return null;
|
|
133
|
+
return parsed.origin;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Why one AILK_TRUSTED_ORIGINS entry is refused, or null when it is an exact origin. */
|
|
137
|
+
function refuseExtra(entry: string, nodeEnv: Config['NODE_ENV']): TrustedOriginRefusal | null {
|
|
138
|
+
// WHATWG parses `https://*.example.com` and keeps it as its own origin, so
|
|
139
|
+
// the wildcard is refused by name, before the parse (spec § 1.3).
|
|
140
|
+
if (entry.includes('*')) return 'wildcard';
|
|
141
|
+
let parsed: URL;
|
|
142
|
+
try {
|
|
143
|
+
parsed = new URL(entry);
|
|
144
|
+
} catch {
|
|
145
|
+
return 'unparseable';
|
|
146
|
+
}
|
|
147
|
+
if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && nodeEnv !== 'production')) {
|
|
148
|
+
return 'scheme';
|
|
149
|
+
}
|
|
150
|
+
// Written exactly as a browser serialises an Origin header: lower-case
|
|
151
|
+
// punycode host, no default port, path, trailing slash or userinfo.
|
|
152
|
+
if (entry !== parsed.origin) return 'not-canonical';
|
|
153
|
+
// `https://a.example.` is a different origin from `https://a.example`.
|
|
154
|
+
if (parsed.hostname.endsWith('.')) return 'trailing-dot';
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The trusted browser-origin allowlist (#7395 D2). Pure; `validateConfig()`
|
|
160
|
+
* calls it once.
|
|
161
|
+
*
|
|
162
|
+
* - `FRONTEND_URL` is normalised to its origin, as the checkout lock always
|
|
163
|
+
* did; unset outside production it is the dev default. Unset in production,
|
|
164
|
+
* or unusable (unparseable, not http(s), or containing `*` or `,`), it
|
|
165
|
+
* contributes nothing, and `primaryUnusable` reports the latter.
|
|
166
|
+
* - `AILK_TRUSTED_ORIGINS` entries must already be exact origins, https-only in
|
|
167
|
+
* production. A refused entry is reported by 1-based position and reason —
|
|
168
|
+
* never its text, which may carry userinfo or a misplaced secret.
|
|
169
|
+
*
|
|
170
|
+
* The result is de-duplicated in first-seen order and frozen.
|
|
171
|
+
*/
|
|
172
|
+
export function resolveTrustedOrigins(env: {
|
|
173
|
+
FRONTEND_URL?: string | undefined;
|
|
174
|
+
AILK_TRUSTED_ORIGINS?: string | undefined;
|
|
175
|
+
NODE_ENV: Config['NODE_ENV'];
|
|
176
|
+
}): {
|
|
177
|
+
origins: readonly string[];
|
|
178
|
+
refused: { position: number; reason: TrustedOriginRefusal }[];
|
|
179
|
+
primaryUnusable: boolean;
|
|
180
|
+
} {
|
|
181
|
+
const origins: string[] = [];
|
|
182
|
+
const refused: { position: number; reason: TrustedOriginRefusal }[] = [];
|
|
183
|
+
let primaryUnusable = false;
|
|
184
|
+
|
|
185
|
+
if (env.FRONTEND_URL) {
|
|
186
|
+
const primary = primaryOrigin(env.FRONTEND_URL);
|
|
187
|
+
if (primary === null) primaryUnusable = true;
|
|
188
|
+
else origins.push(primary);
|
|
189
|
+
} else if (env.NODE_ENV !== 'production') {
|
|
190
|
+
origins.push(DEV_FRONTEND_ORIGIN);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
(env.AILK_TRUSTED_ORIGINS ?? '').split(',').forEach((piece, index) => {
|
|
194
|
+
const entry = piece.trim();
|
|
195
|
+
if (!entry) return;
|
|
196
|
+
const reason = refuseExtra(entry, env.NODE_ENV);
|
|
197
|
+
if (reason !== null) refused.push({ position: index + 1, reason });
|
|
198
|
+
else origins.push(entry);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
return { origins: Object.freeze([...new Set(origins)]), refused, primaryUnusable };
|
|
202
|
+
}
|
|
203
|
+
|
|
97
204
|
/** Enabled modules whose state lives in the database (see validateConfig). */
|
|
98
205
|
const DATABASE_MODULES = [
|
|
99
206
|
'commerce',
|
|
@@ -135,10 +242,39 @@ function validateConfig(): Config {
|
|
|
135
242
|
);
|
|
136
243
|
}
|
|
137
244
|
|
|
245
|
+
// #7395 D3 — a refused AILK_TRUSTED_ORIGINS entry stops the API in every
|
|
246
|
+
// environment: the variable is opt-in, and an entry dropped silently would
|
|
247
|
+
// resurface as the very 401 it was added to fix. Position and reason only,
|
|
248
|
+
// never the entry's text. console.error too, because pino-pretty's worker
|
|
249
|
+
// transport can drop the last line written before exit.
|
|
250
|
+
const trusted = resolveTrustedOrigins({
|
|
251
|
+
FRONTEND_URL: process.env.FRONTEND_URL,
|
|
252
|
+
AILK_TRUSTED_ORIGINS: process.env.AILK_TRUSTED_ORIGINS,
|
|
253
|
+
NODE_ENV: nodeEnv,
|
|
254
|
+
});
|
|
255
|
+
if (trusted.refused.length > 0) {
|
|
256
|
+
const message =
|
|
257
|
+
'❌ AILK_TRUSTED_ORIGINS has entries that are not exact http(s) origins (https only in production; no `*`, path, trailing slash or userinfo)';
|
|
258
|
+
logger.error({ refused: trusted.refused }, message);
|
|
259
|
+
console.error(message, JSON.stringify({ refused: trusted.refused }));
|
|
260
|
+
process.exit(1);
|
|
261
|
+
}
|
|
262
|
+
// #7395 D5 — a set-but-unusable FRONTEND_URL predates this list, so it is
|
|
263
|
+
// logged rather than fatal. The value is never echoed.
|
|
264
|
+
if (trusted.primaryUnusable) {
|
|
265
|
+
logger.error(
|
|
266
|
+
"FRONTEND_URL is set but is not an absolute http(s) URL without '*' or ',' — it is not a trusted origin",
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
138
270
|
return {
|
|
139
271
|
NODE_ENV: nodeEnv,
|
|
140
272
|
PORT: parseInt(process.env.PORT || '3002', 10),
|
|
141
273
|
FRONTEND_URL: process.env.FRONTEND_URL || 'http://localhost:3000',
|
|
274
|
+
TRUSTED_ORIGINS: trusted.origins,
|
|
275
|
+
...(process.env.AILK_TRUSTED_ORIGINS !== undefined && {
|
|
276
|
+
AILK_TRUSTED_ORIGINS: process.env.AILK_TRUSTED_ORIGINS,
|
|
277
|
+
}),
|
|
142
278
|
|
|
143
279
|
...(process.env.DATABASE_URL !== undefined && {
|
|
144
280
|
DATABASE_URL: process.env.DATABASE_URL,
|
|
@@ -37,7 +37,7 @@ import { authPreHandlerPlugin } from "../../auth.js";
|
|
|
37
37
|
function buildApp(): FastifyInstance {
|
|
38
38
|
const app = Fastify();
|
|
39
39
|
app.register(authPreHandlerPlugin, {
|
|
40
|
-
|
|
40
|
+
trustedOrigins: ["https://app.example.com"],
|
|
41
41
|
});
|
|
42
42
|
app.get("/echo", async (req) => ({ userId: req.userId ?? null }));
|
|
43
43
|
return app;
|
package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts
CHANGED
|
@@ -38,7 +38,7 @@ const BEARER = "tok_live_xyz";
|
|
|
38
38
|
|
|
39
39
|
function buildApp(): FastifyInstance {
|
|
40
40
|
const app = Fastify();
|
|
41
|
-
app.register(authPreHandlerPlugin, {
|
|
41
|
+
app.register(authPreHandlerPlugin, { trustedOrigins: [FRONTEND] });
|
|
42
42
|
app.get("/echo", async (req) => ({ userId: req.userId ?? null }));
|
|
43
43
|
app.post("/echo", async (req) => ({ userId: req.userId ?? null }));
|
|
44
44
|
return app;
|
|
@@ -33,7 +33,7 @@ const BEARER = "tok_live_abc";
|
|
|
33
33
|
|
|
34
34
|
function buildApp(): FastifyInstance {
|
|
35
35
|
const app = Fastify();
|
|
36
|
-
app.register(authPreHandlerPlugin, {
|
|
36
|
+
app.register(authPreHandlerPlugin, { trustedOrigins: [FRONTEND] });
|
|
37
37
|
app.post("/echo", async (req) => ({ userId: req.userId ?? null }));
|
|
38
38
|
return app;
|
|
39
39
|
}
|
|
@@ -35,7 +35,7 @@ const COOKIE_NAME = "better-auth.session_token";
|
|
|
35
35
|
|
|
36
36
|
function buildApp(): FastifyInstance {
|
|
37
37
|
const app = Fastify();
|
|
38
|
-
app.register(authPreHandlerPlugin, {
|
|
38
|
+
app.register(authPreHandlerPlugin, { trustedOrigins: [FRONTEND] });
|
|
39
39
|
app.get("/echo", async (req) => ({ userId: req.userId ?? null }));
|
|
40
40
|
app.post("/echo", async (req) => ({ userId: req.userId ?? null }));
|
|
41
41
|
// HEAD is auto-registered by Fastify when GET is declared; no explicit handler needed.
|
|
@@ -72,7 +72,7 @@ describe("S13 adversarial — CSRF on cookie path", () => {
|
|
|
72
72
|
expect(JSON.parse(res.body)).toEqual({ error: "unauthorized" });
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
-
it("rejects POST when Origin scheme differs from the configured
|
|
75
|
+
it("rejects POST when Origin scheme differs from the configured trusted origin", async () => {
|
|
76
76
|
app = buildApp();
|
|
77
77
|
await app.ready();
|
|
78
78
|
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file csrf-trusted-origins.test.ts (#7395 AC-3)
|
|
3
|
+
*
|
|
4
|
+
* The cookie path's CSRF check against a multi-origin allowlist. A spoke site
|
|
5
|
+
* forwards the visitor's cookie and the browser's real `Origin` to the hub
|
|
6
|
+
* API, so a write is accepted iff that `Origin` is EXACTLY one of the
|
|
7
|
+
* configured trusted origins — never a prefix, a suffix or the first entry
|
|
8
|
+
* alone — and an empty list refuses every cookie write in every environment
|
|
9
|
+
* (the dev fail-open is gone, spec D4/D6.2). The non-rejecting resolver the
|
|
10
|
+
* checkout route uses applies the same check (#6866 D2).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import Fastify from "fastify";
|
|
14
|
+
import type { FastifyInstance } from "fastify";
|
|
15
|
+
|
|
16
|
+
// eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
|
|
17
|
+
jest.mock("../../../lib/prisma.js", () => ({
|
|
18
|
+
prisma: {
|
|
19
|
+
session: { findUnique: jest.fn() },
|
|
20
|
+
apiToken: {
|
|
21
|
+
findUnique: jest.fn(),
|
|
22
|
+
update: jest.fn().mockResolvedValue(undefined),
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
import { prisma } from "../../../lib/prisma.js";
|
|
28
|
+
import { authPreHandlerPlugin, resolveOptionalCookieUser } from "../../auth.js";
|
|
29
|
+
|
|
30
|
+
const HUB = "https://hub.example";
|
|
31
|
+
const SPOKE_A = "https://spoke-a.example";
|
|
32
|
+
const TRUSTED = [HUB, SPOKE_A];
|
|
33
|
+
const COOKIE = "better-auth.session_token=sess_tok_abc";
|
|
34
|
+
|
|
35
|
+
function buildApp(trustedOrigins: readonly string[]): FastifyInstance {
|
|
36
|
+
const app = Fastify();
|
|
37
|
+
app.register(authPreHandlerPlugin, { trustedOrigins });
|
|
38
|
+
app.post("/echo", async (req) => ({ userId: req.userId ?? null }));
|
|
39
|
+
return app;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function buildResolverApp(trustedOrigins: readonly string[]): FastifyInstance {
|
|
43
|
+
const app = Fastify();
|
|
44
|
+
app.post("/resolve", async (req) =>
|
|
45
|
+
resolveOptionalCookieUser(req, { trustedOrigins }),
|
|
46
|
+
);
|
|
47
|
+
return app;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function post(app: FastifyInstance, url: string, origin?: string) {
|
|
51
|
+
return app.inject({
|
|
52
|
+
method: "POST",
|
|
53
|
+
url,
|
|
54
|
+
headers: { cookie: COOKIE, ...(origin !== undefined && { origin }) },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("#7395 — cookie-path CSRF against the trusted-origin allowlist", () => {
|
|
59
|
+
const originalNodeEnv = process.env.NODE_ENV;
|
|
60
|
+
const originalDatabaseUrl = process.env.DATABASE_URL;
|
|
61
|
+
let app: FastifyInstance;
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
jest.clearAllMocks();
|
|
65
|
+
process.env.DATABASE_URL = "postgresql://test:test@localhost/test";
|
|
66
|
+
(prisma.session.findUnique as jest.Mock).mockResolvedValue({
|
|
67
|
+
id: "ses_1",
|
|
68
|
+
userId: "usr_42",
|
|
69
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
70
|
+
activeOrganizationId: null,
|
|
71
|
+
activeWorkspaceId: null,
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
afterEach(async () => {
|
|
76
|
+
await app.close();
|
|
77
|
+
if (originalNodeEnv === undefined) delete process.env.NODE_ENV;
|
|
78
|
+
else process.env.NODE_ENV = originalNodeEnv;
|
|
79
|
+
if (originalDatabaseUrl === undefined) delete process.env.DATABASE_URL;
|
|
80
|
+
else process.env.DATABASE_URL = originalDatabaseUrl;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it.each([HUB, SPOKE_A])(
|
|
84
|
+
"accepts a cookie POST from the listed origin %s",
|
|
85
|
+
async (origin) => {
|
|
86
|
+
app = buildApp(TRUSTED);
|
|
87
|
+
await app.ready();
|
|
88
|
+
|
|
89
|
+
const res = await post(app, "/echo", origin);
|
|
90
|
+
|
|
91
|
+
expect(res.statusCode).toBe(200);
|
|
92
|
+
expect(res.json()).toEqual({ userId: "usr_42" });
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
it.each([
|
|
97
|
+
["an unlisted spoke", "https://spoke-b.example"],
|
|
98
|
+
["a listed origin as a host prefix", "https://spoke-a.example.evil.test"],
|
|
99
|
+
["an opaque origin", "null"],
|
|
100
|
+
["a case variant of a listed origin", "HTTPS://HUB.EXAMPLE"],
|
|
101
|
+
["a listed origin with a trailing slash", "https://hub.example/"],
|
|
102
|
+
["a listed origin with its default port", "https://hub.example:443"],
|
|
103
|
+
["a missing Origin", undefined],
|
|
104
|
+
])("refuses a cookie POST from %s with 401", async (_label, origin) => {
|
|
105
|
+
app = buildApp(TRUSTED);
|
|
106
|
+
await app.ready();
|
|
107
|
+
|
|
108
|
+
const res = await post(app, "/echo", origin);
|
|
109
|
+
|
|
110
|
+
expect(res.statusCode).toBe(401);
|
|
111
|
+
expect(res.json()).toEqual({ error: "unauthorized" });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("refuses every cookie POST with an empty list, even in development", async () => {
|
|
115
|
+
process.env.NODE_ENV = "development";
|
|
116
|
+
app = buildApp([]);
|
|
117
|
+
await app.ready();
|
|
118
|
+
|
|
119
|
+
const res = await post(app, "/echo", HUB);
|
|
120
|
+
|
|
121
|
+
expect(res.statusCode).toBe(401);
|
|
122
|
+
expect(res.json()).toEqual({ error: "unauthorized" });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("resolveOptionalCookieUser returns the user for a listed spoke", async () => {
|
|
126
|
+
app = buildResolverApp(TRUSTED);
|
|
127
|
+
await app.ready();
|
|
128
|
+
|
|
129
|
+
const res = await post(app, "/resolve", SPOKE_A);
|
|
130
|
+
|
|
131
|
+
expect(res.json()).toEqual({ kind: "user", userId: "usr_42" });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("resolveOptionalCookieUser returns none/cross_origin for an unlisted spoke", async () => {
|
|
135
|
+
app = buildResolverApp(TRUSTED);
|
|
136
|
+
await app.ready();
|
|
137
|
+
|
|
138
|
+
const res = await post(app, "/resolve", "https://spoke-b.example");
|
|
139
|
+
|
|
140
|
+
expect(res.json()).toEqual({ kind: "none", reason: "cross_origin" });
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -40,7 +40,7 @@ const COOKIE_NAME = "better-auth.session_token";
|
|
|
40
40
|
function buildApp(): FastifyInstance {
|
|
41
41
|
const app = Fastify();
|
|
42
42
|
app.register(authPreHandlerPlugin, {
|
|
43
|
-
|
|
43
|
+
trustedOrigins: ["https://app.example.com"],
|
|
44
44
|
});
|
|
45
45
|
app.get("/echo", async (req) => ({ userId: req.userId ?? null }));
|
|
46
46
|
return app;
|
|
@@ -46,7 +46,7 @@ let CURRENT_TIME = 1_000_000_000_000;
|
|
|
46
46
|
function buildApp(): FastifyInstance {
|
|
47
47
|
const app = Fastify();
|
|
48
48
|
app.register(authPreHandlerPlugin, {
|
|
49
|
-
|
|
49
|
+
trustedOrigins: ["https://app.example.com"],
|
|
50
50
|
});
|
|
51
51
|
app.get("/echo", async (req) => ({ userId: req.userId ?? null }));
|
|
52
52
|
return app;
|
|
@@ -37,7 +37,7 @@ const BEARER_HASH = createHash("sha256").update(BEARER).digest("hex");
|
|
|
37
37
|
|
|
38
38
|
function buildApp(): FastifyInstance {
|
|
39
39
|
const app = Fastify();
|
|
40
|
-
app.register(authPreHandlerPlugin, {
|
|
40
|
+
app.register(authPreHandlerPlugin, { trustedOrigins: [FRONTEND] });
|
|
41
41
|
app.get("/echo", async (req) => ({ userId: req.userId ?? null }));
|
|
42
42
|
app.post("/echo", async (req) => ({ userId: req.userId ?? null }));
|
|
43
43
|
return app;
|
|
@@ -27,7 +27,7 @@ import { authPreHandlerPlugin } from "../auth.js";
|
|
|
27
27
|
function buildApp(): FastifyInstance {
|
|
28
28
|
const app = Fastify();
|
|
29
29
|
app.register(authPreHandlerPlugin, {
|
|
30
|
-
|
|
30
|
+
trustedOrigins: ["https://app.example.com"],
|
|
31
31
|
});
|
|
32
32
|
app.get("/echo", async (req) => ({ userId: req.userId ?? null }));
|
|
33
33
|
return app;
|
|
@@ -33,7 +33,7 @@ const FRONTEND = "https://app.example.com";
|
|
|
33
33
|
|
|
34
34
|
function buildApp(): FastifyInstance {
|
|
35
35
|
const app = Fastify();
|
|
36
|
-
app.register(authPreHandlerPlugin, {
|
|
36
|
+
app.register(authPreHandlerPlugin, { trustedOrigins: [FRONTEND] });
|
|
37
37
|
app.get("/echo", async (req) => ({ userId: req.userId ?? null }));
|
|
38
38
|
app.post("/echo", async (req) => ({ userId: req.userId ?? null }));
|
|
39
39
|
return app;
|
|
@@ -31,7 +31,7 @@ const echoHandler = jest.fn(async () => ({ ok: true }));
|
|
|
31
31
|
function buildApp(): FastifyInstance {
|
|
32
32
|
const app = Fastify();
|
|
33
33
|
app.register(authPreHandlerPlugin, {
|
|
34
|
-
|
|
34
|
+
trustedOrigins: ["https://app.example.com"],
|
|
35
35
|
});
|
|
36
36
|
app.get("/echo", echoHandler);
|
|
37
37
|
app.post("/echo", echoHandler);
|
|
@@ -27,7 +27,7 @@ import { authPreHandlerPlugin } from "../auth.js";
|
|
|
27
27
|
function buildApp(): FastifyInstance {
|
|
28
28
|
const app = Fastify();
|
|
29
29
|
app.register(authPreHandlerPlugin, {
|
|
30
|
-
|
|
30
|
+
trustedOrigins: ["https://app.example.com"],
|
|
31
31
|
});
|
|
32
32
|
app.get("/echo", async () => ({ ok: true }));
|
|
33
33
|
return app;
|
|
@@ -28,7 +28,7 @@ const COOKIE = "better-auth.session_token=sess_tok_abc.signature";
|
|
|
28
28
|
function buildApp(): FastifyInstance {
|
|
29
29
|
const app = Fastify();
|
|
30
30
|
app.post("/resolve", async (req) =>
|
|
31
|
-
resolveOptionalCookieUser(req, {
|
|
31
|
+
resolveOptionalCookieUser(req, { trustedOrigins: [FRONTEND] }),
|
|
32
32
|
);
|
|
33
33
|
return app;
|
|
34
34
|
}
|
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
* Looked up directly against the `Session` table (the row Better Auth's
|
|
9
9
|
* database adapter creates on sign-in). Cookie wins when both modes are
|
|
10
10
|
* present (locked by brief decision OQ-D / spec §3 "Both present").
|
|
11
|
-
* Non-GET requests with a cookie are required to carry
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* Non-GET requests with a cookie are required to carry an Origin header
|
|
12
|
+
* that is exactly one of the configured trusted origins (#7395) — the
|
|
13
|
+
* lightweight CSRF mechanism agreed in OQ-D. Bearer-authenticated callers
|
|
14
|
+
* skip the origin check (non-browser flow).
|
|
14
15
|
*
|
|
15
16
|
* 2. Bearer path — Authorization: Bearer <token>. The token is sha256-hashed
|
|
16
17
|
* and matched against `ApiToken.token` (stored as hash). Revoked / expired
|
|
@@ -120,27 +121,21 @@ function readBearer(request: FastifyRequest): string | undefined {
|
|
|
120
121
|
}
|
|
121
122
|
|
|
122
123
|
/**
|
|
123
|
-
*
|
|
124
|
+
* CSRF check for cookie-authenticated mutating requests (#7395).
|
|
124
125
|
* Returns true if the request is safe (GET/HEAD/OPTIONS) or the Origin header
|
|
125
|
-
*
|
|
126
|
+
* is exactly one of the trusted origins. False otherwise — including for any
|
|
127
|
+
* write when the list is empty, in every environment.
|
|
126
128
|
*/
|
|
127
129
|
function isOriginAllowed(
|
|
128
130
|
request: FastifyRequest,
|
|
129
|
-
|
|
131
|
+
trustedOrigins: readonly string[],
|
|
130
132
|
): boolean {
|
|
131
133
|
const method = request.method.toUpperCase();
|
|
132
134
|
if (method === "GET" || method === "HEAD" || method === "OPTIONS")
|
|
133
135
|
return true;
|
|
134
|
-
if (!frontendOrigin) {
|
|
135
|
-
// No FRONTEND_URL configured: fail OPEN in dev/test (zero-config OSS
|
|
136
|
-
// boot), fail CLOSED in production (security review M1 — with the
|
|
137
|
-
// multi-tenant module's state-changing routes, an unset origin must not
|
|
138
|
-
// silently disable the CSRF check in a real deployment).
|
|
139
|
-
return process.env.NODE_ENV !== "production";
|
|
140
|
-
}
|
|
141
136
|
const origin = request.headers.origin;
|
|
142
137
|
if (!origin) return false;
|
|
143
|
-
return origin
|
|
138
|
+
return trustedOrigins.includes(origin);
|
|
144
139
|
}
|
|
145
140
|
|
|
146
141
|
type SessionRow = {
|
|
@@ -187,8 +182,11 @@ async function resolveBearerToken(
|
|
|
187
182
|
}
|
|
188
183
|
|
|
189
184
|
export type AuthPreHandlerOptions = {
|
|
190
|
-
/**
|
|
191
|
-
|
|
185
|
+
/**
|
|
186
|
+
* The browser origins a cookie-authenticated write may come from — exact
|
|
187
|
+
* matches only. server.ts passes `config.TRUSTED_ORIGINS` (#7395).
|
|
188
|
+
*/
|
|
189
|
+
trustedOrigins: readonly string[];
|
|
192
190
|
};
|
|
193
191
|
|
|
194
192
|
/**
|
|
@@ -214,7 +212,7 @@ export type OptionalCookieUser =
|
|
|
214
212
|
* route's buyer stamp).
|
|
215
213
|
*
|
|
216
214
|
* Applies the cookie path's own rules — the same private cookie reader,
|
|
217
|
-
* Session lookup, expiry comparison and
|
|
215
|
+
* Session lookup, expiry comparison and trusted-origin check as
|
|
218
216
|
* `makeAuthPreHandler` — so the two paths cannot drift. Reads the COOKIE only:
|
|
219
217
|
* a bearer `ApiToken` never identifies a user here. Never writes a reply and
|
|
220
218
|
* never throws; every failure is a `none` with its reason. The cookie is
|
|
@@ -238,7 +236,7 @@ export async function resolveOptionalCookieUser(
|
|
|
238
236
|
if (session.expiresAt.getTime() <= getClock().now()) {
|
|
239
237
|
return { kind: "none", reason: "expired" };
|
|
240
238
|
}
|
|
241
|
-
if (!isOriginAllowed(request, opts.
|
|
239
|
+
if (!isOriginAllowed(request, opts.trustedOrigins)) {
|
|
242
240
|
return { kind: "none", reason: "cross_origin" };
|
|
243
241
|
}
|
|
244
242
|
// Brand assertion at the Prisma read edge (ADR-0031 rule 5), as above.
|
|
@@ -251,8 +249,8 @@ export async function resolveOptionalCookieUser(
|
|
|
251
249
|
* Exported standalone so route-test suites can register it directly without
|
|
252
250
|
* spinning up the full server module.
|
|
253
251
|
*/
|
|
254
|
-
export function makeAuthPreHandler(opts: AuthPreHandlerOptions
|
|
255
|
-
const {
|
|
252
|
+
export function makeAuthPreHandler(opts: AuthPreHandlerOptions) {
|
|
253
|
+
const { trustedOrigins } = opts;
|
|
256
254
|
return async function authPreHandler(
|
|
257
255
|
request: FastifyRequest,
|
|
258
256
|
reply: FastifyReply,
|
|
@@ -272,7 +270,7 @@ export function makeAuthPreHandler(opts: AuthPreHandlerOptions = {}) {
|
|
|
272
270
|
() => undefined,
|
|
273
271
|
);
|
|
274
272
|
if (session && session.expiresAt.getTime() > now) {
|
|
275
|
-
if (!isOriginAllowed(request,
|
|
273
|
+
if (!isOriginAllowed(request, trustedOrigins)) {
|
|
276
274
|
reply.code(401).send(UNAUTHORIZED);
|
|
277
275
|
return;
|
|
278
276
|
}
|
|
@@ -42,7 +42,7 @@ const FRONTEND = "https://app.example.com";
|
|
|
42
42
|
|
|
43
43
|
async function buildApp(): Promise<FastifyInstance> {
|
|
44
44
|
const app = Fastify();
|
|
45
|
-
app.register(authPreHandlerPlugin, {
|
|
45
|
+
app.register(authPreHandlerPlugin, { trustedOrigins: [FRONTEND] });
|
|
46
46
|
app.register(tokenRoutes);
|
|
47
47
|
await app.ready();
|
|
48
48
|
return app;
|
|
@@ -37,7 +37,7 @@ const FRONTEND = "https://app.example.com";
|
|
|
37
37
|
|
|
38
38
|
async function buildApp(): Promise<FastifyInstance> {
|
|
39
39
|
const app = Fastify();
|
|
40
|
-
app.register(authPreHandlerPlugin, {
|
|
40
|
+
app.register(authPreHandlerPlugin, { trustedOrigins: [FRONTEND] });
|
|
41
41
|
app.register(userRoutes);
|
|
42
42
|
await app.ready();
|
|
43
43
|
return app;
|