@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.
@@ -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
+ })