@defra/lis-infra-ui-services 0.50.0 → 0.51.0
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/README.md +50 -1
- package/package.json +4 -1
- package/src/cookies/index.js +14 -32
- package/src/cookies/index.test.js +57 -5
- package/src/csrf/checks.test.js +453 -0
- package/src/csrf/constants.js +14 -0
- package/src/csrf/fail-action.test.js +155 -0
- package/src/csrf/index.js +245 -0
- package/src/csrf/index.test.js +284 -0
- package/src/csrf/same-origin.js +42 -0
- package/src/csrf/same-origin.test.js +72 -0
- package/src/nunjucks/components/form/form.test.js +172 -0
- package/src/nunjucks/components/form/macro.njk +3 -0
- package/src/nunjucks/components/form/template.njk +18 -0
- package/src/nunjucks/context.js +2 -0
- package/src/nunjucks/context.test.js +38 -0
- package/src/nunjucks/routes/auth/sign-out.njk +20 -0
- package/src/nunjucks/routes/auth/sign-out.test.js +62 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
import Boom from '@hapi/boom'
|
|
4
|
+
import hapi from '@hapi/hapi'
|
|
5
|
+
import yar from '@hapi/yar'
|
|
6
|
+
import Joi from 'joi'
|
|
7
|
+
import { afterEach, describe, expect, test, vi } from 'vitest'
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
CSRF_FIELD_NAME,
|
|
11
|
+
CSRF_PLUGIN_NAME,
|
|
12
|
+
CSRF_TOKEN_HEADER,
|
|
13
|
+
csrfPlugin
|
|
14
|
+
} from './index.js'
|
|
15
|
+
|
|
16
|
+
// The shared logger's methods are getters, so it cannot be spied on.
|
|
17
|
+
const { logger } = vi.hoisted(() => ({ logger: { warn: vi.fn() } }))
|
|
18
|
+
|
|
19
|
+
vi.mock('@defra/lis-hubs-infra-core', () => ({ logger }))
|
|
20
|
+
|
|
21
|
+
const mocks = {
|
|
22
|
+
loggerWarn: logger.warn
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const sameOrigin = { host: 'lis.test', 'sec-fetch-site': 'same-origin' }
|
|
26
|
+
const formEncoded = { 'content-type': 'application/x-www-form-urlencoded' }
|
|
27
|
+
const crossSite = { host: 'lis.test', 'sec-fetch-site': 'cross-site' }
|
|
28
|
+
const signedIn = { 'x-test-user': 'keeper' }
|
|
29
|
+
|
|
30
|
+
const tokenRoute = {
|
|
31
|
+
method: 'GET',
|
|
32
|
+
path: '/token',
|
|
33
|
+
handler: (request) => ({
|
|
34
|
+
token: request.plugins[CSRF_PLUGIN_NAME].token ?? null
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function echoRoute(options = {}) {
|
|
39
|
+
return {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
path: '/submit',
|
|
42
|
+
options,
|
|
43
|
+
handler: (request) => request.payload ?? { streamed: true }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let server
|
|
48
|
+
|
|
49
|
+
async function createServer({ tokenSource, routes }) {
|
|
50
|
+
server = hapi.server()
|
|
51
|
+
server.validator(Joi)
|
|
52
|
+
await server.register({
|
|
53
|
+
plugin: yar,
|
|
54
|
+
options: {
|
|
55
|
+
storeBlank: false,
|
|
56
|
+
maxCookieSize: 0,
|
|
57
|
+
cookieOptions: {
|
|
58
|
+
password: randomBytes(32).toString('hex'),
|
|
59
|
+
isSecure: false
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
server.auth.scheme('test', () => ({
|
|
64
|
+
authenticate: (request, h) =>
|
|
65
|
+
request.headers['x-test-user']
|
|
66
|
+
? h.authenticated({ credentials: { user: { id: 'keeper' } } })
|
|
67
|
+
: h.unauthenticated(Boom.unauthorized())
|
|
68
|
+
}))
|
|
69
|
+
server.auth.strategy('test', 'test')
|
|
70
|
+
server.auth.default({ strategy: 'test', mode: 'try' })
|
|
71
|
+
await server.register({ plugin: csrfPlugin, options: { tokenSource } })
|
|
72
|
+
server.route(routes)
|
|
73
|
+
await server.initialize()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function signInAndGetToken() {
|
|
77
|
+
const response = await server.inject({
|
|
78
|
+
method: 'GET',
|
|
79
|
+
url: '/token',
|
|
80
|
+
headers: signedIn
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
token: response.result.token,
|
|
85
|
+
cookie: response.headers['set-cookie'][0].split(';')[0]
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe('csrfPlugin checks', () => {
|
|
90
|
+
afterEach(async () => {
|
|
91
|
+
vi.clearAllMocks()
|
|
92
|
+
await server?.stop()
|
|
93
|
+
server = undefined
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
describe('rejection', () => {
|
|
97
|
+
test('refuses a cross-site post with a 403 and logs the origin check', async () => {
|
|
98
|
+
// Arrange
|
|
99
|
+
await createServer({ tokenSource: 'header', routes: [echoRoute()] })
|
|
100
|
+
|
|
101
|
+
// Act
|
|
102
|
+
const response = await server.inject({
|
|
103
|
+
method: 'POST',
|
|
104
|
+
url: '/submit',
|
|
105
|
+
headers: { ...crossSite, [CSRF_TOKEN_HEADER]: 'hub-token' },
|
|
106
|
+
payload: { [CSRF_FIELD_NAME]: 'hub-token' }
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// Assert
|
|
110
|
+
expect(response.statusCode).toBe(403)
|
|
111
|
+
expect(mocks.loggerWarn).toHaveBeenCalledWith(
|
|
112
|
+
{
|
|
113
|
+
http: { request: { method: 'post' } },
|
|
114
|
+
url: { path: '/submit' },
|
|
115
|
+
event: { reason: 'csrf-origin' }
|
|
116
|
+
},
|
|
117
|
+
'CSRF origin check failed'
|
|
118
|
+
)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('logs the token check without either token value', async () => {
|
|
122
|
+
// Arrange
|
|
123
|
+
await createServer({ tokenSource: 'header', routes: [echoRoute()] })
|
|
124
|
+
|
|
125
|
+
// Act
|
|
126
|
+
const response = await server.inject({
|
|
127
|
+
method: 'POST',
|
|
128
|
+
url: '/submit',
|
|
129
|
+
headers: { ...sameOrigin, [CSRF_TOKEN_HEADER]: 'hub-token' },
|
|
130
|
+
payload: { [CSRF_FIELD_NAME]: 'forged-token' }
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// Assert
|
|
134
|
+
expect(response.statusCode).toBe(403)
|
|
135
|
+
const logged = JSON.stringify(mocks.loggerWarn.mock.calls)
|
|
136
|
+
expect(logged).toContain('CSRF token check failed')
|
|
137
|
+
expect(logged).not.toContain('hub-token')
|
|
138
|
+
expect(logged).not.toContain('forged-token')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test.each(['PUT', 'PATCH', 'DELETE'])(
|
|
142
|
+
'checks a %s request',
|
|
143
|
+
async (method) => {
|
|
144
|
+
// Arrange
|
|
145
|
+
await createServer({
|
|
146
|
+
tokenSource: 'header',
|
|
147
|
+
routes: [{ method, path: '/submit', handler: () => 'done' }]
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
// Act
|
|
151
|
+
const response = await server.inject({
|
|
152
|
+
method,
|
|
153
|
+
url: '/submit',
|
|
154
|
+
headers: crossSite
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
// Assert
|
|
158
|
+
expect(response.statusCode).toBe(403)
|
|
159
|
+
}
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
test.each([
|
|
163
|
+
['GET', 'GET'],
|
|
164
|
+
['HEAD', 'GET'],
|
|
165
|
+
['OPTIONS', 'OPTIONS']
|
|
166
|
+
])('lets a cross-site %s through', async (method, routeMethod) => {
|
|
167
|
+
// Arrange
|
|
168
|
+
await createServer({
|
|
169
|
+
tokenSource: 'header',
|
|
170
|
+
routes: [{ method: routeMethod, path: '/page', handler: () => 'page' }]
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
// Act
|
|
174
|
+
const response = await server.inject({
|
|
175
|
+
method,
|
|
176
|
+
url: '/page',
|
|
177
|
+
headers: crossSite
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
// Assert
|
|
181
|
+
expect(response.statusCode).toBe(200)
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
describe('payload handling', () => {
|
|
186
|
+
test('strips the token before Joi payload validation and the handler', async () => {
|
|
187
|
+
// Arrange
|
|
188
|
+
await createServer({
|
|
189
|
+
tokenSource: 'header',
|
|
190
|
+
routes: [
|
|
191
|
+
echoRoute({
|
|
192
|
+
validate: {
|
|
193
|
+
payload: Joi.object({ name: Joi.string().required() })
|
|
194
|
+
}
|
|
195
|
+
})
|
|
196
|
+
]
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
// Act
|
|
200
|
+
const response = await server.inject({
|
|
201
|
+
method: 'POST',
|
|
202
|
+
url: '/submit',
|
|
203
|
+
headers: {
|
|
204
|
+
...sameOrigin,
|
|
205
|
+
...formEncoded,
|
|
206
|
+
[CSRF_TOKEN_HEADER]: 'hub-token'
|
|
207
|
+
},
|
|
208
|
+
payload: `${CSRF_FIELD_NAME}=hub-token&name=Daisy`
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
// Assert
|
|
212
|
+
expect(response.statusCode).toBe(200)
|
|
213
|
+
expect(response.result).toEqual({ name: 'Daisy' })
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test('refuses a streamed body unless the route opts out of the token check', async () => {
|
|
217
|
+
// Arrange
|
|
218
|
+
await createServer({
|
|
219
|
+
tokenSource: 'header',
|
|
220
|
+
routes: [echoRoute({ payload: { output: 'stream', parse: false } })]
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
// Act
|
|
224
|
+
const response = await server.inject({
|
|
225
|
+
method: 'POST',
|
|
226
|
+
url: '/submit',
|
|
227
|
+
headers: {
|
|
228
|
+
...sameOrigin,
|
|
229
|
+
...formEncoded,
|
|
230
|
+
[CSRF_TOKEN_HEADER]: 'hub-token'
|
|
231
|
+
},
|
|
232
|
+
payload: `${CSRF_FIELD_NAME}=hub-token`
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
// Assert
|
|
236
|
+
expect(response.statusCode).toBe(403)
|
|
237
|
+
})
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
describe('route opt-outs', () => {
|
|
241
|
+
test.each([
|
|
242
|
+
['false', false],
|
|
243
|
+
['enabled: false', { enabled: false }]
|
|
244
|
+
])('skips every check for %s', async (_label, setting) => {
|
|
245
|
+
// Arrange
|
|
246
|
+
await createServer({
|
|
247
|
+
tokenSource: 'header',
|
|
248
|
+
routes: [echoRoute({ plugins: { [CSRF_PLUGIN_NAME]: setting } })]
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
// Act
|
|
252
|
+
const response = await server.inject({
|
|
253
|
+
method: 'POST',
|
|
254
|
+
url: '/submit',
|
|
255
|
+
headers: crossSite,
|
|
256
|
+
payload: { name: 'Daisy' }
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
// Assert
|
|
260
|
+
expect(response.statusCode).toBe(200)
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
test.each([
|
|
264
|
+
['a same-origin parsed body', sameOrigin, {}, 200],
|
|
265
|
+
['a cross-site parsed body', crossSite, {}, 403],
|
|
266
|
+
[
|
|
267
|
+
'a same-origin streamed body',
|
|
268
|
+
sameOrigin,
|
|
269
|
+
{ payload: { output: 'stream', parse: false } },
|
|
270
|
+
200
|
|
271
|
+
],
|
|
272
|
+
[
|
|
273
|
+
'a cross-site streamed body',
|
|
274
|
+
crossSite,
|
|
275
|
+
{ payload: { output: 'stream', parse: false } },
|
|
276
|
+
403
|
|
277
|
+
]
|
|
278
|
+
])(
|
|
279
|
+
'keeps only the origin check for token: false, given %s',
|
|
280
|
+
async (_label, headers, routeOptions, expectedStatus) => {
|
|
281
|
+
// Arrange
|
|
282
|
+
await createServer({
|
|
283
|
+
tokenSource: 'header',
|
|
284
|
+
routes: [
|
|
285
|
+
echoRoute({
|
|
286
|
+
...routeOptions,
|
|
287
|
+
plugins: { [CSRF_PLUGIN_NAME]: { token: false } }
|
|
288
|
+
})
|
|
289
|
+
]
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
// Act
|
|
293
|
+
const response = await server.inject({
|
|
294
|
+
method: 'POST',
|
|
295
|
+
url: '/submit',
|
|
296
|
+
headers: { ...headers, ...formEncoded },
|
|
297
|
+
payload: 'name=Daisy'
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
// Assert
|
|
301
|
+
expect(response.statusCode).toBe(expectedStatus)
|
|
302
|
+
}
|
|
303
|
+
)
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
describe('bodies and headers', () => {
|
|
307
|
+
test.each([
|
|
308
|
+
['the token', 'hub-token', 200],
|
|
309
|
+
['another value', 'forged', 403],
|
|
310
|
+
['an object', { value: 'hub-token' }, 403]
|
|
311
|
+
])(
|
|
312
|
+
'checks a JSON body whose _csrf is %s',
|
|
313
|
+
async (_label, submitted, expectedStatus) => {
|
|
314
|
+
// Arrange
|
|
315
|
+
await createServer({ tokenSource: 'header', routes: [echoRoute()] })
|
|
316
|
+
|
|
317
|
+
// Act
|
|
318
|
+
const response = await server.inject({
|
|
319
|
+
method: 'POST',
|
|
320
|
+
url: '/submit',
|
|
321
|
+
headers: {
|
|
322
|
+
...sameOrigin,
|
|
323
|
+
'content-type': 'application/json',
|
|
324
|
+
[CSRF_TOKEN_HEADER]: 'hub-token'
|
|
325
|
+
},
|
|
326
|
+
payload: JSON.stringify({
|
|
327
|
+
[CSRF_FIELD_NAME]: submitted,
|
|
328
|
+
name: 'Daisy'
|
|
329
|
+
})
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
// Assert
|
|
333
|
+
expect(response.statusCode).toBe(expectedStatus)
|
|
334
|
+
}
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
test.each([
|
|
338
|
+
['carrying the token', 'hub-token', 200],
|
|
339
|
+
['carrying another value', 'forged', 403]
|
|
340
|
+
])(
|
|
341
|
+
'checks a parsed multipart body %s',
|
|
342
|
+
async (_label, submitted, expectedStatus) => {
|
|
343
|
+
// Arrange
|
|
344
|
+
await createServer({
|
|
345
|
+
tokenSource: 'header',
|
|
346
|
+
routes: [echoRoute({ payload: { multipart: { output: 'data' } } })]
|
|
347
|
+
})
|
|
348
|
+
const boundary = 'lis-boundary'
|
|
349
|
+
const payload = [
|
|
350
|
+
`--${boundary}`,
|
|
351
|
+
`Content-Disposition: form-data; name="${CSRF_FIELD_NAME}"`,
|
|
352
|
+
'',
|
|
353
|
+
submitted,
|
|
354
|
+
`--${boundary}--`,
|
|
355
|
+
''
|
|
356
|
+
].join('\r\n')
|
|
357
|
+
|
|
358
|
+
// Act
|
|
359
|
+
const response = await server.inject({
|
|
360
|
+
method: 'POST',
|
|
361
|
+
url: '/submit',
|
|
362
|
+
headers: {
|
|
363
|
+
...sameOrigin,
|
|
364
|
+
'content-type': `multipart/form-data; boundary=${boundary}`,
|
|
365
|
+
[CSRF_TOKEN_HEADER]: 'hub-token'
|
|
366
|
+
},
|
|
367
|
+
payload
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
// Assert
|
|
371
|
+
expect(response.statusCode).toBe(expectedStatus)
|
|
372
|
+
}
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
test('refuses a post with a duplicated Sec-Fetch-Site header', async () => {
|
|
376
|
+
// Arrange
|
|
377
|
+
await createServer({ tokenSource: 'header', routes: [echoRoute()] })
|
|
378
|
+
|
|
379
|
+
// Act
|
|
380
|
+
const response = await server.inject({
|
|
381
|
+
method: 'POST',
|
|
382
|
+
url: '/submit',
|
|
383
|
+
headers: {
|
|
384
|
+
host: 'lis.test',
|
|
385
|
+
'sec-fetch-site': ['same-origin', 'same-origin'],
|
|
386
|
+
[CSRF_TOKEN_HEADER]: 'hub-token'
|
|
387
|
+
},
|
|
388
|
+
payload: { [CSRF_FIELD_NAME]: 'hub-token' }
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
// Assert
|
|
392
|
+
expect(response.statusCode).toBe(403)
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
test('answers a cross-site post to an unknown path 404, as hapi runs no route lifecycle for it', async () => {
|
|
396
|
+
// Arrange
|
|
397
|
+
await createServer({ tokenSource: 'header', routes: [echoRoute()] })
|
|
398
|
+
|
|
399
|
+
// Act
|
|
400
|
+
const response = await server.inject({
|
|
401
|
+
method: 'POST',
|
|
402
|
+
url: '/unknown',
|
|
403
|
+
headers: crossSite,
|
|
404
|
+
payload: {}
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
// Assert
|
|
408
|
+
expect(response.statusCode).toBe(404)
|
|
409
|
+
expect(mocks.loggerWarn).not.toHaveBeenCalled()
|
|
410
|
+
})
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
describe('auth: false routes in session mode', () => {
|
|
414
|
+
test('accepts the stored token on a route that skips authentication', async () => {
|
|
415
|
+
// Arrange
|
|
416
|
+
await createServer({
|
|
417
|
+
tokenSource: 'session',
|
|
418
|
+
routes: [tokenRoute, echoRoute({ auth: false })]
|
|
419
|
+
})
|
|
420
|
+
const { token, cookie } = await signInAndGetToken()
|
|
421
|
+
|
|
422
|
+
// Act
|
|
423
|
+
const response = await server.inject({
|
|
424
|
+
method: 'POST',
|
|
425
|
+
url: '/submit',
|
|
426
|
+
headers: { ...sameOrigin, cookie },
|
|
427
|
+
payload: { [CSRF_FIELD_NAME]: token }
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
// Assert
|
|
431
|
+
expect(response.statusCode).toBe(200)
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
test('refuses a post to such a route when the session holds no token', async () => {
|
|
435
|
+
// Arrange
|
|
436
|
+
await createServer({
|
|
437
|
+
tokenSource: 'session',
|
|
438
|
+
routes: [echoRoute({ auth: false })]
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
// Act
|
|
442
|
+
const response = await server.inject({
|
|
443
|
+
method: 'POST',
|
|
444
|
+
url: '/submit',
|
|
445
|
+
headers: sameOrigin,
|
|
446
|
+
payload: { [CSRF_FIELD_NAME]: '' }
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
// Assert
|
|
450
|
+
expect(response.statusCode).toBe(403)
|
|
451
|
+
})
|
|
452
|
+
})
|
|
453
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** The plugin name, also the key of its route options and of request.plugins. */
|
|
2
|
+
export const CSRF_PLUGIN_NAME = 'lis-csrf'
|
|
3
|
+
|
|
4
|
+
/** The form field that carries the token. */
|
|
5
|
+
export const CSRF_FIELD_NAME = '_csrf'
|
|
6
|
+
|
|
7
|
+
/** The header a hub sends a spoke the token in, taken from the hub session. */
|
|
8
|
+
export const CSRF_TOKEN_HEADER = 'x-lis-csrf-token'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The yar session key a hub keeps the token under. @defra/lis-hubs-infra-access
|
|
12
|
+
* clears the same key at sign-in, as it cannot import this package.
|
|
13
|
+
*/
|
|
14
|
+
export const CSRF_SESSION_KEY = 'lis-csrf-token'
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import Boom from '@hapi/boom'
|
|
2
|
+
import hapi from '@hapi/hapi'
|
|
3
|
+
import { afterEach, describe, expect, test, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { CSRF_FIELD_NAME, CSRF_TOKEN_HEADER, csrfPlugin } from './index.js'
|
|
6
|
+
|
|
7
|
+
// The shared logger's methods are getters, so it cannot be spied on.
|
|
8
|
+
const { logger } = vi.hoisted(() => ({ logger: { warn: vi.fn() } }))
|
|
9
|
+
|
|
10
|
+
vi.mock('@defra/lis-hubs-infra-core', () => ({ logger }))
|
|
11
|
+
|
|
12
|
+
const mocks = {
|
|
13
|
+
loggerWarn: logger.warn
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const sameOrigin = { host: 'lis.test', 'sec-fetch-site': 'same-origin' }
|
|
17
|
+
const crossSite = { host: 'lis.test', 'sec-fetch-site': 'cross-site' }
|
|
18
|
+
|
|
19
|
+
let server
|
|
20
|
+
|
|
21
|
+
async function createServer(failAction) {
|
|
22
|
+
server = hapi.server()
|
|
23
|
+
await server.register({
|
|
24
|
+
plugin: csrfPlugin,
|
|
25
|
+
options: { tokenSource: 'header' }
|
|
26
|
+
})
|
|
27
|
+
server.route({
|
|
28
|
+
method: 'POST',
|
|
29
|
+
path: '/submit',
|
|
30
|
+
options: { plugins: { 'lis-csrf': { failAction } } },
|
|
31
|
+
handler: () => 'handled'
|
|
32
|
+
})
|
|
33
|
+
await server.initialize()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('csrfPlugin failAction', () => {
|
|
37
|
+
afterEach(async () => {
|
|
38
|
+
vi.clearAllMocks()
|
|
39
|
+
await server?.stop()
|
|
40
|
+
server = undefined
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('answers a failed origin check with the route failAction after logging it', async () => {
|
|
44
|
+
// Arrange
|
|
45
|
+
const failAction = vi.fn((_request, h) => h.redirect('/'))
|
|
46
|
+
await createServer(failAction)
|
|
47
|
+
|
|
48
|
+
// Act
|
|
49
|
+
const response = await server.inject({
|
|
50
|
+
method: 'POST',
|
|
51
|
+
url: '/submit',
|
|
52
|
+
headers: { ...crossSite, [CSRF_TOKEN_HEADER]: 'hub-token' },
|
|
53
|
+
payload: { [CSRF_FIELD_NAME]: 'hub-token' }
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// Assert
|
|
57
|
+
expect(response.statusCode).toBe(302)
|
|
58
|
+
expect(response.headers.location).toBe('/')
|
|
59
|
+
expect(failAction).toHaveBeenCalledTimes(1)
|
|
60
|
+
expect(mocks.loggerWarn).toHaveBeenCalledWith(
|
|
61
|
+
expect.objectContaining({ event: { reason: 'csrf-origin' } }),
|
|
62
|
+
'CSRF origin check failed'
|
|
63
|
+
)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('answers a failed token check with the route failAction after logging it', async () => {
|
|
67
|
+
// Arrange
|
|
68
|
+
const failAction = vi.fn((_request, h) => h.redirect('/'))
|
|
69
|
+
await createServer(failAction)
|
|
70
|
+
|
|
71
|
+
// Act
|
|
72
|
+
const response = await server.inject({
|
|
73
|
+
method: 'POST',
|
|
74
|
+
url: '/submit',
|
|
75
|
+
headers: sameOrigin,
|
|
76
|
+
payload: { [CSRF_FIELD_NAME]: 'forged' }
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// Assert
|
|
80
|
+
expect(response.statusCode).toBe(302)
|
|
81
|
+
expect(response.payload).not.toBe('handled')
|
|
82
|
+
expect(mocks.loggerWarn).toHaveBeenCalledWith(
|
|
83
|
+
expect.objectContaining({ event: { reason: 'csrf-token' } }),
|
|
84
|
+
'CSRF token check failed'
|
|
85
|
+
)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('passes on an error the failAction returns', async () => {
|
|
89
|
+
// Arrange
|
|
90
|
+
await createServer(() => Boom.badRequest())
|
|
91
|
+
|
|
92
|
+
// Act
|
|
93
|
+
const response = await server.inject({
|
|
94
|
+
method: 'POST',
|
|
95
|
+
url: '/submit',
|
|
96
|
+
headers: crossSite,
|
|
97
|
+
payload: {}
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// Assert
|
|
101
|
+
expect(response.statusCode).toBe(400)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('still refuses with a 403 when the failAction continues', async () => {
|
|
105
|
+
// Arrange
|
|
106
|
+
await createServer((_request, h) => h.continue)
|
|
107
|
+
|
|
108
|
+
// Act
|
|
109
|
+
const response = await server.inject({
|
|
110
|
+
method: 'POST',
|
|
111
|
+
url: '/submit',
|
|
112
|
+
headers: crossSite,
|
|
113
|
+
payload: {}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// Assert
|
|
117
|
+
expect(response.statusCode).toBe(403)
|
|
118
|
+
expect(response.payload).not.toBe('handled')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('does not call the failAction when the checks pass', async () => {
|
|
122
|
+
// Arrange
|
|
123
|
+
const failAction = vi.fn()
|
|
124
|
+
await createServer(failAction)
|
|
125
|
+
|
|
126
|
+
// Act
|
|
127
|
+
const response = await server.inject({
|
|
128
|
+
method: 'POST',
|
|
129
|
+
url: '/submit',
|
|
130
|
+
headers: { ...sameOrigin, [CSRF_TOKEN_HEADER]: 'hub-token' },
|
|
131
|
+
payload: { [CSRF_FIELD_NAME]: 'hub-token' }
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// Assert
|
|
135
|
+
expect(response.statusCode).toBe(200)
|
|
136
|
+
expect(response.payload).toBe('handled')
|
|
137
|
+
expect(failAction).not.toHaveBeenCalled()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('refuses with a 403 when failAction is not a function', async () => {
|
|
141
|
+
// Arrange
|
|
142
|
+
await createServer('/')
|
|
143
|
+
|
|
144
|
+
// Act
|
|
145
|
+
const response = await server.inject({
|
|
146
|
+
method: 'POST',
|
|
147
|
+
url: '/submit',
|
|
148
|
+
headers: crossSite,
|
|
149
|
+
payload: {}
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
// Assert
|
|
153
|
+
expect(response.statusCode).toBe(403)
|
|
154
|
+
})
|
|
155
|
+
})
|