@akinon/next 1.126.2-v1-rc.0 → 1.126.3

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 CHANGED
@@ -1,10 +1,16 @@
1
1
  # @akinon/next
2
2
 
3
- ## 1.126.2-v1-rc.0
3
+ ## 1.126.3
4
4
 
5
5
  ### Patch Changes
6
6
 
7
- - 182a639b: ZERO-4483: Verify v1-rc maintenance pipeline (no-op patch to confirm v1-rc dist-tag flow)
7
+ - 588a98a1: ZERO-4486: Harden CSRF handling for the BFF proxy (v1)
8
+
9
+ ## 1.126.2
10
+
11
+ ### Patch Changes
12
+
13
+ - 8351ac53: ZERO-4483: Verify v1 maintenance pipeline (no-op patch to confirm v1-latest dist-tag flow)
8
14
 
9
15
  ## 1.126.1
10
16
 
package/api/auth.ts CHANGED
@@ -9,6 +9,41 @@ import logger from '@akinon/next/utils/log';
9
9
  import { AuthError } from '../types';
10
10
  import getRootHostname from '../utils/get-root-hostname';
11
11
  import { LocaleUrlStrategy } from '../localization';
12
+ import { getCsrfCookieFlags } from '../utils/csrf';
13
+
14
+ function getRequestRootHostname(headers: NextApiRequest['headers']) {
15
+ if (Settings.localization.localeUrlStrategy !== LocaleUrlStrategy.Subdomain) {
16
+ return null;
17
+ }
18
+
19
+ const fallbackHost =
20
+ headers['x-forwarded-host']?.toString() || headers.host?.toString();
21
+ const hostname = process.env.NEXT_PUBLIC_URL || `https://${fallbackHost}`;
22
+ return getRootHostname(hostname);
23
+ }
24
+
25
+ function buildCsrfCookieString(value: string, rootHostname: string | null) {
26
+ const flags = getCsrfCookieFlags();
27
+ const parts = [
28
+ `csrftoken=${value}`,
29
+ 'Path=/',
30
+ `SameSite=${flags.sameSite}`
31
+ ];
32
+
33
+ if (flags.httpOnly) {
34
+ parts.push('HttpOnly');
35
+ }
36
+
37
+ if (flags.secure) {
38
+ parts.push('Secure');
39
+ }
40
+
41
+ if (rootHostname) {
42
+ parts.push(`Domain=${rootHostname}`);
43
+ }
44
+
45
+ return parts.join('; ');
46
+ }
12
47
 
13
48
  async function getCurrentUser(sessionId: string, currency = '') {
14
49
  const headers = {
@@ -146,12 +181,17 @@ const defaultNextAuthOptions = (
146
181
  logger.debug(`Login/Register response: ${JSON.stringify(response)}`);
147
182
 
148
183
  let sessionId = '';
184
+ let rotatedCsrfToken = '';
149
185
  const setCookieHeader = apiRequest.headers.get('set-cookie');
150
186
  if (setCookieHeader) {
151
187
  sessionId =
152
188
  setCookieHeader
153
189
  .match(/osessionid=\w+/)?.[0]
154
190
  .replace(/osessionid=/, '') || '';
191
+ rotatedCsrfToken =
192
+ setCookieHeader
193
+ .match(/csrftoken=[^;,\s]+/)?.[0]
194
+ .replace(/csrftoken=/, '') || '';
155
195
 
156
196
  logger.debug(`Login/Register session id: ${sessionId}`);
157
197
  } else {
@@ -174,10 +214,18 @@ const defaultNextAuthOptions = (
174
214
  const domainOption = rootHostname ? `Domain=${rootHostname};` : '';
175
215
  const cookieOptions = `Path=/; HttpOnly; Secure; Max-Age=${maxAge}; ${domainOption}`;
176
216
 
177
- res.setHeader('Set-Cookie', [
217
+ const setCookies = [
178
218
  `osessionid=${sessionId}; ${cookieOptions}`,
179
219
  `sessionid=${sessionId}; ${cookieOptions}` // required to get 3D redirection form
180
- ]);
220
+ ];
221
+
222
+ if (rotatedCsrfToken) {
223
+ setCookies.push(
224
+ buildCsrfCookieString(rotatedCsrfToken, rootHostname)
225
+ );
226
+ }
227
+
228
+ res.setHeader('Set-Cookie', setCookies);
181
229
  }
182
230
 
183
231
  if (!response.key) {
@@ -261,9 +309,14 @@ const defaultNextAuthOptions = (
261
309
  logger.debug('Successfully signed in');
262
310
  },
263
311
  signOut: () => {
312
+ const rootHostname = getRequestRootHostname(req.headers);
313
+ const domainAttr = rootHostname ? `; Domain=${rootHostname}` : '';
314
+ const expiry = `Path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT${domainAttr}`;
315
+
264
316
  res.setHeader('Set-Cookie', [
265
- `osessionid=; Path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`,
266
- `sessionid=; Path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`
317
+ `osessionid=; ${expiry}`,
318
+ `sessionid=; ${expiry}`,
319
+ `csrftoken=; ${expiry}`
267
320
  ]);
268
321
  logger.debug('Successfully signed out');
269
322
  }
package/api/client.ts CHANGED
@@ -7,6 +7,7 @@ import cookieParser from 'set-cookie-parser';
7
7
  import { cookies } from 'next/headers';
8
8
  import getRootHostname from '../utils/get-root-hostname';
9
9
  import { LocaleUrlStrategy } from '../localization';
10
+ import { getCsrfCookieFlags, isCsrfHttpOnly } from '../utils/csrf';
10
11
 
11
12
  interface RouteParams {
12
13
  params: {
@@ -14,6 +15,60 @@ interface RouteParams {
14
15
  };
15
16
  }
16
17
 
18
+ const STATE_CHANGING_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];
19
+
20
+ function getProxyHosts(req: Request): string[] {
21
+ const hosts = new Set<string>();
22
+ const forwarded =
23
+ req.headers.get('x-forwarded-host') || req.headers.get('host');
24
+ if (forwarded) {
25
+ hosts.add(forwarded.split(':')[0].toLowerCase());
26
+ }
27
+ try {
28
+ if (process.env.NEXT_PUBLIC_URL) {
29
+ hosts.add(new URL(process.env.NEXT_PUBLIC_URL).hostname.toLowerCase());
30
+ }
31
+ } catch {
32
+ // ignore malformed NEXT_PUBLIC_URL
33
+ }
34
+ return Array.from(hosts);
35
+ }
36
+
37
+ /**
38
+ * Next.js-layer CSRF defense for the BFF proxy. State-changing requests must
39
+ * originate from our own app: when an `Origin` header is present it has to
40
+ * resolve to the proxy host (or, under the subdomain locale strategy, the
41
+ * same registrable domain). Requests without an `Origin` (non-browser
42
+ * clients, same-origin navigations) fall back to the `SameSite=Lax` cookie
43
+ * guarantee. Only enforced when CSRF hardening is enabled.
44
+ */
45
+ function isOriginAllowed(req: Request): boolean {
46
+ const origin = req.headers.get('origin');
47
+ if (!origin) return true;
48
+
49
+ let originHost: string;
50
+ try {
51
+ originHost = new URL(origin).hostname.toLowerCase();
52
+ } catch {
53
+ return false;
54
+ }
55
+
56
+ const allowedHosts = getProxyHosts(req);
57
+ if (allowedHosts.includes(originHost)) return true;
58
+
59
+ if (settings.localization.localeUrlStrategy === LocaleUrlStrategy.Subdomain) {
60
+ const originRoot = getRootHostname(`https://${originHost}`);
61
+ return (
62
+ !!originRoot &&
63
+ allowedHosts.some(
64
+ (host) => getRootHostname(`https://${host}`) === originRoot
65
+ )
66
+ );
67
+ }
68
+
69
+ return false;
70
+ }
71
+
17
72
  async function proxyRequest(...args) {
18
73
  const [req, { params }] = args as [req: Request, params: RouteParams];
19
74
  const { searchParams } = new URL(req.url);
@@ -101,6 +156,28 @@ async function proxyRequest(...args) {
101
156
  });
102
157
  }
103
158
 
159
+ // CSRF hardening (BFF model): when the csrftoken cookie is HttpOnly the
160
+ // browser can no longer mirror it into the `x-csrftoken` header, so the
161
+ // proxy validates the request origin and injects the header server-side
162
+ // from the cookie that the browser sent with this request.
163
+ if (isCsrfHttpOnly() && STATE_CHANGING_METHODS.includes(req.method)) {
164
+ if (!isOriginAllowed(req)) {
165
+ logger.warn('Client Proxy Request - Blocked cross-origin request', {
166
+ url: req.url,
167
+ origin: req.headers.get('origin')
168
+ });
169
+ return NextResponse.json(
170
+ { detail: 'CSRF origin check failed.' },
171
+ { status: 403 }
172
+ );
173
+ }
174
+
175
+ const csrfToken = nextCookies.get('csrftoken')?.value;
176
+ if (csrfToken) {
177
+ fetchOptions.headers['x-csrftoken'] = csrfToken;
178
+ }
179
+ }
180
+
104
181
  if (options.contentType) {
105
182
  fetchOptions.headers['Content-Type'] = options.contentType;
106
183
  }
@@ -207,6 +284,14 @@ async function proxyRequest(...args) {
207
284
  if (!cookie.domain && rootHostname) {
208
285
  cookie.domain = rootHostname;
209
286
  }
287
+ if (cookie.name === 'csrftoken') {
288
+ const flags = getCsrfCookieFlags();
289
+ if (flags.httpOnly) {
290
+ cookie.httpOnly = true;
291
+ cookie.secure = flags.secure;
292
+ cookie.sameSite = flags.sameSite;
293
+ }
294
+ }
210
295
  return formatCookieString(cookie);
211
296
  })
212
297
  .join(', ');
@@ -18,7 +18,12 @@ import {
18
18
  withBfcacheHeaders
19
19
  } from '.';
20
20
  import { urlLocaleMatcherRegex } from '../utils';
21
- import { getPzSegmentsConfig, encodePzValue, isLegacyMode } from '../utils/pz-segments';
21
+ import { getCsrfCookieFlags } from '../utils/csrf';
22
+ import {
23
+ getPzSegmentsConfig,
24
+ encodePzValue,
25
+ isLegacyMode
26
+ } from '../utils/pz-segments';
22
27
  import withCurrency from './currency';
23
28
  import withLocale from './locale';
24
29
  import logger from '../utils/log';
@@ -262,98 +267,98 @@ const withPzDefault =
262
267
  req: PzNextRequest,
263
268
  event: NextFetchEvent
264
269
  ) => {
265
- let middlewareResult: NextResponse | void =
266
- NextResponse.next();
267
-
268
- try {
269
- const { locale, prettyUrl, currency } =
270
- req.middlewareParams.rewrites;
271
- const { defaultLocaleValue } =
272
- Settings.localization;
273
- const url = req.nextUrl.clone();
274
- const pathnameWithoutLocale =
275
- url.pathname.replace(
276
- urlLocaleMatcherRegex,
277
- ''
278
- );
270
+ let middlewareResult: NextResponse | void =
271
+ NextResponse.next();
272
+
273
+ try {
274
+ const { locale, prettyUrl, currency } =
275
+ req.middlewareParams.rewrites;
276
+ const { defaultLocaleValue } =
277
+ Settings.localization;
278
+ const url = req.nextUrl.clone();
279
+ const pathnameWithoutLocale =
280
+ url.pathname.replace(
281
+ urlLocaleMatcherRegex,
282
+ ''
283
+ );
279
284
 
280
- middlewareResult = (await middleware(
281
- req,
282
- event
283
- )) as NextResponse | void;
284
-
285
- let customRewriteUrlDiff = '';
286
-
287
- if (
288
- middlewareResult instanceof
289
- NextResponse &&
290
- middlewareResult.headers.get(
291
- 'pz-override-response'
292
- ) &&
293
- middlewareResult.headers.get(
294
- 'x-middleware-rewrite'
295
- )
296
- ) {
297
- const rewriteUrl = new URL(
285
+ middlewareResult = (await middleware(
286
+ req,
287
+ event
288
+ )) as NextResponse | void;
289
+
290
+ let customRewriteUrlDiff = '';
291
+
292
+ if (
293
+ middlewareResult instanceof
294
+ NextResponse &&
295
+ middlewareResult.headers.get(
296
+ 'pz-override-response'
297
+ ) &&
298
298
  middlewareResult.headers.get(
299
299
  'x-middleware-rewrite'
300
300
  )
301
- );
302
- const originalUrl = new URL(req.url);
303
- customRewriteUrlDiff =
304
- rewriteUrl.pathname.replace(
305
- originalUrl.pathname,
306
- ''
301
+ ) {
302
+ const rewriteUrl = new URL(
303
+ middlewareResult.headers.get(
304
+ 'x-middleware-rewrite'
305
+ )
307
306
  );
308
- }
307
+ const originalUrl = new URL(req.url);
308
+ customRewriteUrlDiff =
309
+ rewriteUrl.pathname.replace(
310
+ originalUrl.pathname,
311
+ ''
312
+ );
313
+ }
309
314
 
310
- let ordersPrefix: string;
315
+ let ordersPrefix: string;
311
316
 
312
- if (isLegacyMode(Settings)) {
313
- url.basePath = `/${commerceUrl}`;
314
- url.pathname =
315
- `/${locale.length ? `${locale}/` : ''}${currency}/${customRewriteUrlDiff}${
316
- prettyUrl ?? pathnameWithoutLocale
317
- }`.replace(/\/+/g, '/');
318
- ordersPrefix = `/${currency}/orders`;
319
- } else {
320
- const pzConfig =
321
- getPzSegmentsConfig(Settings);
322
- const fullUrlPath =
323
- `${customRewriteUrlDiff}${
317
+ if (isLegacyMode(Settings)) {
318
+ url.basePath = `/${commerceUrl}`;
319
+ url.pathname = `/${
320
+ locale.length ? `${locale}/` : ''
321
+ }${currency}/${customRewriteUrlDiff}${
324
322
  prettyUrl ?? pathnameWithoutLocale
325
323
  }`.replace(/\/+/g, '/');
326
- const fullUrl = `${req.nextUrl.origin}${fullUrlPath}`;
327
- const resolvedLocale =
328
- locale?.length > 0
329
- ? locale
330
- : Settings.localization
331
- .defaultLocaleValue;
332
- const resolveContext = {
333
- req,
334
- event,
335
- url,
336
- locale: resolvedLocale,
337
- currency,
338
- pathname: pathnameWithoutLocale
339
- };
340
- const customSegments = pzConfig.segments
341
- .filter(
342
- (seg) =>
343
- seg.name !== 'locale' &&
344
- seg.name !== 'currency' &&
345
- seg.name !== 'url'
346
- );
347
- const segmentValues: Record<
348
- string,
349
- string
350
- > = {
351
- locale: resolvedLocale,
352
- currency,
353
- url: encodeURIComponent(fullUrl),
354
- ...Object.fromEntries(
355
- customSegments
356
- .map((seg) => [
324
+ ordersPrefix = `/${currency}/orders`;
325
+ } else {
326
+ const pzConfig =
327
+ getPzSegmentsConfig(Settings);
328
+ const fullUrlPath =
329
+ `${customRewriteUrlDiff}${
330
+ prettyUrl ?? pathnameWithoutLocale
331
+ }`.replace(/\/+/g, '/');
332
+ const fullUrl = `${req.nextUrl.origin}${fullUrlPath}`;
333
+ const resolvedLocale =
334
+ locale?.length > 0
335
+ ? locale
336
+ : Settings.localization
337
+ .defaultLocaleValue;
338
+ const resolveContext = {
339
+ req,
340
+ event,
341
+ url,
342
+ locale: resolvedLocale,
343
+ currency,
344
+ pathname: pathnameWithoutLocale
345
+ };
346
+ const customSegments =
347
+ pzConfig.segments.filter(
348
+ (seg) =>
349
+ seg.name !== 'locale' &&
350
+ seg.name !== 'currency' &&
351
+ seg.name !== 'url'
352
+ );
353
+ const segmentValues: Record<
354
+ string,
355
+ string
356
+ > = {
357
+ locale: resolvedLocale,
358
+ currency,
359
+ url: encodeURIComponent(fullUrl),
360
+ ...Object.fromEntries(
361
+ customSegments.map((seg) => [
357
362
  seg.name,
358
363
  req.middlewareParams.rewrites[
359
364
  seg.name
@@ -362,105 +367,121 @@ const withPzDefault =
362
367
  ? seg.resolve(resolveContext)
363
368
  : '')
364
369
  ])
365
- )
366
- };
367
-
368
- const pzValue = encodePzValue(
369
- segmentValues,
370
- pzConfig
371
- );
370
+ )
371
+ };
372
372
 
373
- url.pathname =
374
- `/${pzValue}/${fullUrlPath}`.replace(
375
- /\/+/g,
376
- '/'
373
+ const pzValue = encodePzValue(
374
+ segmentValues,
375
+ pzConfig
377
376
  );
378
- ordersPrefix = `/${pzValue}/orders`;
379
- }
380
377
 
381
- if (
382
- Settings.usePrettyUrlRoute &&
383
- url.searchParams.toString().length > 0 &&
384
- !Object.entries(ROUTES).find(
385
- ([, value]) =>
386
- new RegExp(`^${value}/?$`).test(
387
- pathnameWithoutLocale
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
- });
378
+ url.pathname =
379
+ `/${pzValue}/${fullUrlPath}`.replace(
380
+ /\/+/g,
381
+ '/'
382
+ );
383
+ ordersPrefix = `/${pzValue}/orders`;
384
+ }
405
385
 
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
386
  if (
412
- middlewareResult.headers.get(
413
- 'pz-override-response'
414
- ) !== 'true'
387
+ Settings.usePrettyUrlRoute &&
388
+ url.searchParams.toString().length >
389
+ 0 &&
390
+ !Object.entries(ROUTES).find(
391
+ ([, value]) =>
392
+ new RegExp(`^${value}/?$`).test(
393
+ pathnameWithoutLocale
394
+ )
395
+ )
415
396
  ) {
416
- middlewareResult.headers.set(
417
- 'x-middleware-rewrite',
418
- url.href
397
+ url.pathname =
398
+ url.pathname +
399
+ (/\/$/.test(url.pathname)
400
+ ? ''
401
+ : '/') +
402
+ `searchparams|${encodeURIComponent(
403
+ url.searchParams.toString()
404
+ )}`;
405
+ }
406
+
407
+ Settings.rewrites.forEach((rewrite) => {
408
+ url.pathname = url.pathname.replace(
409
+ rewrite.source,
410
+ rewrite.destination
419
411
  );
420
- } else if (
421
- middlewareResult.headers.get(
422
- 'x-middleware-rewrite'
423
- ) &&
424
- middlewareResult.headers.get(
425
- 'pz-override-response'
426
- ) === 'true'
412
+ });
413
+
414
+ // if middleware.ts has a return value for current url
415
+ if (
416
+ middlewareResult instanceof NextResponse
427
417
  ) {
418
+ // pz-override-response header is used to prevent 404 page for custom responses.
419
+ if (
420
+ middlewareResult.headers.get(
421
+ 'pz-override-response'
422
+ ) !== 'true'
423
+ ) {
424
+ middlewareResult.headers.set(
425
+ 'x-middleware-rewrite',
426
+ url.href
427
+ );
428
+ } else if (
429
+ middlewareResult.headers.get(
430
+ 'x-middleware-rewrite'
431
+ ) &&
432
+ middlewareResult.headers.get(
433
+ 'pz-override-response'
434
+ ) === 'true'
435
+ ) {
436
+ middlewareResult =
437
+ NextResponse.rewrite(url);
438
+ }
439
+ } else {
440
+ // if middleware.ts doesn't have a return value.
441
+ // e.g. NextResponse.next() doesn't exist in middleware.ts
442
+
428
443
  middlewareResult =
429
444
  NextResponse.rewrite(url);
430
445
  }
431
- } else {
432
- // if middleware.ts doesn't have a return value.
433
- // e.g. NextResponse.next() doesn't exist in middleware.ts
434
446
 
435
- middlewareResult =
436
- NextResponse.rewrite(url);
437
- }
447
+ const { localeUrlStrategy } =
448
+ Settings.localization;
449
+
450
+ const fallbackHost =
451
+ req.headers.get('x-forwarded-host') ||
452
+ req.headers.get('host');
453
+ const hostname =
454
+ process.env.NEXT_PUBLIC_URL ||
455
+ `https://${fallbackHost}`;
456
+ const rootHostname =
457
+ localeUrlStrategy ===
458
+ LocaleUrlStrategy.Subdomain
459
+ ? getRootHostname(hostname)
460
+ : null;
461
+
462
+ if (
463
+ !url.pathname.startsWith(ordersPrefix)
464
+ ) {
465
+ middlewareResult.cookies.set(
466
+ 'pz-locale',
467
+ locale?.length > 0
468
+ ? locale
469
+ : defaultLocaleValue,
470
+ {
471
+ domain: rootHostname,
472
+ sameSite: 'none',
473
+ secure: true,
474
+ expires: new Date(
475
+ Date.now() +
476
+ 1000 * 60 * 60 * 24 * 7
477
+ ) // 7 days
478
+ }
479
+ );
480
+ }
438
481
 
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
482
  middlewareResult.cookies.set(
460
- 'pz-locale',
461
- locale?.length > 0
462
- ? locale
463
- : defaultLocaleValue,
483
+ 'pz-currency',
484
+ currency,
464
485
  {
465
486
  domain: rootHostname,
466
487
  sameSite: 'none',
@@ -470,102 +491,89 @@ const withPzDefault =
470
491
  ) // 7 days
471
492
  }
472
493
  );
473
- }
474
494
 
475
- middlewareResult.cookies.set(
476
- 'pz-currency',
477
- currency,
478
- {
479
- domain: rootHostname,
480
- sameSite: 'none',
481
- secure: true,
482
- expires: new Date(
483
- Date.now() + 1000 * 60 * 60 * 24 * 7
484
- ) // 7 days
495
+ if (
496
+ req.cookies.get('pz-locale') &&
497
+ req.cookies.get('pz-locale').value !==
498
+ locale
499
+ ) {
500
+ logger.debug('Locale changed', {
501
+ locale,
502
+ oldLocale:
503
+ req.cookies.get('pz-locale')?.value,
504
+ ip
505
+ });
485
506
  }
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
507
 
506
- if (req.cookies.get('pz-set-currency')) {
507
- middlewareResult.cookies.delete(
508
- 'pz-set-currency'
508
+ middlewareResult.headers.set(
509
+ 'pz-url',
510
+ req.nextUrl.toString()
509
511
  );
510
- }
511
512
 
512
- if (
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
- ) {
513
+ if (req.cookies.get('pz-set-currency')) {
525
514
  middlewareResult.cookies.delete(
526
- 'pz-post-checkout-flow'
515
+ 'pz-set-currency'
527
516
  );
528
517
  }
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
518
 
538
- // Set CSRF token if not set
539
- try {
540
- const url = `${Settings.commerceUrl}${user.csrfToken}`;
519
+ if (
520
+ req.cookies.get('pz-post-checkout-flow')
521
+ ) {
522
+ if (
523
+ pathnameWithoutLocale.startsWith(
524
+ '/orders/completed/'
525
+ ) ||
526
+ pathnameWithoutLocale.startsWith(
527
+ '/basket'
528
+ )
529
+ ) {
530
+ middlewareResult.cookies.delete(
531
+ 'pz-post-checkout-flow'
532
+ );
533
+ }
534
+ }
541
535
 
542
- if (!req.cookies.get('csrftoken')) {
543
- const { csrf_token } = await (
544
- await fetch(url)
545
- ).json();
546
- middlewareResult.cookies.set(
547
- 'csrftoken',
548
- csrf_token,
549
- {
550
- domain: rootHostname
551
- }
536
+ if (process.env.ACC_APP_VERSION) {
537
+ middlewareResult.headers.set(
538
+ 'acc-app-version',
539
+ process.env.ACC_APP_VERSION
552
540
  );
553
541
  }
542
+
543
+ // Set CSRF token if not set
544
+ try {
545
+ const url = `${Settings.commerceUrl}${user.csrfToken}`;
546
+
547
+ if (!req.cookies.get('csrftoken')) {
548
+ const { csrf_token } = await (
549
+ await fetch(url)
550
+ ).json();
551
+ middlewareResult.cookies.set(
552
+ 'csrftoken',
553
+ csrf_token,
554
+ {
555
+ path: '/',
556
+ domain: rootHostname,
557
+ ...getCsrfCookieFlags()
558
+ }
559
+ );
560
+ }
561
+ } catch (error) {
562
+ logger.error('CSRF Error', {
563
+ error,
564
+ ip
565
+ });
566
+ }
554
567
  } catch (error) {
555
- logger.error('CSRF Error', {
568
+ logger.error('withPzDefault Error', {
556
569
  error,
557
570
  ip
558
571
  });
559
572
  }
560
- } catch (error) {
561
- logger.error('withPzDefault Error', {
562
- error,
563
- ip
564
- });
565
- }
566
573
 
567
- return middlewareResult;
568
- })
574
+ return middlewareResult;
575
+ }
576
+ )
569
577
  )
570
578
  )
571
579
  )
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": "1.126.2-v1-rc.0",
4
+ "version": "1.126.3",
5
5
  "private": false,
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -35,7 +35,7 @@
35
35
  "set-cookie-parser": "2.6.0"
36
36
  },
37
37
  "devDependencies": {
38
- "@akinon/eslint-plugin-projectzero": "1.126.2-v1-rc.0",
38
+ "@akinon/eslint-plugin-projectzero": "1.126.3",
39
39
  "@babel/core": "7.26.10",
40
40
  "@babel/preset-env": "7.26.9",
41
41
  "@babel/preset-typescript": "7.27.0",
package/types/index.ts CHANGED
@@ -85,6 +85,25 @@ export interface Settings {
85
85
  };
86
86
  usePrettyUrlRoute?: boolean;
87
87
  commerceUrl: string;
88
+ /**
89
+ * CSRF cookie hardening settings.
90
+ *
91
+ * When `httpOnly` is `true`, the `csrftoken` cookie is set with the
92
+ * `HttpOnly`, `Secure` (production) and `SameSite=Lax` flags. The token is
93
+ * never exposed to the browser: instead of the client mirroring the cookie
94
+ * into the `x-csrftoken` header, the Next.js proxy (BFF) reads the cookie
95
+ * server-side and injects the header before forwarding state-changing
96
+ * requests to the commerce backend, after validating the request `Origin`.
97
+ *
98
+ * Because the token no longer reaches JS, brand code must NOT rely on
99
+ * reading `csrftoken` from `document.cookie` or `getCookie('csrftoken')`
100
+ * when this flag is enabled — all CSRF handling happens on the server.
101
+ *
102
+ * @default false
103
+ */
104
+ csrf?: {
105
+ httpOnly?: boolean;
106
+ };
88
107
  redis: {
89
108
  defaultExpirationTime: number;
90
109
  };
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
+ }
package/utils/index.ts CHANGED
@@ -9,6 +9,7 @@ export * from './generate-commerce-search-params';
9
9
  export * from './get-currency-label';
10
10
  export * from './pz-segments';
11
11
  export * from './get-checkout-path';
12
+ export * from './csrf';
12
13
 
13
14
  export function getCookie(name: string) {
14
15
  if (typeof document === 'undefined') {