@lenne.tech/nest-server 11.27.4 → 11.27.5
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/.claude/rules/configurable-features.md +1 -1
- package/FRAMEWORK-API.md +2 -1
- package/dist/config.env.js +1 -0
- package/dist/config.env.js.map +1 -1
- package/dist/core/common/helpers/cookies.helper.d.ts +18 -0
- package/dist/core/common/helpers/cookies.helper.js +81 -4
- package/dist/core/common/helpers/cookies.helper.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +1 -0
- package/dist/core/modules/better-auth/better-auth.config.js +14 -33
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +8 -1
- package/migration-guides/11.27.4-to-11.27.5.md +241 -0
- package/package.json +7 -5
- package/src/config.env.ts +4 -0
- package/src/core/common/helpers/cookies.helper.ts +218 -12
- package/src/core/common/interfaces/server-options.interface.ts +22 -0
- package/src/core/modules/better-auth/better-auth.config.ts +20 -70
|
@@ -460,8 +460,15 @@ if (!isCorsDisabled(envConfig.cors)) {
|
|
|
460
460
|
| `cors: { allowAll: true }` | `origin: true` | `origin: true` | `trustedOrigins: undefined` |
|
|
461
461
|
| `cors: { allowedOrigins: [...] }` | `origin: [merged list]` | `origin: [merged list]` | `trustedOrigins: [merged list]` |
|
|
462
462
|
| `cors: { enabled: false }` | No CORS headers | No CORS headers | `trustedOrigins: []` |
|
|
463
|
+
| `cors: { deriveAppUrl: false }` | `appUrl` not derived from `baseUrl` | same | same |
|
|
463
464
|
|
|
464
|
-
|
|
465
|
+
**URL resolution (since v11.27.5):** all three layers resolve `appUrl`/`baseUrl` through the single `resolveServerUrls()` helper in `cookies.helper.ts`, so they can no longer drift:
|
|
466
|
+
|
|
467
|
+
1. `appUrl` set explicitly → used as-is
|
|
468
|
+
2. `env: 'local' | 'ci' | 'e2e'` with a localhost `baseUrl` → `appUrl` defaults to `http://localhost:3001`
|
|
469
|
+
3. otherwise derived from `baseUrl` by stripping a leading `api.` label (`https://api.example.com` → `https://example.com`), unless `cors.deriveAppUrl: false`
|
|
470
|
+
|
|
471
|
+
> **Security:** step 3 grants the derived origin credentialed CORS. If the apex domain is not trusted (e.g. a third-party-hosted marketing site whose XSS surface you do not control), set `cors.deriveAppUrl: false` and list the frontend origin explicitly via `appUrl` or `cors.allowedOrigins`. The derivation never yields a bare TLD (`https://api.dev` stays unchanged) and never emits the opaque `null` origin.
|
|
465
472
|
|
|
466
473
|
### NestJS Middleware Chain (CoreModule)
|
|
467
474
|
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# Migration Guide: 11.27.4 → 11.27.5
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
| Category | Details |
|
|
6
|
+
|----------|---------|
|
|
7
|
+
| **Breaking Changes** | None (API-compatible). One **behavior change**: a config that sets only `baseUrl` now also trusts the derived app origin in the REST/GraphQL CORS allowlist — see [Behavior Change](#behavior-change-cors-allowlist-derives-appurl). |
|
|
8
|
+
| **New Features** | `cors.deriveAppUrl` opt-out; exported `resolveServerUrls()` and `deriveAppUrlFromBaseUrl()` helpers. |
|
|
9
|
+
| **Bugfixes** | REST/GraphQL CORS blocked the frontend origin on `api.<host>` deployments and in `local`/`ci`/`e2e`; the `api.`-strip could produce a bare TLD or the opaque `null` origin; trailing-slash `baseUrl` produced a never-matching origin entry. |
|
|
10
|
+
| **Security Updates** | `better-auth` + `@better-auth/passkey` `1.6.11` → `1.6.23` (GHSA-86j7-9j95-vpqj, High); `@xhmikosr/decompress` pinned to `11.1.3` via override (GHSA-mp2f-45pm-3cg9, Critical). |
|
|
11
|
+
| **Migration Effort** | 0 minutes for most projects (`pnpm update`). ~2 minutes if your apex domain is untrusted — see below. |
|
|
12
|
+
|
|
13
|
+
The three CORS layers (GraphQL/Apollo, REST/Express, BetterAuth `trustedOrigins`)
|
|
14
|
+
previously each resolved `appUrl`/`baseUrl` on their own and had drifted. They now
|
|
15
|
+
share a single `resolveServerUrls()` helper, so they cannot disagree.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Quick Migration
|
|
20
|
+
|
|
21
|
+
No code changes required for the common case.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Update package
|
|
25
|
+
pnpm add @lenne.tech/nest-server@11.27.5
|
|
26
|
+
|
|
27
|
+
# Verify build
|
|
28
|
+
pnpm run build
|
|
29
|
+
|
|
30
|
+
# Run tests
|
|
31
|
+
pnpm test
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## What's Fixed in 11.27.5
|
|
37
|
+
|
|
38
|
+
### 1. `api.<host>` deployments: the frontend origin was blocked
|
|
39
|
+
|
|
40
|
+
A deployment that sets only `baseUrl` (the typical `NSC__BASE_URL` /
|
|
41
|
+
`BASE_URL` setup) got a CORS allowlist containing just the API's own origin.
|
|
42
|
+
BetterAuth already derived `https://example.com` from
|
|
43
|
+
`https://api.example.com` for its `trustedOrigins`, but the REST and GraphQL
|
|
44
|
+
layers did not — so IAM endpoints worked cross-origin while every other
|
|
45
|
+
endpoint failed preflight.
|
|
46
|
+
|
|
47
|
+
Both layers now derive `appUrl` identically:
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
// config.env.ts — no appUrl needed
|
|
51
|
+
baseUrl: 'https://api.example.com',
|
|
52
|
+
// → CORS allowlist: ['https://example.com', 'https://api.example.com']
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### 2. `local` / `ci` / `e2e`: localhost defaults were not applied to CORS
|
|
56
|
+
|
|
57
|
+
`resolveUrls()` in BetterAuth mapped these environments to
|
|
58
|
+
`appUrl = http://localhost:3001` (API on `:3000`, app on `:3001`).
|
|
59
|
+
`buildCorsConfig()` had no such branch, so the frontend was blocked. The
|
|
60
|
+
shared resolver now applies the same defaults on every layer.
|
|
61
|
+
|
|
62
|
+
> The framework's own `development` environment is **not** one of the
|
|
63
|
+
> localhost-default environments. `src/config.env.ts` now sets
|
|
64
|
+
> `appUrl: process.env.APP_URL || 'http://localhost:3001'` there explicitly.
|
|
65
|
+
> Projects that copied the `development` block from the starter and run their
|
|
66
|
+
> frontend on `:3001` should do the same.
|
|
67
|
+
|
|
68
|
+
### 3. Hardened `api.`-label stripping
|
|
69
|
+
|
|
70
|
+
| `baseUrl` | Before | After |
|
|
71
|
+
|-----------|--------|-------|
|
|
72
|
+
| `https://api.example.com` | `https://example.com` | unchanged |
|
|
73
|
+
| `https://api.dev.example.com` | `https://dev.example.com` | unchanged |
|
|
74
|
+
| `https://api.dev` | `https://dev` (bare TLD!) | `https://api.dev` (no strip) |
|
|
75
|
+
| `https://api.` | `https://api.` (silent no-op) | `https://api.` (explicit no-op) |
|
|
76
|
+
| `custom://api.example.com` | `"null"` (opaque origin!) | `custom://api.example.com` |
|
|
77
|
+
| `https://api.example.com/` | `https://example.com` + dead `…/` entry | both entries origin-normalized |
|
|
78
|
+
|
|
79
|
+
The `"null"` case was the most serious: `URL.origin` serializes opaque origins
|
|
80
|
+
to the literal string `"null"`, which is exactly the `Origin` header a
|
|
81
|
+
sandboxed iframe sends. In a `credentials: true` allowlist that would have
|
|
82
|
+
granted credentialed access to any site able to frame a sandboxed document.
|
|
83
|
+
Only `http:`/`https:` URLs are normalized to origins now.
|
|
84
|
+
|
|
85
|
+
### 4. Security updates in dependencies
|
|
86
|
+
|
|
87
|
+
| Package | Change | Advisory |
|
|
88
|
+
|---------|--------|----------|
|
|
89
|
+
| `better-auth` | `1.6.11` → `1.6.23` (direct) | [GHSA-86j7-9j95-vpqj](https://github.com/advisories/GHSA-86j7-9j95-vpqj) — High: stored XSS in the auth-server origin via a `javascript:` `redirect_uri` in `oidc-provider` and `mcp` |
|
|
90
|
+
| `@better-auth/passkey` | `1.6.11` → `1.6.23` (direct) | Kept in lockstep — peers on `better-auth@^1.6.23` |
|
|
91
|
+
| `@xhmikosr/decompress` | pinned to `11.1.3` via `pnpm.overrides` | [GHSA-mp2f-45pm-3cg9](https://github.com/advisories/GHSA-mp2f-45pm-3cg9) — Critical: archive extraction can create files/links outside the target directory |
|
|
92
|
+
|
|
93
|
+
`@xhmikosr/decompress` is transitive via `@swc/cli > @xhmikosr/bin-wrapper > @xhmikosr/downloader`.
|
|
94
|
+
`@swc/cli@0.8.1` is the latest release and still resolves the vulnerable range, so an override
|
|
95
|
+
with a fixed target is the only available fix.
|
|
96
|
+
|
|
97
|
+
Both `better-auth` bumps stay inside `1.6.x` — no BetterAuth API change. The full suite
|
|
98
|
+
(2019 tests) passes unchanged.
|
|
99
|
+
|
|
100
|
+
npm-mode consumers get all three fixes with a plain `pnpm update`. Vendor-mode consumers must
|
|
101
|
+
bump `better-auth`/`@better-auth/passkey` in their own `package.json` and copy the
|
|
102
|
+
`@xhmikosr/decompress` override — the vendored file set does not carry `package.json`.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Behavior Change: CORS allowlist derives `appUrl`
|
|
107
|
+
|
|
108
|
+
**Who is affected:** projects that configure `baseUrl` but neither `appUrl`
|
|
109
|
+
nor `cors.allowedOrigins`, and whose apex domain is not under their control.
|
|
110
|
+
|
|
111
|
+
Deriving `appUrl` means the apex origin (`https://example.com`) receives
|
|
112
|
+
**credentialed** cross-origin access (`Access-Control-Allow-Credentials: true`).
|
|
113
|
+
That is the intended, documented `appUrl` auto-detection and matches what
|
|
114
|
+
BetterAuth's `trustedOrigins` has always done — but it now also applies to the
|
|
115
|
+
REST and GraphQL layers.
|
|
116
|
+
|
|
117
|
+
If your apex is a third-party-hosted marketing site (WordPress, Webflow,
|
|
118
|
+
HubSpot) whose XSS surface you do not control, opt out:
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
// config.env.ts
|
|
122
|
+
{
|
|
123
|
+
baseUrl: 'https://api.example.com',
|
|
124
|
+
cors: {
|
|
125
|
+
// Do not trust https://example.com implicitly
|
|
126
|
+
deriveAppUrl: false,
|
|
127
|
+
// List the real frontend origin instead
|
|
128
|
+
allowedOrigins: ['https://app.example.com'],
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`deriveAppUrl: false` suppresses the derivation on **all three** layers
|
|
134
|
+
(GraphQL, REST, BetterAuth `trustedOrigins`). It has no effect when `appUrl`
|
|
135
|
+
is set explicitly, and it does not disable the `local`/`ci`/`e2e` localhost
|
|
136
|
+
defaults.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## What's New in 11.27.5
|
|
141
|
+
|
|
142
|
+
### `cors.deriveAppUrl`
|
|
143
|
+
|
|
144
|
+
| Value | Effect |
|
|
145
|
+
|-------|--------|
|
|
146
|
+
| `true` (default) | `appUrl` is derived from `baseUrl` by stripping a leading `api.` label |
|
|
147
|
+
| `false` | No derivation; configure `appUrl` / `cors.allowedOrigins` explicitly |
|
|
148
|
+
|
|
149
|
+
### Exported URL helpers
|
|
150
|
+
|
|
151
|
+
Both are exported from the package root (`cookies.helper.ts`):
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
import { deriveAppUrlFromBaseUrl, resolveServerUrls } from '@lenne.tech/nest-server';
|
|
155
|
+
|
|
156
|
+
deriveAppUrlFromBaseUrl('https://api.example.com'); // 'https://example.com'
|
|
157
|
+
|
|
158
|
+
resolveServerUrls({ baseUrl: 'https://api.example.com' });
|
|
159
|
+
// { appUrl: 'https://example.com', appUrlSource: 'derived',
|
|
160
|
+
// baseUrl: 'https://api.example.com', baseUrlSource: 'explicit' }
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
`appUrlSource` / `baseUrlSource` (`'explicit' | 'localhost-default' | 'derived' | 'none'`)
|
|
164
|
+
let callers emit accurate startup diagnostics without re-deriving the resolution logic.
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## Breaking Changes
|
|
169
|
+
|
|
170
|
+
None. `ICorsConfig` gained an optional field; no signature, export, or Core
|
|
171
|
+
class changed.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Compatibility Notes
|
|
176
|
+
|
|
177
|
+
- **npm-mode consumers:** `pnpm update` is sufficient. Review the
|
|
178
|
+
[behavior change](#behavior-change-cors-allowlist-derives-appurl) if your
|
|
179
|
+
apex domain is untrusted.
|
|
180
|
+
- **Vendor-mode consumers:** the change touches `src/core/common/helpers/cookies.helper.ts`,
|
|
181
|
+
`src/core/common/interfaces/server-options.interface.ts` and
|
|
182
|
+
`src/core/modules/better-auth/better-auth.config.ts` — all inside the vendored
|
|
183
|
+
`src/core/` file set. Sync via `/lt-dev:backend:update-nest-server-core`.
|
|
184
|
+
`better-auth.config.ts` now imports `resolveServerUrls` from
|
|
185
|
+
`../../common/helpers/cookies.helper` (still inside `src/core/`, so the
|
|
186
|
+
self-containment rule holds).
|
|
187
|
+
- **Projects that set `appUrl` explicitly:** unaffected. Explicit values are
|
|
188
|
+
never overridden by the derivation.
|
|
189
|
+
- **Projects that set `cors.allowAll: true`:** unaffected. `allowAll` short-circuits
|
|
190
|
+
before URL resolution.
|
|
191
|
+
- **Passkey / WebAuthn in `development`:** if you adopt the new
|
|
192
|
+
`appUrl: 'http://localhost:3001'` in the `development` block, the Passkey
|
|
193
|
+
`origin` becomes `http://localhost:3001` (where the browser actually runs)
|
|
194
|
+
instead of the API's `:3000`. This is a fix — WebAuthn ceremonies from the
|
|
195
|
+
frontend previously used the wrong origin.
|
|
196
|
+
- **A duplicate private `deriveAppUrlFromBaseUrl()` was removed** from
|
|
197
|
+
`better-auth.config.ts`. It was never exported; nothing to migrate.
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## Troubleshooting
|
|
202
|
+
|
|
203
|
+
### My frontend is still blocked by CORS
|
|
204
|
+
|
|
205
|
+
Check, in order:
|
|
206
|
+
|
|
207
|
+
1. `cors.enabled` is not `false`.
|
|
208
|
+
2. `cookies` is not disabled — `buildCorsConfig()` returns `{}` when cookies
|
|
209
|
+
are off (REST then falls back to permissive `enableCors()` without credentials).
|
|
210
|
+
3. Your frontend origin is actually derivable. `baseUrl: 'https://my-api.example.com'`
|
|
211
|
+
has no `api.` label to strip, so `appUrl` resolves to the API's own origin.
|
|
212
|
+
Set `appUrl` explicitly.
|
|
213
|
+
4. `env` is one of `local`/`ci`/`e2e` if you rely on the `localhost:3001` default.
|
|
214
|
+
`development` is not — set `appUrl` there.
|
|
215
|
+
|
|
216
|
+
### My apex domain unexpectedly appears in the allowlist
|
|
217
|
+
|
|
218
|
+
That is the `appUrl` derivation. Set `cors.deriveAppUrl: false` and list the
|
|
219
|
+
frontend origin explicitly. See
|
|
220
|
+
[Behavior Change](#behavior-change-cors-allowlist-derives-appurl).
|
|
221
|
+
|
|
222
|
+
### `https://api.dev` no longer derives an app origin
|
|
223
|
+
|
|
224
|
+
Intended. Stripping would leave the bare TLD `dev`, which is not a deployable
|
|
225
|
+
host. Set `appUrl` explicitly for such domains.
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
## Module Documentation
|
|
230
|
+
|
|
231
|
+
- **Request lifecycle & CORS:** [docs/REQUEST-LIFECYCLE.md](../docs/REQUEST-LIFECYCLE.md) — section `0b. CORS`
|
|
232
|
+
- **Configurable features:** [.claude/rules/configurable-features.md](../.claude/rules/configurable-features.md) — `CORS` row
|
|
233
|
+
- **BetterAuth:** [src/core/modules/better-auth/README.md](../src/core/modules/better-auth/README.md)
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## References
|
|
238
|
+
|
|
239
|
+
- [Migration Guide 11.27.3 → 11.27.4](./11.27.3-to-11.27.4.md) — Previous release (test tooling)
|
|
240
|
+
- [Migration Guide 11.24.x → 11.25.0](./11.24.x-to-11.25.0.md) — Introduced the unified `cors` config
|
|
241
|
+
- [nest-server-starter](https://github.com/lenneTech/nest-server-starter) — reference implementation
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/nest-server",
|
|
3
|
-
"version": "11.27.
|
|
3
|
+
"version": "11.27.5",
|
|
4
4
|
"description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"node",
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
"dependencies": {
|
|
78
78
|
"@apollo/server": "5.5.1",
|
|
79
79
|
"@as-integrations/express5": "1.1.2",
|
|
80
|
-
"@better-auth/passkey": "1.6.
|
|
80
|
+
"@better-auth/passkey": "1.6.23",
|
|
81
81
|
"@getbrevo/brevo": "3.0.1",
|
|
82
82
|
"@modelcontextprotocol/sdk": "1.29.0",
|
|
83
83
|
"@nestjs/apollo": "13.4.2",
|
|
@@ -96,7 +96,7 @@
|
|
|
96
96
|
"@tus/server": "2.4.1",
|
|
97
97
|
"@types/supertest": "7.2.0",
|
|
98
98
|
"bcrypt": "6.0.0",
|
|
99
|
-
"better-auth": "1.6.
|
|
99
|
+
"better-auth": "1.6.23",
|
|
100
100
|
"class-transformer": "0.5.1",
|
|
101
101
|
"class-validator": "0.15.1",
|
|
102
102
|
"compression": "1.8.1",
|
|
@@ -216,7 +216,8 @@
|
|
|
216
216
|
"hono@<4.12.25": "Security: multiple CVEs <4.12.25 (prototype pollution, bodyLimit/Vary bypass, JWT NumericDate) - transitive via @nestjs/terminus>prisma>@prisma/dev",
|
|
217
217
|
"nodemailer@<9.0.1": "Security: email/header injection CVEs <9.0.1 - direct dependency",
|
|
218
218
|
"multer@<2.2.0": "Security: unhandled multipart errors / DoS <2.2.0 - transitive via @nestjs/platform-express",
|
|
219
|
-
"js-yaml@<4.2.0": "Security: special-character handling / prototype pollution (patched in 4.2.0; 4.1.2 was never published) - transitive via @nestjs/swagger"
|
|
219
|
+
"js-yaml@<4.2.0": "Security: special-character handling / prototype pollution (patched in 4.2.0; 4.1.2 was never published) - transitive via @nestjs/swagger",
|
|
220
|
+
"@xhmikosr/decompress@<11.1.3": "Security: archive extraction can create files/links outside the target directory (GHSA-mp2f-45pm-3cg9, critical) - transitive via @swc/cli>@xhmikosr/bin-wrapper>@xhmikosr/downloader; @swc/cli 0.8.1 is already the latest release and still resolves the vulnerable range, so an override is the only fix"
|
|
220
221
|
},
|
|
221
222
|
"overrides": {
|
|
222
223
|
"axios@<1.16.0": "1.16.0",
|
|
@@ -252,7 +253,8 @@
|
|
|
252
253
|
"hono@<4.12.25": "4.12.25",
|
|
253
254
|
"nodemailer@<9.0.1": "9.0.1",
|
|
254
255
|
"multer@<2.2.0": "2.2.0",
|
|
255
|
-
"js-yaml@<4.2.0": "4.2.0"
|
|
256
|
+
"js-yaml@<4.2.0": "4.2.0",
|
|
257
|
+
"@xhmikosr/decompress@<11.1.3": "11.1.3"
|
|
256
258
|
},
|
|
257
259
|
"//peerDependencyRules": "allowedVersions: deps lag behind our newer majors (graphql-upload wants @types/express@^4, the deprecated apollo playground plugin wants @apollo/server@^4) — both work with our v5. ignoreMissing: browser-only vis-network peers pulled in transitively via yuml-diagram (server-side UML generation never renders, so these are not needed).",
|
|
258
260
|
"peerDependencyRules": {
|
package/src/config.env.ts
CHANGED
|
@@ -170,6 +170,10 @@ const config: { [env: string]: IServerOptions } = {
|
|
|
170
170
|
// Development environment
|
|
171
171
|
// ===========================================================================
|
|
172
172
|
development: {
|
|
173
|
+
// `development` is not one of the localhost-default environments (local/ci/e2e), so appUrl
|
|
174
|
+
// must be set explicitly: deriving it from baseUrl would yield the API's own :3000 origin
|
|
175
|
+
// and leave the frontend on :3001 outside the CORS allowlist.
|
|
176
|
+
appUrl: process.env.APP_URL || 'http://localhost:3001',
|
|
173
177
|
auth: {
|
|
174
178
|
legacyEndpoints: { enabled: true },
|
|
175
179
|
},
|
|
@@ -237,6 +237,190 @@ export function isCorsDisabled(cors: boolean | ICorsConfig | undefined): boolean
|
|
|
237
237
|
return false;
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
+
// =================================================================================================
|
|
241
|
+
// Server URL resolution
|
|
242
|
+
//
|
|
243
|
+
// Single source of truth for "which app/API origin is this server reachable under".
|
|
244
|
+
// Consumed by `buildCorsConfig()` (REST + GraphQL CORS) and by BetterAuth's `resolveUrls()`
|
|
245
|
+
// (trustedOrigins, Passkey rpId/origin, cross-subdomain cookies). Keeping one implementation
|
|
246
|
+
// is what makes the three CORS layers agree — previously each layer derived URLs on its own
|
|
247
|
+
// and they drifted (BetterAuth applied localhost defaults, the CORS layer did not).
|
|
248
|
+
// =================================================================================================
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Default URLs for local/test environments (`local`, `ci`, `e2e`).
|
|
252
|
+
*
|
|
253
|
+
* These environments run on localhost and have no deployed domain: the API listens on
|
|
254
|
+
* port 3000, the frontend app on port 3001.
|
|
255
|
+
*
|
|
256
|
+
* @since 11.27.5
|
|
257
|
+
*/
|
|
258
|
+
export const LOCALHOST_URL_DEFAULTS = {
|
|
259
|
+
apiUrl: 'http://localhost:3000',
|
|
260
|
+
appUrl: 'http://localhost:3001',
|
|
261
|
+
} as const;
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Environments that fall back to {@link LOCALHOST_URL_DEFAULTS} when no URLs are configured.
|
|
265
|
+
*
|
|
266
|
+
* @since 11.27.5
|
|
267
|
+
*/
|
|
268
|
+
export const LOCALHOST_URL_ENVS: readonly string[] = ['ci', 'e2e', 'local'];
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The hostname label stripped from `baseUrl` to derive `appUrl`.
|
|
272
|
+
*/
|
|
273
|
+
const API_HOST_LABEL = 'api.';
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Normalizes a URL string to its http(s) origin, or `undefined` when it is not a usable
|
|
277
|
+
* http(s) URL.
|
|
278
|
+
*
|
|
279
|
+
* The `protocol` guard is security-relevant, not cosmetic: `URL.origin` serializes to the
|
|
280
|
+
* literal string `'null'` for opaque origins (any non-special scheme, e.g. `custom://host`).
|
|
281
|
+
* That string is exactly the `Origin` header a sandboxed iframe sends, so letting it into a
|
|
282
|
+
* `credentials: true` allowlist would grant credentialed access to any site able to frame a
|
|
283
|
+
* sandboxed document.
|
|
284
|
+
*/
|
|
285
|
+
function toHttpOrigin(value: string): string | undefined {
|
|
286
|
+
let url: URL;
|
|
287
|
+
try {
|
|
288
|
+
url = new URL(value);
|
|
289
|
+
} catch {
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
294
|
+
return undefined;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return url.origin;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Whether the URL points at the local machine (localhost, `*.localhost`, loopback IP).
|
|
302
|
+
*/
|
|
303
|
+
function isLocalhostUrl(value: string | undefined): boolean {
|
|
304
|
+
if (!value) {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
const { hostname } = new URL(value);
|
|
310
|
+
return (
|
|
311
|
+
hostname === 'localhost' || hostname.endsWith('.localhost') || hostname === '127.0.0.1' || hostname === '[::1]'
|
|
312
|
+
);
|
|
313
|
+
} catch {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Whether stripping the leading `api.` label from the hostname leaves a deployable host.
|
|
320
|
+
*
|
|
321
|
+
* Guards two cases where a naive strip produces a bogus origin:
|
|
322
|
+
* - `api.dev` → `dev` — a bare TLD. `api.dev`/`api.io`/`api.co` are registrable domains, so
|
|
323
|
+
* this is reachable configuration, and the result would be an unreachable host in a
|
|
324
|
+
* credentialed allowlist.
|
|
325
|
+
* - `api.` → `` — not a host at all. Assigning an empty hostname is silently ignored by the
|
|
326
|
+
* `URL` setter for special schemes, so the strip would appear to succeed but do nothing.
|
|
327
|
+
*
|
|
328
|
+
* `localhost` is the one legitimate single-label host (`api.localhost` → `localhost`).
|
|
329
|
+
*/
|
|
330
|
+
function canStripApiLabel(hostname: string): boolean {
|
|
331
|
+
if (!hostname.startsWith(API_HOST_LABEL)) {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const remainder = hostname.slice(API_HOST_LABEL.length);
|
|
336
|
+
return remainder === 'localhost' || remainder.includes('.');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Derives the frontend app URL from the API base URL by stripping a leading `api.`
|
|
341
|
+
* label from the hostname (e.g. `https://api.example.com` → `https://example.com`,
|
|
342
|
+
* `https://api.dev.example.com` → `https://dev.example.com`).
|
|
343
|
+
*
|
|
344
|
+
* Returns the origin unchanged when there is no strippable `api.` prefix, and returns the
|
|
345
|
+
* input unchanged when it is not an http(s) URL — callers decide what to do with a value
|
|
346
|
+
* they cannot normalize.
|
|
347
|
+
*
|
|
348
|
+
* @since 11.27.5
|
|
349
|
+
*/
|
|
350
|
+
export function deriveAppUrlFromBaseUrl(baseUrl: string): string {
|
|
351
|
+
const origin = toHttpOrigin(baseUrl);
|
|
352
|
+
if (!origin) {
|
|
353
|
+
return baseUrl;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const url = new URL(origin);
|
|
357
|
+
if (canStripApiLabel(url.hostname)) {
|
|
358
|
+
url.hostname = url.hostname.slice(API_HOST_LABEL.length);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return url.origin;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Where a resolved URL came from. Callers use this to emit accurate startup diagnostics
|
|
366
|
+
* without re-deriving the resolution logic.
|
|
367
|
+
*
|
|
368
|
+
* @since 11.27.5
|
|
369
|
+
*/
|
|
370
|
+
export interface IResolvedServerUrls {
|
|
371
|
+
appUrl: string | undefined;
|
|
372
|
+
appUrlSource: 'derived' | 'explicit' | 'localhost-default' | 'none';
|
|
373
|
+
baseUrl: string | undefined;
|
|
374
|
+
baseUrlSource: 'explicit' | 'localhost-default' | 'none';
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Resolves the effective app/API URLs for a server configuration.
|
|
379
|
+
*
|
|
380
|
+
* `baseUrl`: explicit → localhost default (local/ci/e2e only) → none.
|
|
381
|
+
* `appUrl`: explicit → localhost default (local/ci/e2e with a localhost `baseUrl`) →
|
|
382
|
+
* derived from `baseUrl` → none.
|
|
383
|
+
*
|
|
384
|
+
* `baseUrl` is returned verbatim (not origin-normalized) because BetterAuth passes it
|
|
385
|
+
* straight through as its `baseURL`; normalization for origin matching is the caller's job.
|
|
386
|
+
*
|
|
387
|
+
* @param input.deriveAppUrl - Set to `false` to disable the `api.`-strip derivation. The
|
|
388
|
+
* localhost defaults are unaffected — they are an explicit,
|
|
389
|
+
* documented behavior of the `local`/`ci`/`e2e` environments.
|
|
390
|
+
*
|
|
391
|
+
* @since 11.27.5
|
|
392
|
+
*/
|
|
393
|
+
export function resolveServerUrls(input: {
|
|
394
|
+
appUrl?: string;
|
|
395
|
+
baseUrl?: string;
|
|
396
|
+
deriveAppUrl?: boolean;
|
|
397
|
+
env?: string;
|
|
398
|
+
}): IResolvedServerUrls {
|
|
399
|
+
const usesLocalhostDefaults = LOCALHOST_URL_ENVS.includes(input.env ?? '');
|
|
400
|
+
|
|
401
|
+
let baseUrl = input.baseUrl;
|
|
402
|
+
let baseUrlSource: IResolvedServerUrls['baseUrlSource'] = baseUrl ? 'explicit' : 'none';
|
|
403
|
+
if (!baseUrl && usesLocalhostDefaults) {
|
|
404
|
+
baseUrl = LOCALHOST_URL_DEFAULTS.apiUrl;
|
|
405
|
+
baseUrlSource = 'localhost-default';
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (input.appUrl) {
|
|
409
|
+
return { appUrl: input.appUrl, appUrlSource: 'explicit', baseUrl, baseUrlSource };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// API on :3000 and app on :3001 — deriving from baseUrl would yield the API's own origin.
|
|
413
|
+
if (usesLocalhostDefaults && isLocalhostUrl(baseUrl)) {
|
|
414
|
+
return { appUrl: LOCALHOST_URL_DEFAULTS.appUrl, appUrlSource: 'localhost-default', baseUrl, baseUrlSource };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (baseUrl && input.deriveAppUrl !== false) {
|
|
418
|
+
return { appUrl: deriveAppUrlFromBaseUrl(baseUrl), appUrlSource: 'derived', baseUrl, baseUrlSource };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return { appUrl: undefined, appUrlSource: 'none', baseUrl, baseUrlSource };
|
|
422
|
+
}
|
|
423
|
+
|
|
240
424
|
/**
|
|
241
425
|
* Builds a CORS configuration object from server options.
|
|
242
426
|
*
|
|
@@ -244,21 +428,30 @@ export function isCorsDisabled(cors: boolean | ICorsConfig | undefined): boolean
|
|
|
244
428
|
* 1. CORS disabled → empty object (no CORS)
|
|
245
429
|
* 2. Cookies disabled → empty object (no credentials needed, handled by simple enableCors())
|
|
246
430
|
* 3. `cors.allowAll` → `{ credentials: true, origin: true }` (mirror request origin)
|
|
247
|
-
* 4. `cors.allowedOrigins` + `appUrl`/`baseUrl` → deduplicated origin list
|
|
248
|
-
* 5. Only `appUrl`/`baseUrl` → those origins
|
|
431
|
+
* 4. `cors.allowedOrigins` + resolved `appUrl`/`baseUrl` → deduplicated origin list
|
|
432
|
+
* 5. Only resolved `appUrl`/`baseUrl` → those origins
|
|
249
433
|
* 6. Nothing configured → `{}` (no credentialed CORS — caller decides fallback)
|
|
250
434
|
*
|
|
435
|
+
* `appUrl`/`baseUrl` are resolved via {@link resolveServerUrls}, the same function BetterAuth
|
|
436
|
+
* uses, so all three CORS layers (GraphQL, REST, BetterAuth `trustedOrigins`) agree.
|
|
437
|
+
*
|
|
251
438
|
* Used by both:
|
|
252
439
|
* - `CoreModule.buildCorsConfig()` for GraphQL (Apollo) CORS
|
|
253
440
|
* - `main.ts` reference implementation for REST (Express) CORS
|
|
254
441
|
*
|
|
255
|
-
* Security
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
* explicitly (for development),
|
|
260
|
-
*
|
|
261
|
-
*
|
|
442
|
+
* Security notes:
|
|
443
|
+
* - When no origins are resolvable AND cookies are enabled, the function returns `{}` rather
|
|
444
|
+
* than `{ credentials: true, origin: true }`. Returning open CORS with credentials would
|
|
445
|
+
* allow any website to make credentialed requests. Callers should either configure
|
|
446
|
+
* `appUrl`/`baseUrl`/`allowedOrigins`, enable `cors.allowAll` explicitly (for development),
|
|
447
|
+
* or accept no credentialed CORS.
|
|
448
|
+
* - Configuring only `baseUrl` grants credentialed CORS to the derived app origin as well
|
|
449
|
+
* (`https://api.example.com` → also `https://example.com`). This is the documented
|
|
450
|
+
* `appUrl` auto-detection and matches BetterAuth's `trustedOrigins`. Deployments whose
|
|
451
|
+
* apex domain is not trusted (e.g. a third-party-hosted marketing site) must opt out with
|
|
452
|
+
* `cors.deriveAppUrl: false` and list the real frontend origin explicitly.
|
|
453
|
+
*
|
|
454
|
+
* @param options - Server options containing `cors`, `cookies`, `appUrl`, `baseUrl`, `env`
|
|
262
455
|
* @returns CORS config object for Apollo/Express, or empty object if disabled/unconfigured
|
|
263
456
|
*
|
|
264
457
|
* @since 11.25.0
|
|
@@ -279,10 +472,23 @@ export function buildCorsConfig(options: Partial<IServerOptions>): Record<string
|
|
|
279
472
|
return { credentials: true, origin: true };
|
|
280
473
|
}
|
|
281
474
|
|
|
282
|
-
// Build origin list from appUrl
|
|
475
|
+
// Build origin list from the shared URL resolution (appUrl auto-derived from baseUrl,
|
|
476
|
+
// localhost defaults for local/ci/e2e), then allowedOrigins.
|
|
477
|
+
const { appUrl, baseUrl } = resolveServerUrls({
|
|
478
|
+
appUrl: options?.appUrl,
|
|
479
|
+
baseUrl: options?.baseUrl,
|
|
480
|
+
deriveAppUrl: corsObj.deriveAppUrl,
|
|
481
|
+
env: options?.env,
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// Normalize to origins before deduplicating: a browser's `Origin` header is always a bare
|
|
485
|
+
// scheme://host[:port] triple, so a configured `https://api.example.com/` (trailing slash —
|
|
486
|
+
// common in env-var-sourced URLs) could never match, and would defeat the Set below.
|
|
487
|
+
// Values we cannot normalize are passed through verbatim rather than dropped.
|
|
283
488
|
const origins: string[] = [];
|
|
284
|
-
|
|
285
|
-
|
|
489
|
+
for (const url of [appUrl, baseUrl]) {
|
|
490
|
+
if (url) origins.push(toHttpOrigin(url) ?? url);
|
|
491
|
+
}
|
|
286
492
|
if (corsObj.allowedOrigins?.length) {
|
|
287
493
|
origins.push(...corsObj.allowedOrigins);
|
|
288
494
|
}
|
|
@@ -1032,6 +1032,28 @@ export interface ICorsConfig {
|
|
|
1032
1032
|
*/
|
|
1033
1033
|
allowedOrigins?: string[];
|
|
1034
1034
|
|
|
1035
|
+
/**
|
|
1036
|
+
* Whether `appUrl` may be auto-derived from `baseUrl` when it is not set explicitly.
|
|
1037
|
+
*
|
|
1038
|
+
* By default the leading `api.` label is stripped from `baseUrl`'s hostname
|
|
1039
|
+
* (`https://api.example.com` → `https://example.com`), and the result is trusted by all
|
|
1040
|
+
* three CORS layers (GraphQL, REST, BetterAuth `trustedOrigins`). This is what makes the
|
|
1041
|
+
* common `api.<host>` / `<host>` deployment work without extra configuration.
|
|
1042
|
+
*
|
|
1043
|
+
* Set to `false` when the derived apex domain must NOT receive credentialed cross-origin
|
|
1044
|
+
* access — for example when `example.com` is a third-party-hosted marketing site whose
|
|
1045
|
+
* XSS surface you do not control. With `false`, configure the frontend origin explicitly
|
|
1046
|
+
* via `appUrl` or `allowedOrigins`.
|
|
1047
|
+
*
|
|
1048
|
+
* Has no effect on the localhost defaults applied for `env: 'local' | 'ci' | 'e2e'`, and
|
|
1049
|
+
* no effect when `appUrl` is set explicitly.
|
|
1050
|
+
*
|
|
1051
|
+
* @default true
|
|
1052
|
+
*
|
|
1053
|
+
* @since 11.27.5
|
|
1054
|
+
*/
|
|
1055
|
+
deriveAppUrl?: boolean;
|
|
1056
|
+
|
|
1035
1057
|
/**
|
|
1036
1058
|
* Whether CORS is enabled.
|
|
1037
1059
|
*
|