@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 +8 -2
- package/api/auth.ts +57 -4
- package/api/client.ts +85 -0
- package/middlewares/default.ts +255 -247
- package/package.json +2 -2
- package/types/index.ts +19 -0
- package/utils/csrf.ts +37 -0
- package/utils/index.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
# @akinon/next
|
|
2
2
|
|
|
3
|
-
## 1.126.
|
|
3
|
+
## 1.126.3
|
|
4
4
|
|
|
5
5
|
### Patch Changes
|
|
6
6
|
|
|
7
|
-
-
|
|
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
|
-
|
|
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=;
|
|
266
|
-
`sessionid=;
|
|
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(', ');
|
package/middlewares/default.ts
CHANGED
|
@@ -18,7 +18,12 @@ import {
|
|
|
18
18
|
withBfcacheHeaders
|
|
19
19
|
} from '.';
|
|
20
20
|
import { urlLocaleMatcherRegex } from '../utils';
|
|
21
|
-
import {
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
315
|
+
let ordersPrefix: string;
|
|
311
316
|
|
|
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}${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
'/'
|
|
373
|
+
const pzValue = encodePzValue(
|
|
374
|
+
segmentValues,
|
|
375
|
+
pzConfig
|
|
377
376
|
);
|
|
378
|
-
ordersPrefix = `/${pzValue}/orders`;
|
|
379
|
-
}
|
|
380
377
|
|
|
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
|
-
});
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
url.
|
|
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
|
-
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
middlewareResult
|
|
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
|
-
|
|
436
|
-
|
|
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-
|
|
461
|
-
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
508
|
+
middlewareResult.headers.set(
|
|
509
|
+
'pz-url',
|
|
510
|
+
req.nextUrl.toString()
|
|
509
511
|
);
|
|
510
|
-
}
|
|
511
512
|
|
|
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
|
-
) {
|
|
513
|
+
if (req.cookies.get('pz-set-currency')) {
|
|
525
514
|
middlewareResult.cookies.delete(
|
|
526
|
-
'pz-
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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 (
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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('
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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') {
|