Repository navigation
Expand file tree
/
Copy pathproxy.ts
More file actions
254 lines (239 loc) · 12.2 KB
/
Copy pathproxy.ts
File metadata and controls
254 lines (239 loc) · 12.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
import { getSessionCookie } from "better-auth/cookies";
import { NextRequest, NextResponse } from "next/server";
import createIntlMiddleware from "next-intl/middleware";
import { routing } from "@/i18n/routing";
import { defaultLocale, isSupportedLocale } from "@/config/i18n-config";
import { isLocalizedSecurePath } from "@/config/route-regions";
import { applyClientIpHeader } from "@/lib/client-ip";
import { REQUEST_PATH_HEADER, REQUEST_TARGET_HEADER } from "@/lib/request-id";
import { ORG_SIGNUP_HINT_COOKIE } from "@/lib/scoped-auth";
const intlMiddleware = createIntlMiddleware(routing);
function getLocaleFromPath(pathname: string): string {
const locale = pathname.split("/")[1] ?? "";
return isSupportedLocale(locale) ? locale : defaultLocale;
}
function safeDecode(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
/**
* Carries the organization scope of a sign-in/up page across the social OAuth
* round trip (which has no request body) via a short-lived cookie. Set on a
* scoped page (`/sign-in/<org>` or `?org=<slug>`), cleared on a plain one, so
* the hint always matches the page the visitor launches social sign-in from —
* the sign-in provisioning hook reads it for a brand-new social user. Placement
* only: provisioning still applies the target org's signup policy
* (auth-signup-policy.md §7). Never throws — a proxy exception breaks the page.
*/
function applyOrgSignupHint(request: NextRequest, response: NextResponse): void {
const segments = request.nextUrl.pathname.split("/").filter(Boolean);
if (!isSupportedLocale(segments[0])) {
return;
}
const page = segments[1];
if (page !== "sign-in" && page !== "sign-up") {
return;
}
// Path scope (`/sign-in/<org>`) or query scope (`?org=`); path wins.
const hint =
page === "sign-in" && segments[2]
? safeDecode(segments[2])
: (request.nextUrl.searchParams.get("org") ?? "");
if (hint) {
response.cookies.set(ORG_SIGNUP_HINT_COOKIE, hint, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 600, // 10 min — a generous OAuth round-trip window
});
} else if (request.cookies.has(ORG_SIGNUP_HINT_COOKIE)) {
// Plain sign-in/up: drop any stale hint so the scope matches the page.
response.cookies.delete(ORG_SIGNUP_HINT_COOKIE);
}
}
/**
* Builds the per-request Content-Security-Policy (A7 cutover, review #34).
*
* Enforcing mode: `script-src` drops `'unsafe-inline'` / `'unsafe-eval'` in
* favour of a per-request `'nonce-…'` plus `'strict-dynamic'`. Next.js reads
* the nonce off the request's `Content-Security-Policy` header and stamps it
* onto every framework `<script>` it injects (hydration/RSC payloads), and
* `'strict-dynamic'` lets those trusted scripts pull in the chunked bundles
* without an allowlist. This is the layer that actually stops reflected-XSS
* script execution — the prior policy allowed any inline script.
*
* Deliberate exception — `style-src` keeps `'unsafe-inline'`. React renders
* `style={{…}}` props as inline `style="…"` ATTRIBUTES, which a nonce cannot
* cover (nonces apply to `<script>`/`<style>` elements, not attributes).
* Concretely, recharts (via `metric-bar-chart.tsx`) sets inline style
* attributes — it emits no `<style>` element — and the theme provider
* (`theme-provider.tsx`) appends a transient `<style>` element while
* switching themes (review #133). Style injection is a far weaker vector than
* script injection, so allowing inline styles is the standard pragmatic trade
* to keep the UI intact.
*
* Development keeps the permissive `script-src` because Next's HMR / React
* Fast Refresh runtime relies on `eval` and unnonced inline bootstrap; the
* strict nonce policy only engages in production builds. `report-uri` /
* `report-to` are retained through the switch so any regression still lands in
* the hardened sink at `/api/security/csp-report`.
*/
function buildContentSecurityPolicy(nonce: string): string {
const scriptSrc =
process.env.NODE_ENV === "production"
? `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`
: "script-src 'self' 'unsafe-inline' 'unsafe-eval'";
return [
"default-src 'self'",
"base-uri 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
"form-action 'self'",
"img-src 'self' data: blob:",
"font-src 'self'",
"style-src 'self' 'unsafe-inline'",
scriptSrc,
// Sentry ingest — only contacted when observability is enabled.
"connect-src 'self' https://*.ingest.sentry.io https://*.sentry.io",
"worker-src 'self' blob:",
"report-uri /api/security/csp-report",
"report-to csp-endpoint",
// Only meaningful in production (real TLS). In dev, a non-localhost host
// (e.g. devresponse.local subdomain SSO testing) is not a "trustworthy
// origin", so the directive would silently upgrade every subresource and
// form POST to https:// and fail against the plain-http dev server
// (localhost itself is exempt, which is why this never bites there).
...(process.env.NODE_ENV === "production" ? ["upgrade-insecure-requests"] : []),
].join("; ");
}
/** A fresh base64 nonce per request (Edge runtime: `crypto`/`btoa` are globals). */
function generateNonce(): string {
return btoa(crypto.randomUUID());
}
/**
* proxy
*
* Combines four concerns:
* 0. The trusted client IP (review #35): `x-drk-client-ip` is derived with
* the app's `CLIENT_IP_SOURCE` / `TRUSTED_PROXY_COUNT` model (F-17) and
* ALWAYS overwritten on the forwarded request — for page renders (whose
* server actions call `auth.api.*`) and for the Better Auth catch-all
* (`/api/auth/*`, matched explicitly below), which reads ONLY that
* header for its sign-in/reset limiter and `session.ipAddress`. A client
* cannot inject it, and multi-hop chains resolve to the same hop the
* app's own limiter trusts. The stamped value is normalized (F-16): a
* port is stripped, IPv4-mapped IPv6 becomes IPv4, and a hop that is not
* an IP address removes the header. This is defence in depth, not the only
* line: every server-side `auth.api.*` call site (SSO consume, the
* admin wrappers, session reads) and the catch-all route itself
* re-derive the header via `withTrustedClientIp`, so routes outside
* this matcher (`/api/sso/*`, `/api/administrator/*`) are covered too.
* 1. A per-request CSP nonce: an enforcing `Content-Security-Policy` is set
* on every response, and the nonce is threaded into the request headers
* (`x-nonce` + the CSP itself) so Next.js — and the root layout, which
* hands it to the server theme script — can stamp it onto inline scripts.
* 2. next-intl locale routing (rewrites `/` to `/<defaultLocale>` and
* validates the locale segment).
* 3. Early redirect for localized secure browser paths
* (`isLocalizedSecurePath`, classified by `route-regions.ts`) when
* no Better Auth session cookie is present, so unauthenticated
* users never see the secure layout shell flash.
*
* This is NOT the authorization boundary — it intentionally avoids any
* database calls. The real check happens in `requireSecureSession`.
*
* Note: the file is named `proxy.ts` per Next.js 16. Only `proxy` is
* exported — defining a `middleware` alias in the same file is
* forbidden by Next.js 16 and would fail the build.
*/
export function proxy(request: NextRequest) {
const { pathname, search } = request.nextUrl;
const nonce = generateNonce();
const csp = buildContentSecurityPolicy(nonce);
// Forwarded request headers: the trusted client IP is stamped first so
// every branch below hands Better Auth the same derivation.
const requestHeaders = new Headers(request.headers);
applyClientIpHeader(requestHeaders);
// `x-drk-pathname` is OURS (review #74). Drop any inbound copy here, before
// any branch returns, so no request the proxy forwards — including the
// `/api/*` early return below, which stamps nothing — can carry a
// client-chosen value into `headers()`. The follow-up review to #74 found
// the original code set it on the localized branch only, which left every
// other forwarded request believing the browser. Its query-carrying
// sibling (F-70) gets the same treatment.
requestHeaders.delete(REQUEST_PATH_HEADER);
requestHeaders.delete(REQUEST_TARGET_HEADER);
// Only `/api/auth/*` is matched (Better Auth needs the client-IP header);
// other API routes are excluded by the matcher, but defend in depth.
if (pathname.startsWith("/api/")) {
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set("Content-Security-Policy", csp);
return response;
}
const isSecurePage = isLocalizedSecurePath(pathname);
if (isSecurePage) {
const sessionCookie = getSessionCookie(request);
if (!sessionCookie) {
const locale = getLocaleFromPath(pathname);
const url = new URL(`/${locale}/sign-in`, request.url);
url.searchParams.set("returnTo", `${pathname}${search}`);
const response = NextResponse.redirect(url);
response.headers.set("Content-Security-Policy", csp);
return response;
}
}
// Forward the nonce + CSP into the rendered request so Next.js applies the
// nonce to its injected scripts (next-intl copies these request headers onto
// the rewrite/next it returns — see its middleware), then enforce the policy
// on the outgoing response.
requestHeaders.set("x-nonce", nonce);
requestHeaders.set("Content-Security-Policy", csp);
// Review #74: name the page for server-side guards. An RSC has no request
// object, so an audited permission denial can only say WHICH page was probed
// if the pathname rides the request headers. Any inbound copy was deleted
// above, so on a path the matcher covers this value is the proxy's own —
// but the matcher cannot cover every path (see `config` below), so
// consumers still normalize it (`normalizeRequestPath`) and treat it as a
// hint rather than evidence.
requestHeaders.set(REQUEST_PATH_HEADER, pathname);
// F-70: a cookie that is present but no longer names a live session gets
// past the branch above, so the sign-in bounce happens later, in
// `requireSecureSession`. Hand it the same `returnTo` the branch above would
// have used, query included. Secure pages only: nothing else reads it, and
// other pages' queries can carry invite and reset tokens.
if (isSecurePage) requestHeaders.set(REQUEST_TARGET_HEADER, `${pathname}${search}`);
const response = intlMiddleware(new NextRequest(request, { headers: requestHeaders }));
response.headers.set("Content-Security-Policy", csp);
applyOrgSignupHint(request, response);
return response;
}
export const config = {
matcher: [
"/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)",
// The entry above skips ANY path containing a dot (the cheap "looks like
// a static asset" heuristic). Next still routes `/en/app/…/a.b` to the
// RSC, so before this entry existed such a URL reached the secure tree
// with NO proxy pass at all: no cookie pre-redirect, no CSP, and — the
// reason it was found — raw client headers, including a forged
// `x-drk-pathname` that the RSC denial audit then recorded verbatim.
// The localized secure tree is where every audited RSC guard lives and
// holds no static assets, so matching it unconditionally closes that gap
// (a dotted segment there is a user-supplied id, never a file).
"/:locale/app/:path*",
// F-106: the same gap on the one localized page OUTSIDE the secure tree
// that takes a dynamic segment. `sign-in/[org]` renders the full password
// form for any segment, so `/en/sign-in/a.b` served it with no CSP and no
// proxy headers. Every other page there is static, so a dotted path under
// it is a 404 with nothing to reflect. A new dynamic page outside
// `/app` needs an entry like this one; tests/unit/proxy-request-path.test.ts
// walks every localized page and fails until it has one.
"/:locale/sign-in/:path*",
// Better Auth's catch-all must pass through the proxy so the trusted
// client-IP header is set before its rate limiter runs (review #35).
"/api/auth/:path*",
],
};