-
-
Notifications
You must be signed in to change notification settings - Fork 66
Expand file tree
/
Copy pathproxy.ts
More file actions
169 lines (148 loc) · 6.67 KB
/
proxy.ts
File metadata and controls
169 lines (148 loc) · 6.67 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
import { type NextRequest, NextResponse } from "next/server";
import createIntlMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
import { getEnabledPluginFrameOrigins } from "./lib/admin/csp-frame-origins";
import { configManager } from "./lib/admin/config-manager";
import { detectSetupState } from "./lib/setup/state";
const intlMiddleware = createIntlMiddleware(routing);
// Next 16's Proxy always runs on Node.js runtime and route-segment config
// (e.g. `export const config = { matcher }`) is no longer allowed in the
// proxy file. We replicate the previous matcher inline by short-circuiting
// requests for API routes, Next internals and static assets.
const PROXY_SKIP_PATTERN = /^\/(?:api|_next)(?:\/|$)|\.[^/]+$/;
function isSetupPath(pathname: string): boolean {
return (
pathname === "/setup" ||
pathname.startsWith("/setup/") ||
pathname.startsWith("/api/setup")
);
}
export async function proxy(request: NextRequest) {
// Resolve setup state before deciding what to skip. The first call after
// boot triggers the config load; subsequent calls are in-memory.
await configManager.ensureLoaded();
const setupState = detectSetupState();
const pathname = request.nextUrl.pathname;
if (setupState === "bootstrap") {
// Wizard active. Redirect HTML pages to /setup; let asset/internal
// requests through so the wizard UI can render. Block non-setup APIs
// with a 503 so cached SPA code doesn't silently call them.
const allowed =
isSetupPath(pathname) ||
pathname === "/api/health" ||
pathname.startsWith("/_next/") ||
pathname.startsWith("/branding/") ||
// Public read endpoint - serves wizard-uploaded branding assets so
// image previews work during the wizard. No auth on the GET route.
pathname.startsWith("/api/admin/branding/") ||
/\.[^/]+$/.test(pathname);
if (!allowed) {
if (pathname.startsWith("/api/")) {
return new NextResponse(
JSON.stringify({ error: "setup_required", message: "Initial setup has not completed." }),
{ status: 503, headers: { "content-type": "application/json" } },
);
}
const url = request.nextUrl.clone();
url.pathname = "/setup";
url.search = request.nextUrl.search;
return NextResponse.redirect(url);
}
} else if (isSetupPath(pathname)) {
// Configured / env-managed: wizard is no longer reachable.
// - HTML /setup pages → redirect to admin login so users who reload
// the URL after setup don't see a dead "Not Found" page.
// - /api/setup/* → 404 (no reason to expose these endpoints).
if (pathname.startsWith("/api/setup")) {
return new NextResponse("Not Found", { status: 404 });
}
const url = request.nextUrl.clone();
url.pathname = "/admin/login";
url.search = "";
return NextResponse.redirect(url);
}
if (PROXY_SKIP_PATTERN.test(pathname)) {
return NextResponse.next();
}
const nonce = crypto.randomUUID();
const isDev = process.env.NODE_ENV === "development";
// The plugin-sandbox iframe document needs `'unsafe-eval'` to run plugin
// bundles via `new Function`. It is null-origin (sandbox="allow-scripts"),
// so the relaxation is scoped strictly to that document and never reaches
// the main app, plus it must be embeddable from `'self'`.
const isSandboxPath = pathname === "/plugin-sandbox" || pathname.startsWith("/plugin-sandbox/");
const scriptSrc = isSandboxPath
? `'self' 'nonce-${nonce}' 'unsafe-eval'`
: isDev
? `'self' 'nonce-${nonce}' 'unsafe-eval'`
: `'self' 'nonce-${nonce}'`;
const connectSrc = isDev ? `'self' http: https: ws: wss:` : `'self' https:`;
const frameAncestors = isSandboxPath
? `'self'`
: process.env.ALLOWED_FRAME_ANCESTORS?.trim() || "'none'";
// Plugins may declare iframe origins they need (e.g. for embedded video).
// Each origin is validated at install time and re-validated here.
const pluginFrameOrigins = await getEnabledPluginFrameOrigins();
const frameSrc =
pluginFrameOrigins.length > 0
? `frame-src 'self' ${pluginFrameOrigins.join(" ")}`
: `frame-src 'self'`;
const csp = [
`default-src 'self'`,
`script-src ${scriptSrc}`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: blob: https:`,
`font-src 'self'`,
`connect-src ${connectSrc}`,
frameSrc,
`object-src 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
`frame-ancestors ${frameAncestors}`,
`media-src 'self' blob:`,
].join("; ");
// Skip intl middleware for routes outside the localized app tree.
const isAdminRoute = pathname === '/admin' || pathname.startsWith('/admin/');
const isProtocolRoute = pathname === '/protocol' || pathname.startsWith('/protocol/');
const isSetupRoute = pathname === '/setup' || pathname.startsWith('/setup/');
// The plugin sandbox lives in its own root layout under app/(sandbox)/ and
// is not part of the localized tree. Letting next-intl rewrite the path to
// /en/plugin-sandbox 404s, which kills the iframe and disables every plugin.
const isSandboxRoute = isSandboxPath;
// When localePrefix is 'always', paths that already have a locale prefix
// (e.g. /en/settings) should not be re-processed by the intl middleware -
// doing so can trigger rewrite loops when combined with a proxy basePath.
const locales = routing.locales as readonly string[];
const hasLocalePrefix = locales.some(
(l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`)
);
let intlResponse: ReturnType<typeof intlMiddleware> | null = null;
if (!isAdminRoute && !isProtocolRoute && !isSetupRoute && !isSandboxRoute && !hasLocalePrefix) {
try {
intlResponse = intlMiddleware(request);
} catch (error) {
console.error('Locale middleware error:', error);
}
}
const response = intlResponse ?? NextResponse.next();
const existing = response.headers.get("x-middleware-override-headers");
response.headers.set(
"x-middleware-override-headers",
existing ? `${existing},x-nonce` : "x-nonce"
);
response.headers.set("x-middleware-request-x-nonce", nonce);
response.headers.set("X-Content-Type-Options", "nosniff");
// X-Frame-Options only supports DENY/SAMEORIGIN. When frame-ancestors
// specifies explicit origins, we rely solely on the CSP header.
if (frameAncestors === "'none'") {
response.headers.set("X-Frame-Options", "DENY");
}
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
response.headers.set("X-XSS-Protection", "0");
response.headers.set(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=(), payment=()"
);
response.headers.set("Content-Security-Policy", csp);
return response;
}