Repository navigation
Expand file tree
/
Copy pathnext.config.ts
More file actions
224 lines (218 loc) · 8.63 KB
/
Copy pathnext.config.ts
File metadata and controls
224 lines (218 loc) · 8.63 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
import type { NextConfig } from "next";
import { withSentryConfig } from "@sentry/nextjs/config";
import { legacyRedirects } from "./data/redirects";
import { sentryBuildOptions } from "./lib/sentry-build";
// Next.js dev tooling (HMR / dev overlays) evaluates code; production does not.
const isDev = process.env.NODE_ENV !== "production";
// Sentry browser SDK posts envelopes to the DSN's ingest origin. Deriving the
// origin from the configured DSN (rather than writing a wildcard) means the
// CSP only ever allows the exact ingest host this project uses — and none at
// all where no DSN is configured (local dev, tests). Non-HTTPS DSNs fail
// closed to null.
const sentryIngestOrigin = (() => {
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
if (!dsn) return null;
try {
const { protocol, origin } = new URL(dsn);
return protocol === "https:" ? origin : null;
} catch {
return null;
}
})();
// Content Security Policy — every origin below is confirmed by observed
// production traffic (Playwright network capture) or maps to a confirmed
// active integration. GTM-managed tags (GA4, Google Ads, Clarity, Meta,
// Bing, Cookiebot) are configured in the external GTM container
// (GTM-5PFMJFN); the Google-family hosts stay because that container — not
// this repo — decides which of them fire on a given event.
const cspDirectives: [string, string[]][] = [
["default-src", ["'self'"]],
[
"script-src",
[
"'self'",
// Next.js renders inline hydration/flight scripts and next/script
// inline initializers; removing 'unsafe-inline' requires nonce-based
// rendering, which forces per-request dynamic rendering — rejected.
"'unsafe-inline'",
// Dev-only: Next.js dev toolchain evaluates modules. Excluded from
// production builds.
...(isDev ? ["'unsafe-eval'"] : []),
// Checkfront droplet booking widget (interface--0.js). Bare host on
// purpose: the embed uses a protocol-relative URL, so an https-only
// source would break it on the http test/dev servers.
"seasaba.checkfront.com",
// GTM container + tags it injects (GA4, Google Ads, Clarity, Meta,
// Bing UET, Cookiebot uc.js). Tag set lives in the GTM dashboard.
"https://www.googletagmanager.com",
"https://www.google-analytics.com",
"https://www.googleadservices.com",
"https://*.doubleclick.net",
"https://www.google.com",
"https://*.clarity.ms",
"https://bat.bing.com",
"https://connect.facebook.net",
"https://consent.cookiebot.com",
"https://consentcdn.cookiebot.com",
// Respond.io Website Chat launcher (widget.js). The chat UI itself
// lives inside the cdn.respond.io iframe in frame-src below.
"https://cdn.respond.io",
// Vercel Live toolbar — only ever loaded on preview deployments.
"https://vercel.live",
],
],
[
// 'unsafe-inline' covers React style attributes; consentcdn hosts
// Cookiebot dialog stylesheets.
"style-src",
["'self'", "'unsafe-inline'", "https://consentcdn.cookiebot.com"],
],
[
// https: stays broad on purpose: GTM delivers tracking-pixel beacons to
// hosts that change with container config, and third-party content
// images are low-risk.
"img-src",
["'self'", "data:", "https:"],
],
// next/font self-hosts Open Sans at build time.
["font-src", ["'self'"]],
[
"connect-src",
[
"'self'",
"seasaba.checkfront.com",
// Firestore (anonymous read-only). Deliberately narrowed from
// *.googleapis.com — the browser SDK only talks to this host.
"https://firestore.googleapis.com",
// GTM tag destinations (GA4 collect, Ads conversions, Clarity
// ingest, Meta/Bing beacons, Cookiebot consent API).
"https://www.googletagmanager.com",
"https://www.google-analytics.com",
"https://*.google-analytics.com",
"https://analytics.google.com",
"https://region1.google-analytics.com",
"https://www.googleadservices.com",
"https://pagead2.googlesyndication.com",
"https://*.doubleclick.net",
"https://www.google.com",
"https://*.clarity.ms",
"https://bat.bing.com",
// UET consent/beacon endpoint: bat.js posts consent defaults and
// updates to bat.bing.net/actionp, a sibling host to bat.bing.com
// (#169). Exact origin only — no *.bing.net wildcard.
"https://bat.bing.net",
"https://connect.facebook.net",
"https://www.facebook.com",
"https://consent.cookiebot.com",
"https://consentcdn.cookiebot.com",
// Respond.io Website Chat remote-config fetch
// (GET /webchat/connect?cId=...) issued by widget.js in the top
// frame — the only respond.io call our CSP governs. The widget's
// APIs, WebSocket, fonts and assets all run inside its iframe and
// are governed by that document's own CSP.
"https://service.respond.io",
// Sentry ingest — exactly the origin of NEXT_PUBLIC_SENTRY_DSN, added
// only when a DSN is configured (#129). No *.sentry.io wildcard.
...(sentryIngestOrigin ? [sentryIngestOrigin] : []),
],
],
[
"frame-src",
[
"'self'",
"seasaba.checkfront.com",
// GTM noscript iframe.
"https://www.googletagmanager.com",
// Dive-site video embeds (data/dive-site-videos.ts).
"https://www.youtube.com",
// Google Ads conversion iframes (GTM-managed).
"https://www.google.com",
"https://*.doubleclick.net",
// Cookiebot consent banner/dialog iframe.
"https://consentcdn.cookiebot.com",
// Respond.io Website Chat — the launcher and chat window are a
// single iframe (chat.html) injected by widget.js.
"https://cdn.respond.io",
],
],
["media-src", ["'self'"]],
["object-src", ["'none'"]],
["manifest-src", ["'self'"]],
["worker-src", ["'self'"]],
["frame-ancestors", ["'self'"]],
["base-uri", ["'self'"]],
["form-action", ["'self'"]],
// Deliberately omitted: upgrade-insecure-requests upgrades subresource
// requests even on the local http test server under WebKit, which breaks
// the e2e suite — and the site is already HSTS-enforced in production.
];
const csp = cspDirectives.map(([k, v]) => [k, ...v].join(" ")).join("; ");
const nextConfig: NextConfig = {
// Required for app/global-not-found.tsx — the site uses multiple root
// layouts (per-locale subtrees), so there is no single root layout to
// compose a global 404 from (#150).
experimental: {
globalNotFound: true,
},
async redirects() {
return legacyRedirects;
},
async headers() {
return [
{
source: "/:path*",
headers: [
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
{
key: "X-Frame-Options",
value: "SAMEORIGIN",
},
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
},
// autoplay/fullscreen/picture-in-picture/accelerometer/gyroscope
// are intentionally absent or already delegated: the YouTube
// dive-site embed requests them via its allow attribute, and the
// existing accelerometer/gyroscope blocks do not break playback.
{
key: "Permissions-Policy",
value:
"accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=(), browsing-topics=()",
},
{
// same-origin-allow-popups (not bare same-origin) so
// Checkfront's redirect/popup hand-offs keep working.
key: "Cross-Origin-Opener-Policy",
value: "same-origin-allow-popups",
},
{
key: "Content-Security-Policy",
value: csp,
},
],
},
];
},
};
// Sentry build integration (#129 runtime, #130 releases + source maps).
// org/authToken are env-wired here; the release/source-map/failure policy
// lives in lib/sentry-build.ts so it is unit-testable.
//
// Release identity is deliberately NOT set: the SDK resolves it from
// VERCEL_GIT_COMMIT_SHA on Vercel (then git HEAD) and injects it into the
// bundles as `_sentryRelease`, so the event release, the uploaded source
// maps and the deployed commit are the same SHA. SENTRY_AUTH_TOKEN is a
// build-time secret only — never NEXT_PUBLIC_*.
export default withSentryConfig(nextConfig, {
org: process.env.SENTRY_ORG,
authToken: process.env.SENTRY_AUTH_TOKEN,
...sentryBuildOptions(),
});