@akinon/next 2.0.16-rc.0 → 2.0.16-rc.1
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/CHANGELOG.md +8 -0
- package/api/auth.ts +38 -10
- package/api/client.ts +94 -0
- package/middlewares/default.ts +255 -249
- package/package.json +2 -2
- package/types/index.ts +32 -9
- package/utils/csrf.ts +37 -0
- package/utils/get-root-hostname.ts +18 -0
- package/utils/index.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# @akinon/next
|
|
2
2
|
|
|
3
|
+
## 2.0.16-rc.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 378607d1: ZERO-4430: Harden CSRF handling for the BFF proxy
|
|
8
|
+
|
|
9
|
+
When `settings.csrf.httpOnly` is enabled, the Django `csrftoken` cookie is set `HttpOnly` + `Secure` + `SameSite=Lax` and the token is never exposed to the browser. The Next.js proxy validates the request `Origin` and injects the `x-csrftoken` header server-side from the cookie before forwarding state-changing requests, instead of round-tripping the token through client JavaScript.
|
|
10
|
+
|
|
3
11
|
## 2.0.16-rc.0
|
|
4
12
|
|
|
5
13
|
### Patch Changes
|
package/api/auth.ts
CHANGED
|
@@ -6,7 +6,10 @@ import Settings from 'settings';
|
|
|
6
6
|
import { urlLocaleMatcherRegex } from '../utils';
|
|
7
7
|
import logger from '@akinon/next/utils/log';
|
|
8
8
|
import { AuthError } from '../types';
|
|
9
|
-
import getRootHostname
|
|
9
|
+
import getRootHostname, {
|
|
10
|
+
getRequestRootHostname
|
|
11
|
+
} from '../utils/get-root-hostname';
|
|
12
|
+
import { getCsrfCookieFlags } from '../utils/csrf';
|
|
10
13
|
import { LocaleUrlStrategy } from '../localization';
|
|
11
14
|
import { cookies, headers } from 'next/headers';
|
|
12
15
|
|
|
@@ -222,12 +225,17 @@ const getDefaultAuthConfig = () => {
|
|
|
222
225
|
logger.debug(`Login/Register response: ${JSON.stringify(response)}`);
|
|
223
226
|
|
|
224
227
|
let sessionId = '';
|
|
228
|
+
let rotatedCsrfToken = '';
|
|
225
229
|
const setCookieHeader = apiRequest.headers.get('set-cookie');
|
|
226
230
|
if (setCookieHeader) {
|
|
227
231
|
sessionId =
|
|
228
232
|
setCookieHeader
|
|
229
233
|
.match(/osessionid=\w+/)?.[0]
|
|
230
234
|
.replace(/osessionid=/, '') || '';
|
|
235
|
+
rotatedCsrfToken =
|
|
236
|
+
setCookieHeader
|
|
237
|
+
.match(/csrftoken=[^;,\s]+/)?.[0]
|
|
238
|
+
.replace(/csrftoken=/, '') || '';
|
|
231
239
|
|
|
232
240
|
logger.debug(`Login/Register session id: ${sessionId}`);
|
|
233
241
|
} else {
|
|
@@ -258,6 +266,14 @@ const getDefaultAuthConfig = () => {
|
|
|
258
266
|
|
|
259
267
|
cookieStore.set('osessionid', sessionId, cookieOptions);
|
|
260
268
|
cookieStore.set('sessionid', sessionId, cookieOptions);
|
|
269
|
+
|
|
270
|
+
if (rotatedCsrfToken) {
|
|
271
|
+
cookieStore.set('csrftoken', rotatedCsrfToken, {
|
|
272
|
+
path: '/',
|
|
273
|
+
...getCsrfCookieFlags(),
|
|
274
|
+
...(rootHostname ? { domain: rootHostname } : {})
|
|
275
|
+
});
|
|
276
|
+
}
|
|
261
277
|
}
|
|
262
278
|
|
|
263
279
|
if (!response.key) {
|
|
@@ -314,14 +330,16 @@ const getDefaultAuthConfig = () => {
|
|
|
314
330
|
},
|
|
315
331
|
signOut: async () => {
|
|
316
332
|
const cookieStore = await cookies();
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
maxAge: 0
|
|
320
|
-
});
|
|
321
|
-
cookieStore.set('sessionid', '', {
|
|
333
|
+
const rootHostname = getRequestRootHostname(await headers());
|
|
334
|
+
const expireOptions = {
|
|
322
335
|
path: '/',
|
|
323
|
-
maxAge: 0
|
|
324
|
-
|
|
336
|
+
maxAge: 0,
|
|
337
|
+
...(rootHostname ? { domain: rootHostname } : {})
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
cookieStore.set('osessionid', '', expireOptions);
|
|
341
|
+
cookieStore.set('sessionid', '', expireOptions);
|
|
342
|
+
cookieStore.set('csrftoken', '', expireOptions);
|
|
325
343
|
logger.debug('Successfully signed out');
|
|
326
344
|
}
|
|
327
345
|
},
|
|
@@ -575,9 +593,19 @@ const defaultNextAuthOptionsV4 = (req: any, res: any) => {
|
|
|
575
593
|
logger.debug('Successfully signed in');
|
|
576
594
|
},
|
|
577
595
|
signOut: () => {
|
|
596
|
+
const rootHostname = getRequestRootHostname({
|
|
597
|
+
get: (name) => {
|
|
598
|
+
const v = req.headers[name];
|
|
599
|
+
return Array.isArray(v) ? v[0] : (v ?? null);
|
|
600
|
+
}
|
|
601
|
+
});
|
|
602
|
+
const domainAttr = rootHostname ? `; Domain=${rootHostname}` : '';
|
|
603
|
+
const expiry = `Path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT${domainAttr}`;
|
|
604
|
+
|
|
578
605
|
res.setHeader('Set-Cookie', [
|
|
579
|
-
`osessionid=;
|
|
580
|
-
`sessionid=;
|
|
606
|
+
`osessionid=; ${expiry}`,
|
|
607
|
+
`sessionid=; ${expiry}`,
|
|
608
|
+
`csrftoken=; ${expiry}`
|
|
581
609
|
]);
|
|
582
610
|
logger.debug('Successfully signed out');
|
|
583
611
|
}
|
package/api/client.ts
CHANGED
|
@@ -8,6 +8,64 @@ import { cookies } from 'next/headers';
|
|
|
8
8
|
import getRootHostname from '../utils/get-root-hostname';
|
|
9
9
|
import { LocaleUrlStrategy } from '../localization';
|
|
10
10
|
import { fixtureManager, MockMode } from '../lib/fixture-manager';
|
|
11
|
+
import { user } from '../data/urls';
|
|
12
|
+
import { getCsrfCookieFlags, isCsrfHttpOnly } from '../utils/csrf';
|
|
13
|
+
|
|
14
|
+
const CSRF_TOKEN_SLUG = user.csrfToken.replace(/^\//, '');
|
|
15
|
+
|
|
16
|
+
const STATE_CHANGING_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];
|
|
17
|
+
|
|
18
|
+
function getProxyHosts(req: Request): string[] {
|
|
19
|
+
const hosts = new Set<string>();
|
|
20
|
+
const forwarded =
|
|
21
|
+
req.headers.get('x-forwarded-host') || req.headers.get('host');
|
|
22
|
+
if (forwarded) {
|
|
23
|
+
hosts.add(forwarded.split(':')[0].toLowerCase());
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
if (process.env.NEXT_PUBLIC_URL) {
|
|
27
|
+
hosts.add(new URL(process.env.NEXT_PUBLIC_URL).hostname.toLowerCase());
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// ignore malformed NEXT_PUBLIC_URL
|
|
31
|
+
}
|
|
32
|
+
return Array.from(hosts);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Next.js-layer CSRF defense for the BFF proxy. State-changing requests must
|
|
37
|
+
* originate from our own app: when an `Origin` header is present it has to
|
|
38
|
+
* resolve to the proxy host (or, under the subdomain locale strategy, the
|
|
39
|
+
* same registrable domain). Requests without an `Origin` (non-browser
|
|
40
|
+
* clients, same-origin navigations) fall back to the `SameSite=Lax` cookie
|
|
41
|
+
* guarantee. Only enforced when CSRF hardening is enabled.
|
|
42
|
+
*/
|
|
43
|
+
function isOriginAllowed(req: Request): boolean {
|
|
44
|
+
const origin = req.headers.get('origin');
|
|
45
|
+
if (!origin) return true;
|
|
46
|
+
|
|
47
|
+
let originHost: string;
|
|
48
|
+
try {
|
|
49
|
+
originHost = new URL(origin).hostname.toLowerCase();
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const allowedHosts = getProxyHosts(req);
|
|
55
|
+
if (allowedHosts.includes(originHost)) return true;
|
|
56
|
+
|
|
57
|
+
if (settings.localization.localeUrlStrategy === LocaleUrlStrategy.Subdomain) {
|
|
58
|
+
const originRoot = getRootHostname(`https://${originHost}`);
|
|
59
|
+
return (
|
|
60
|
+
!!originRoot &&
|
|
61
|
+
allowedHosts.some(
|
|
62
|
+
(host) => getRootHostname(`https://${host}`) === originRoot
|
|
63
|
+
)
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
11
69
|
|
|
12
70
|
interface RouteParams {
|
|
13
71
|
params: {
|
|
@@ -103,6 +161,28 @@ async function proxyRequest(...args) {
|
|
|
103
161
|
});
|
|
104
162
|
}
|
|
105
163
|
|
|
164
|
+
// CSRF hardening (BFF model): when the csrftoken cookie is HttpOnly the
|
|
165
|
+
// browser can no longer mirror it into the `x-csrftoken` header, so the
|
|
166
|
+
// proxy validates the request origin and injects the header server-side
|
|
167
|
+
// from the cookie that the browser sent with this request.
|
|
168
|
+
if (isCsrfHttpOnly() && STATE_CHANGING_METHODS.includes(req.method)) {
|
|
169
|
+
if (!isOriginAllowed(req)) {
|
|
170
|
+
logger.warn('Client Proxy Request - Blocked cross-origin request', {
|
|
171
|
+
url: req.url,
|
|
172
|
+
origin: req.headers.get('origin')
|
|
173
|
+
});
|
|
174
|
+
return NextResponse.json(
|
|
175
|
+
{ detail: 'CSRF origin check failed.' },
|
|
176
|
+
{ status: 403 }
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const csrfToken = nextCookies.get('csrftoken')?.value;
|
|
181
|
+
if (csrfToken) {
|
|
182
|
+
fetchOptions.headers['x-csrftoken'] = csrfToken;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
106
186
|
if (options.contentType) {
|
|
107
187
|
fetchOptions.headers['Content-Type'] = options.contentType;
|
|
108
188
|
}
|
|
@@ -240,11 +320,25 @@ async function proxyRequest(...args) {
|
|
|
240
320
|
if (!cookie.domain && rootHostname) {
|
|
241
321
|
cookie.domain = rootHostname;
|
|
242
322
|
}
|
|
323
|
+
if (cookie.name === 'csrftoken') {
|
|
324
|
+
const flags = getCsrfCookieFlags();
|
|
325
|
+
if (flags.httpOnly) {
|
|
326
|
+
cookie.httpOnly = true;
|
|
327
|
+
cookie.secure = flags.secure;
|
|
328
|
+
cookie.sameSite = flags.sameSite;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
243
331
|
return formatCookieString(cookie);
|
|
244
332
|
})
|
|
245
333
|
.join(', ');
|
|
246
334
|
}
|
|
247
335
|
|
|
336
|
+
if (slug === CSRF_TOKEN_SLUG) {
|
|
337
|
+
responseHeaders['Cache-Control'] =
|
|
338
|
+
'private, no-store, no-cache, must-revalidate';
|
|
339
|
+
responseHeaders['Pragma'] = 'no-cache';
|
|
340
|
+
}
|
|
341
|
+
|
|
248
342
|
return NextResponse.json(
|
|
249
343
|
options.responseType === 'text' ? { result: response } : response,
|
|
250
344
|
{ status: request.status, headers: responseHeaders }
|
package/middlewares/default.ts
CHANGED
|
@@ -17,8 +17,12 @@ import {
|
|
|
17
17
|
withMasterpassRestCallback,
|
|
18
18
|
withBfcacheHeaders
|
|
19
19
|
} from '.';
|
|
20
|
-
import { urlLocaleMatcherRegex } from '../utils';
|
|
21
|
-
import {
|
|
20
|
+
import { getCsrfCookieFlags, urlLocaleMatcherRegex } from '../utils';
|
|
21
|
+
import {
|
|
22
|
+
getPzSegmentsConfig,
|
|
23
|
+
encodePzValue,
|
|
24
|
+
isLegacyMode
|
|
25
|
+
} from '../utils/pz-segments';
|
|
22
26
|
import withCurrency from './currency';
|
|
23
27
|
import withLocale from './locale';
|
|
24
28
|
import logger from '../utils/log';
|
|
@@ -262,98 +266,98 @@ const withPzDefault =
|
|
|
262
266
|
req: PzNextRequest,
|
|
263
267
|
event: NextFetchEvent
|
|
264
268
|
) => {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
269
|
+
let middlewareResult: NextResponse | void =
|
|
270
|
+
NextResponse.next();
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
const { locale, prettyUrl, currency } =
|
|
274
|
+
req.middlewareParams.rewrites;
|
|
275
|
+
const { defaultLocaleValue } =
|
|
276
|
+
Settings.localization;
|
|
277
|
+
const url = req.nextUrl.clone();
|
|
278
|
+
const pathnameWithoutLocale =
|
|
279
|
+
url.pathname.replace(
|
|
280
|
+
urlLocaleMatcherRegex,
|
|
281
|
+
''
|
|
282
|
+
);
|
|
279
283
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
middlewareResult.headers.get(
|
|
294
|
-
'x-middleware-rewrite'
|
|
295
|
-
)
|
|
296
|
-
) {
|
|
297
|
-
const rewriteUrl = new URL(
|
|
284
|
+
middlewareResult = (await middleware(
|
|
285
|
+
req,
|
|
286
|
+
event
|
|
287
|
+
)) as NextResponse | void;
|
|
288
|
+
|
|
289
|
+
let customRewriteUrlDiff = '';
|
|
290
|
+
|
|
291
|
+
if (
|
|
292
|
+
middlewareResult instanceof
|
|
293
|
+
NextResponse &&
|
|
294
|
+
middlewareResult.headers.get(
|
|
295
|
+
'pz-override-response'
|
|
296
|
+
) &&
|
|
298
297
|
middlewareResult.headers.get(
|
|
299
298
|
'x-middleware-rewrite'
|
|
300
299
|
)
|
|
301
|
-
)
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
''
|
|
300
|
+
) {
|
|
301
|
+
const rewriteUrl = new URL(
|
|
302
|
+
middlewareResult.headers.get(
|
|
303
|
+
'x-middleware-rewrite'
|
|
304
|
+
)
|
|
307
305
|
);
|
|
308
|
-
|
|
306
|
+
const originalUrl = new URL(req.url);
|
|
307
|
+
customRewriteUrlDiff =
|
|
308
|
+
rewriteUrl.pathname.replace(
|
|
309
|
+
originalUrl.pathname,
|
|
310
|
+
''
|
|
311
|
+
);
|
|
312
|
+
}
|
|
309
313
|
|
|
310
|
-
|
|
314
|
+
let ordersPrefix: string;
|
|
311
315
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
}`.replace(/\/+/g, '/');
|
|
318
|
-
ordersPrefix = `/${currency}/orders`;
|
|
319
|
-
} else {
|
|
320
|
-
const pzConfig =
|
|
321
|
-
getPzSegmentsConfig(Settings);
|
|
322
|
-
const fullUrlPath =
|
|
323
|
-
`${customRewriteUrlDiff}${
|
|
316
|
+
if (isLegacyMode(Settings)) {
|
|
317
|
+
url.basePath = `/${commerceUrl}`;
|
|
318
|
+
url.pathname = `/${
|
|
319
|
+
locale.length ? `${locale}/` : ''
|
|
320
|
+
}${currency}/${customRewriteUrlDiff}${
|
|
324
321
|
prettyUrl ?? pathnameWithoutLocale
|
|
325
322
|
}`.replace(/\/+/g, '/');
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
323
|
+
ordersPrefix = `/${currency}/orders`;
|
|
324
|
+
} else {
|
|
325
|
+
const pzConfig =
|
|
326
|
+
getPzSegmentsConfig(Settings);
|
|
327
|
+
const fullUrlPath =
|
|
328
|
+
`${customRewriteUrlDiff}${
|
|
329
|
+
prettyUrl ?? pathnameWithoutLocale
|
|
330
|
+
}`.replace(/\/+/g, '/');
|
|
331
|
+
const fullUrl = `${req.nextUrl.origin}${fullUrlPath}`;
|
|
332
|
+
const resolvedLocale =
|
|
333
|
+
locale?.length > 0
|
|
334
|
+
? locale
|
|
335
|
+
: Settings.localization
|
|
336
|
+
.defaultLocaleValue;
|
|
337
|
+
const resolveContext = {
|
|
338
|
+
req,
|
|
339
|
+
event,
|
|
340
|
+
url,
|
|
341
|
+
locale: resolvedLocale,
|
|
342
|
+
currency,
|
|
343
|
+
pathname: pathnameWithoutLocale
|
|
344
|
+
};
|
|
345
|
+
const customSegments =
|
|
346
|
+
pzConfig.segments.filter(
|
|
347
|
+
(seg) =>
|
|
348
|
+
seg.name !== 'locale' &&
|
|
349
|
+
seg.name !== 'currency' &&
|
|
350
|
+
seg.name !== 'url'
|
|
351
|
+
);
|
|
352
|
+
const segmentValues: Record<
|
|
353
|
+
string,
|
|
354
|
+
string
|
|
355
|
+
> = {
|
|
356
|
+
locale: resolvedLocale,
|
|
357
|
+
currency,
|
|
358
|
+
url: encodeURIComponent(fullUrl),
|
|
359
|
+
...Object.fromEntries(
|
|
360
|
+
customSegments.map((seg) => [
|
|
357
361
|
seg.name,
|
|
358
362
|
req.middlewareParams.rewrites[
|
|
359
363
|
seg.name
|
|
@@ -362,105 +366,121 @@ const withPzDefault =
|
|
|
362
366
|
? seg.resolve(resolveContext)
|
|
363
367
|
: '')
|
|
364
368
|
])
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
const pzValue = encodePzValue(
|
|
369
|
-
segmentValues,
|
|
370
|
-
pzConfig
|
|
371
|
-
);
|
|
369
|
+
)
|
|
370
|
+
};
|
|
372
371
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
'/'
|
|
372
|
+
const pzValue = encodePzValue(
|
|
373
|
+
segmentValues,
|
|
374
|
+
pzConfig
|
|
377
375
|
);
|
|
378
|
-
ordersPrefix = `/${pzValue}/orders`;
|
|
379
|
-
}
|
|
380
376
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
)
|
|
389
|
-
)
|
|
390
|
-
) {
|
|
391
|
-
url.pathname =
|
|
392
|
-
url.pathname +
|
|
393
|
-
(/\/$/.test(url.pathname) ? '' : '/') +
|
|
394
|
-
`searchparams|${encodeURIComponent(
|
|
395
|
-
url.searchParams.toString()
|
|
396
|
-
)}`;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
Settings.rewrites.forEach((rewrite) => {
|
|
400
|
-
url.pathname = url.pathname.replace(
|
|
401
|
-
rewrite.source,
|
|
402
|
-
rewrite.destination
|
|
403
|
-
);
|
|
404
|
-
});
|
|
377
|
+
url.pathname =
|
|
378
|
+
`/${pzValue}/${fullUrlPath}`.replace(
|
|
379
|
+
/\/+/g,
|
|
380
|
+
'/'
|
|
381
|
+
);
|
|
382
|
+
ordersPrefix = `/${pzValue}/orders`;
|
|
383
|
+
}
|
|
405
384
|
|
|
406
|
-
// if middleware.ts has a return value for current url
|
|
407
|
-
if (
|
|
408
|
-
middlewareResult instanceof NextResponse
|
|
409
|
-
) {
|
|
410
|
-
// pz-override-response header is used to prevent 404 page for custom responses.
|
|
411
385
|
if (
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
386
|
+
Settings.usePrettyUrlRoute &&
|
|
387
|
+
url.searchParams.toString().length >
|
|
388
|
+
0 &&
|
|
389
|
+
!Object.entries(ROUTES).find(
|
|
390
|
+
([, value]) =>
|
|
391
|
+
new RegExp(`^${value}/?$`).test(
|
|
392
|
+
pathnameWithoutLocale
|
|
393
|
+
)
|
|
394
|
+
)
|
|
415
395
|
) {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
url.
|
|
396
|
+
url.pathname =
|
|
397
|
+
url.pathname +
|
|
398
|
+
(/\/$/.test(url.pathname)
|
|
399
|
+
? ''
|
|
400
|
+
: '/') +
|
|
401
|
+
`searchparams|${encodeURIComponent(
|
|
402
|
+
url.searchParams.toString()
|
|
403
|
+
)}`;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
Settings.rewrites.forEach((rewrite) => {
|
|
407
|
+
url.pathname = url.pathname.replace(
|
|
408
|
+
rewrite.source,
|
|
409
|
+
rewrite.destination
|
|
419
410
|
);
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
middlewareResult
|
|
425
|
-
'pz-override-response'
|
|
426
|
-
) === 'true'
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
// if middleware.ts has a return value for current url
|
|
414
|
+
if (
|
|
415
|
+
middlewareResult instanceof NextResponse
|
|
427
416
|
) {
|
|
417
|
+
// pz-override-response header is used to prevent 404 page for custom responses.
|
|
418
|
+
if (
|
|
419
|
+
middlewareResult.headers.get(
|
|
420
|
+
'pz-override-response'
|
|
421
|
+
) !== 'true'
|
|
422
|
+
) {
|
|
423
|
+
middlewareResult.headers.set(
|
|
424
|
+
'x-middleware-rewrite',
|
|
425
|
+
url.href
|
|
426
|
+
);
|
|
427
|
+
} else if (
|
|
428
|
+
middlewareResult.headers.get(
|
|
429
|
+
'x-middleware-rewrite'
|
|
430
|
+
) &&
|
|
431
|
+
middlewareResult.headers.get(
|
|
432
|
+
'pz-override-response'
|
|
433
|
+
) === 'true'
|
|
434
|
+
) {
|
|
435
|
+
middlewareResult =
|
|
436
|
+
NextResponse.rewrite(url);
|
|
437
|
+
}
|
|
438
|
+
} else {
|
|
439
|
+
// if middleware.ts doesn't have a return value.
|
|
440
|
+
// e.g. NextResponse.next() doesn't exist in middleware.ts
|
|
441
|
+
|
|
428
442
|
middlewareResult =
|
|
429
443
|
NextResponse.rewrite(url);
|
|
430
444
|
}
|
|
431
|
-
} else {
|
|
432
|
-
// if middleware.ts doesn't have a return value.
|
|
433
|
-
// e.g. NextResponse.next() doesn't exist in middleware.ts
|
|
434
445
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
446
|
+
const { localeUrlStrategy } =
|
|
447
|
+
Settings.localization;
|
|
448
|
+
|
|
449
|
+
const fallbackHost =
|
|
450
|
+
req.headers.get('x-forwarded-host') ||
|
|
451
|
+
req.headers.get('host');
|
|
452
|
+
const hostname =
|
|
453
|
+
process.env.NEXT_PUBLIC_URL ||
|
|
454
|
+
`https://${fallbackHost}`;
|
|
455
|
+
const rootHostname =
|
|
456
|
+
localeUrlStrategy ===
|
|
457
|
+
LocaleUrlStrategy.Subdomain
|
|
458
|
+
? getRootHostname(hostname)
|
|
459
|
+
: null;
|
|
460
|
+
|
|
461
|
+
if (
|
|
462
|
+
!url.pathname.startsWith(ordersPrefix)
|
|
463
|
+
) {
|
|
464
|
+
middlewareResult.cookies.set(
|
|
465
|
+
'pz-locale',
|
|
466
|
+
locale?.length > 0
|
|
467
|
+
? locale
|
|
468
|
+
: defaultLocaleValue,
|
|
469
|
+
{
|
|
470
|
+
domain: rootHostname,
|
|
471
|
+
sameSite: 'none',
|
|
472
|
+
secure: true,
|
|
473
|
+
expires: new Date(
|
|
474
|
+
Date.now() +
|
|
475
|
+
1000 * 60 * 60 * 24 * 7
|
|
476
|
+
) // 7 days
|
|
477
|
+
}
|
|
478
|
+
);
|
|
479
|
+
}
|
|
438
480
|
|
|
439
|
-
const { localeUrlStrategy } =
|
|
440
|
-
Settings.localization;
|
|
441
|
-
|
|
442
|
-
const fallbackHost =
|
|
443
|
-
req.headers.get('x-forwarded-host') ||
|
|
444
|
-
req.headers.get('host');
|
|
445
|
-
const hostname =
|
|
446
|
-
process.env.NEXT_PUBLIC_URL ||
|
|
447
|
-
`https://${fallbackHost}`;
|
|
448
|
-
const rootHostname =
|
|
449
|
-
localeUrlStrategy ===
|
|
450
|
-
LocaleUrlStrategy.Subdomain
|
|
451
|
-
? getRootHostname(hostname)
|
|
452
|
-
: null;
|
|
453
|
-
|
|
454
|
-
if (
|
|
455
|
-
!url.pathname.startsWith(
|
|
456
|
-
ordersPrefix
|
|
457
|
-
)
|
|
458
|
-
) {
|
|
459
481
|
middlewareResult.cookies.set(
|
|
460
|
-
'pz-
|
|
461
|
-
|
|
462
|
-
? locale
|
|
463
|
-
: defaultLocaleValue,
|
|
482
|
+
'pz-currency',
|
|
483
|
+
currency,
|
|
464
484
|
{
|
|
465
485
|
domain: rootHostname,
|
|
466
486
|
sameSite: 'none',
|
|
@@ -470,103 +490,89 @@ const withPzDefault =
|
|
|
470
490
|
) // 7 days
|
|
471
491
|
}
|
|
472
492
|
);
|
|
473
|
-
}
|
|
474
493
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
494
|
+
if (
|
|
495
|
+
req.cookies.get('pz-locale') &&
|
|
496
|
+
req.cookies.get('pz-locale').value !==
|
|
497
|
+
locale
|
|
498
|
+
) {
|
|
499
|
+
logger.debug('Locale changed', {
|
|
500
|
+
locale,
|
|
501
|
+
oldLocale:
|
|
502
|
+
req.cookies.get('pz-locale')?.value,
|
|
503
|
+
ip
|
|
504
|
+
});
|
|
485
505
|
}
|
|
486
|
-
);
|
|
487
|
-
|
|
488
|
-
if (
|
|
489
|
-
req.cookies.get('pz-locale') &&
|
|
490
|
-
req.cookies.get('pz-locale').value !==
|
|
491
|
-
locale
|
|
492
|
-
) {
|
|
493
|
-
logger.debug('Locale changed', {
|
|
494
|
-
locale,
|
|
495
|
-
oldLocale:
|
|
496
|
-
req.cookies.get('pz-locale')?.value,
|
|
497
|
-
ip
|
|
498
|
-
});
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
middlewareResult.headers.set(
|
|
502
|
-
'pz-url',
|
|
503
|
-
req.nextUrl.toString()
|
|
504
|
-
);
|
|
505
506
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
507
|
+
middlewareResult.headers.set(
|
|
508
|
+
'pz-url',
|
|
509
|
+
req.nextUrl.toString()
|
|
509
510
|
);
|
|
510
|
-
}
|
|
511
511
|
|
|
512
|
-
|
|
513
|
-
req.cookies.get(
|
|
514
|
-
'pz-post-checkout-flow'
|
|
515
|
-
)
|
|
516
|
-
) {
|
|
517
|
-
if (
|
|
518
|
-
pathnameWithoutLocale.startsWith(
|
|
519
|
-
'/orders/completed/'
|
|
520
|
-
) ||
|
|
521
|
-
pathnameWithoutLocale.startsWith(
|
|
522
|
-
'/basket'
|
|
523
|
-
)
|
|
524
|
-
) {
|
|
512
|
+
if (req.cookies.get('pz-set-currency')) {
|
|
525
513
|
middlewareResult.cookies.delete(
|
|
526
|
-
'pz-
|
|
514
|
+
'pz-set-currency'
|
|
527
515
|
);
|
|
528
516
|
}
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
if (process.env.ACC_APP_VERSION) {
|
|
532
|
-
middlewareResult.headers.set(
|
|
533
|
-
'acc-app-version',
|
|
534
|
-
process.env.ACC_APP_VERSION
|
|
535
|
-
);
|
|
536
|
-
}
|
|
537
517
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
518
|
+
if (
|
|
519
|
+
req.cookies.get('pz-post-checkout-flow')
|
|
520
|
+
) {
|
|
521
|
+
if (
|
|
522
|
+
pathnameWithoutLocale.startsWith(
|
|
523
|
+
'/orders/completed/'
|
|
524
|
+
) ||
|
|
525
|
+
pathnameWithoutLocale.startsWith(
|
|
526
|
+
'/basket'
|
|
527
|
+
)
|
|
528
|
+
) {
|
|
529
|
+
middlewareResult.cookies.delete(
|
|
530
|
+
'pz-post-checkout-flow'
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
541
534
|
|
|
542
|
-
if (
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
middlewareResult.cookies.set(
|
|
547
|
-
'csrftoken',
|
|
548
|
-
csrf_token,
|
|
549
|
-
{
|
|
550
|
-
domain: rootHostname,
|
|
551
|
-
secure: true
|
|
552
|
-
}
|
|
535
|
+
if (process.env.ACC_APP_VERSION) {
|
|
536
|
+
middlewareResult.headers.set(
|
|
537
|
+
'acc-app-version',
|
|
538
|
+
process.env.ACC_APP_VERSION
|
|
553
539
|
);
|
|
554
540
|
}
|
|
541
|
+
|
|
542
|
+
// Set CSRF token if not set
|
|
543
|
+
try {
|
|
544
|
+
const url = `${Settings.commerceUrl}${user.csrfToken}`;
|
|
545
|
+
|
|
546
|
+
if (!req.cookies.get('csrftoken')) {
|
|
547
|
+
const { csrf_token } = await (
|
|
548
|
+
await fetch(url)
|
|
549
|
+
).json();
|
|
550
|
+
middlewareResult.cookies.set(
|
|
551
|
+
'csrftoken',
|
|
552
|
+
csrf_token,
|
|
553
|
+
{
|
|
554
|
+
path: '/',
|
|
555
|
+
domain: rootHostname,
|
|
556
|
+
...getCsrfCookieFlags()
|
|
557
|
+
}
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
} catch (error) {
|
|
561
|
+
logger.error('CSRF Error', {
|
|
562
|
+
error,
|
|
563
|
+
ip
|
|
564
|
+
});
|
|
565
|
+
}
|
|
555
566
|
} catch (error) {
|
|
556
|
-
logger.error('
|
|
567
|
+
logger.error('withPzDefault Error', {
|
|
557
568
|
error,
|
|
558
569
|
ip
|
|
559
570
|
});
|
|
560
571
|
}
|
|
561
|
-
} catch (error) {
|
|
562
|
-
logger.error('withPzDefault Error', {
|
|
563
|
-
error,
|
|
564
|
-
ip
|
|
565
|
-
});
|
|
566
|
-
}
|
|
567
572
|
|
|
568
|
-
|
|
569
|
-
|
|
573
|
+
return middlewareResult;
|
|
574
|
+
}
|
|
575
|
+
)
|
|
570
576
|
)
|
|
571
577
|
)
|
|
572
578
|
)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akinon/next",
|
|
3
3
|
"description": "Core package for Project Zero Next",
|
|
4
|
-
"version": "2.0.16-rc.
|
|
4
|
+
"version": "2.0.16-rc.1",
|
|
5
5
|
"private": false,
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"set-cookie-parser": "2.6.0"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
|
-
"@akinon/eslint-plugin-projectzero": "2.0.16-rc.
|
|
39
|
+
"@akinon/eslint-plugin-projectzero": "2.0.16-rc.1",
|
|
40
40
|
"@babel/core": "7.26.10",
|
|
41
41
|
"@babel/preset-env": "7.26.9",
|
|
42
42
|
"@babel/preset-typescript": "7.27.0",
|
package/types/index.ts
CHANGED
|
@@ -91,6 +91,25 @@ export interface Settings {
|
|
|
91
91
|
* It overrides process.env.NEXT_PUBLIC_SENTRY_DSN and process.env.SENTRY_DSN.
|
|
92
92
|
*/
|
|
93
93
|
sentryDsn?: string;
|
|
94
|
+
/**
|
|
95
|
+
* CSRF cookie hardening settings.
|
|
96
|
+
*
|
|
97
|
+
* When `httpOnly` is `true`, the `csrftoken` cookie is set with the
|
|
98
|
+
* `HttpOnly`, `Secure` (production) and `SameSite=Lax` flags. The token is
|
|
99
|
+
* never exposed to the browser: instead of the client mirroring the cookie
|
|
100
|
+
* into the `x-csrftoken` header, the Next.js proxy (BFF) reads the cookie
|
|
101
|
+
* server-side and injects the header before forwarding state-changing
|
|
102
|
+
* requests to the commerce backend, after validating the request `Origin`.
|
|
103
|
+
*
|
|
104
|
+
* Because the token no longer reaches JS, brand code must NOT rely on
|
|
105
|
+
* reading `csrftoken` from `document.cookie` or `getCookie('csrftoken')`
|
|
106
|
+
* when this flag is enabled — all CSRF handling happens on the server.
|
|
107
|
+
*
|
|
108
|
+
* @default false
|
|
109
|
+
*/
|
|
110
|
+
csrf?: {
|
|
111
|
+
httpOnly?: boolean;
|
|
112
|
+
};
|
|
94
113
|
redis: {
|
|
95
114
|
defaultExpirationTime: number;
|
|
96
115
|
};
|
|
@@ -283,7 +302,9 @@ export interface PzSegmentsConfig {
|
|
|
283
302
|
}
|
|
284
303
|
|
|
285
304
|
// Search params type compatible with both Next.js resolved searchParams and URLSearchParams
|
|
286
|
-
export type SearchParams =
|
|
305
|
+
export type SearchParams =
|
|
306
|
+
| Record<string, string | string[] | undefined>
|
|
307
|
+
| URLSearchParams;
|
|
287
308
|
|
|
288
309
|
// Raw Next 16 server prop shape, used at the middleware/HOC boundary before normalization
|
|
289
310
|
export type RawSearchParams = Record<string, string | string[] | undefined>;
|
|
@@ -316,14 +337,16 @@ export interface RootLayoutProps<T = any> extends LayoutProps<T> {
|
|
|
316
337
|
|
|
317
338
|
// Async versions for Next.js 16 generateMetadata and internal use
|
|
318
339
|
export interface AsyncPageProps<T = any> {
|
|
319
|
-
params: Promise<
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
340
|
+
params: Promise<
|
|
341
|
+
T & {
|
|
342
|
+
pz?: string;
|
|
343
|
+
commerce?: string;
|
|
344
|
+
locale?: string;
|
|
345
|
+
currency?: string;
|
|
346
|
+
url?: string;
|
|
347
|
+
[key: string]: any;
|
|
348
|
+
}
|
|
349
|
+
>;
|
|
327
350
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
|
328
351
|
}
|
|
329
352
|
|
package/utils/csrf.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import settings from 'settings';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether the Django `csrftoken` cookie should be hardened with the
|
|
5
|
+
* `HttpOnly` flag.
|
|
6
|
+
*
|
|
7
|
+
* When enabled, the token never reaches the browser: the Next.js proxy
|
|
8
|
+
* (BFF) reads the cookie server-side and injects the `x-csrftoken` header
|
|
9
|
+
* before forwarding state-changing requests to the commerce backend. See
|
|
10
|
+
* `api/client.ts`.
|
|
11
|
+
*/
|
|
12
|
+
export function isCsrfHttpOnly(): boolean {
|
|
13
|
+
return settings.csrf?.httpOnly === true;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CsrfCookieFlags {
|
|
17
|
+
httpOnly: boolean;
|
|
18
|
+
secure: boolean;
|
|
19
|
+
sameSite: 'lax';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Cookie attributes applied to the `csrftoken` cookie wherever it is set
|
|
24
|
+
* (middleware bootstrap, auth login rotation, proxy passthrough).
|
|
25
|
+
*
|
|
26
|
+
* `SameSite=Lax` is the Next.js-layer CSRF defense: the browser will not
|
|
27
|
+
* attach the cookie on cross-site state-changing requests, so the proxy's
|
|
28
|
+
* server-side header injection cannot be abused from a third-party origin.
|
|
29
|
+
*/
|
|
30
|
+
export function getCsrfCookieFlags(): CsrfCookieFlags {
|
|
31
|
+
const httpOnly = isCsrfHttpOnly();
|
|
32
|
+
return {
|
|
33
|
+
httpOnly,
|
|
34
|
+
secure: process.env.NODE_ENV === 'production',
|
|
35
|
+
sameSite: 'lax'
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import Settings from 'settings';
|
|
2
|
+
import { LocaleUrlStrategy } from '../localization';
|
|
2
3
|
|
|
3
4
|
export default function getRootHostname(
|
|
4
5
|
url: string | undefined
|
|
@@ -26,3 +27,20 @@ export default function getRootHostname(
|
|
|
26
27
|
return null;
|
|
27
28
|
}
|
|
28
29
|
}
|
|
30
|
+
|
|
31
|
+
interface HeaderLike {
|
|
32
|
+
get(name: string): string | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getRequestRootHostname(
|
|
36
|
+
headerStore: HeaderLike
|
|
37
|
+
): string | null {
|
|
38
|
+
if (Settings.localization.localeUrlStrategy !== LocaleUrlStrategy.Subdomain) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const fallbackHost =
|
|
43
|
+
headerStore.get('x-forwarded-host') || headerStore.get('host');
|
|
44
|
+
const hostname = process.env.NEXT_PUBLIC_URL || `https://${fallbackHost}`;
|
|
45
|
+
return getRootHostname(hostname);
|
|
46
|
+
}
|
package/utils/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ export * from './get-currency-label';
|
|
|
10
10
|
export * from './pz-segments';
|
|
11
11
|
export * from './get-checkout-path';
|
|
12
12
|
export * from './format-error-message';
|
|
13
|
+
export * from './csrf';
|
|
13
14
|
|
|
14
15
|
export function getCookie(name: string) {
|
|
15
16
|
if (typeof document === 'undefined') {
|