create-ailk 0.11.1 → 0.11.3
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/component-catalog.json +12 -1
- package/component-catalog.md +6 -5
- package/dist/content-payloads.js +2 -2
- package/dist/module-architecture.js +11 -0
- package/dist/surfaces.d.ts +4 -5
- package/dist/surfaces.js +50 -8
- package/package.json +3 -3
- package/templates/.env.example +42 -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__/leads-forwarded-rate-limit.test.ts +135 -0
- 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/__tests__/vercel-handler.test.ts +233 -12
- 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/lib/auth-email-budget.ts +3 -2
- 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/__tests__/rate-limit.test.ts +529 -1
- package/templates/apps/api/src/middleware/auth.ts +19 -21
- package/templates/apps/api/src/middleware/rate-limit.ts +341 -28
- 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/auth/__tests__/transactional-email.test.ts +105 -1
- package/templates/apps/api/src/routes/auth/transactional-email.ts +37 -9
- 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 +49 -16
- package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +251 -0
- package/templates/apps/api/src/services/lead-accept-user.ts +126 -0
- package/templates/apps/api/src/vercel-handler.ts +58 -12
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +81 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +4 -0
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +3 -1
- package/templates/apps/web/.env.example +4 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +2 -1
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +14 -9
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +5 -4
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +69 -2
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +156 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +44 -26
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +104 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +57 -0
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +306 -28
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +143 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +297 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +145 -234
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +397 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +203 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +183 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +143 -0
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +3 -1
- package/templates/apps/web/app/[locale]/(authed)/account/error-row.tsx +35 -0
- package/templates/apps/web/app/[locale]/(authed)/account/for-you-labels.ts +36 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/[kind]/[id]/page.tsx +96 -0
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +88 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +120 -297
- package/templates/apps/web/app/[locale]/(authed)/account/retry-button.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +68 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +55 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +118 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +298 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +32 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support/page.tsx +60 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support/support-form.tsx +98 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +98 -0
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +77 -3
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +52 -9
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +63 -2
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +22 -2
- package/templates/apps/web/app/[locale]/layout.tsx +98 -84
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +66 -2
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +22 -6
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +176 -0
- package/templates/apps/web/app/openapi.json/route.ts +127 -0
- package/templates/apps/web/auth.ts +102 -18
- package/templates/apps/web/components/sign-out-trigger.tsx +3 -2
- package/templates/apps/web/components/signed-in-nav.tsx +58 -0
- package/templates/apps/web/jest.config.cjs +5 -0
- package/templates/apps/web/lib/__tests__/account-config.test.ts +285 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +156 -1
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +41 -2
- package/templates/apps/web/lib/account-config.ts +219 -11
- package/templates/apps/web/lib/auth-email.ts +104 -2
- package/templates/apps/web/lib/auth-env.ts +37 -5
- package/templates/apps/web/lib/auth-routes.ts +33 -4
- package/templates/apps/web/messages/en.json +147 -21
- package/templates/database/CHANGELOG.md +7 -0
- package/templates/database/package.json +1 -1
- package/versions.json +33 -33
- package/templates/apps/web/app/[locale]/(authed)/account/sign-out-button.tsx +0 -39
|
@@ -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,
|
|
@@ -34,11 +34,12 @@ import { createHash } from "node:crypto";
|
|
|
34
34
|
|
|
35
35
|
import { getClock } from "@working-theory/common";
|
|
36
36
|
|
|
37
|
-
/** The
|
|
37
|
+
/** The four fixed message kinds the relay will send. */
|
|
38
38
|
export type AuthEmailKind =
|
|
39
39
|
| "password-reset"
|
|
40
40
|
| "email-verification"
|
|
41
|
-
| "password-changed"
|
|
41
|
+
| "password-changed"
|
|
42
|
+
| "magic-link";
|
|
42
43
|
|
|
43
44
|
/** Sends allowed per (recipient, kind) per hour. */
|
|
44
45
|
export const RECIPIENT_HOURLY_MAX = 3;
|
|
@@ -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
|
}
|