@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,245 @@
1
+ /** @import { Plugin, Request, ResponseToolkit } from '@hapi/hapi' */
2
+ import { randomBytes, timingSafeEqual } from 'node:crypto'
3
+
4
+ import Boom from '@hapi/boom'
5
+ import { logger } from '@defra/lis-hubs-infra-core'
6
+
7
+ import {
8
+ CSRF_FIELD_NAME,
9
+ CSRF_PLUGIN_NAME,
10
+ CSRF_SESSION_KEY,
11
+ CSRF_TOKEN_HEADER
12
+ } from './constants.js'
13
+ import { isSameOriginRequest } from './same-origin.js'
14
+
15
+ export {
16
+ CSRF_FIELD_NAME,
17
+ CSRF_PLUGIN_NAME,
18
+ CSRF_SESSION_KEY,
19
+ CSRF_TOKEN_HEADER
20
+ }
21
+ export { isSameOriginRequest }
22
+
23
+ const TOKEN_BYTES = 32
24
+ const SAFE_METHODS = new Set(['get', 'head', 'options'])
25
+
26
+ const tokenResolvers = {
27
+ session: resolveSessionToken,
28
+ header: resolveHeaderToken
29
+ }
30
+
31
+ /**
32
+ * @typedef {object} CsrfPluginOptions
33
+ * @property {'session' | 'header'} tokenSource - `session` for a hub, which
34
+ * keeps the token in its yar session; `header` for a spoke, which gets it from
35
+ * the hub in the `x-lis-csrf-token` header
36
+ */
37
+
38
+ /**
39
+ * @typedef {(request: Request, h: ResponseToolkit) => unknown} CsrfFailAction
40
+ * Answers a request that failed a check, in place of the 403. It must respond
41
+ * (e.g. `h.redirect('/')`) or return an error: `h.continue` is refused with a
42
+ * 403 all the same, so a failed request never reaches the handler.
43
+ */
44
+
45
+ /**
46
+ * @typedef {false | { enabled?: boolean, token?: boolean, failAction?: CsrfFailAction }} CsrfRouteOptions
47
+ * Route `options.plugins['lis-csrf']`: `false` or `{ enabled: false }` skips
48
+ * every check; `{ token: false }` keeps the origin check only; `failAction`
49
+ * answers a failed check in place of the 403, after the warning is logged.
50
+ */
51
+
52
+ /**
53
+ * A session gets a token only once signed in, so an anonymous visitor is not
54
+ * given a session for it.
55
+ * @param {Request} request
56
+ * @returns {string | undefined}
57
+ */
58
+ function resolveSessionToken(request) {
59
+ const stored = request.yar?.get(CSRF_SESSION_KEY)
60
+
61
+ if (typeof stored === 'string' && stored !== '') {
62
+ return stored
63
+ }
64
+
65
+ if (!request.auth.isAuthenticated || !request.yar) {
66
+ return undefined
67
+ }
68
+
69
+ const token = randomBytes(TOKEN_BYTES).toString('base64url')
70
+ request.yar.set(CSRF_SESSION_KEY, token)
71
+
72
+ return token
73
+ }
74
+
75
+ /**
76
+ * @param {Request} request
77
+ * @returns {string | undefined}
78
+ */
79
+ function resolveHeaderToken(request) {
80
+ const token = request.headers[CSRF_TOKEN_HEADER]
81
+
82
+ return typeof token === 'string' && token !== '' ? token : undefined
83
+ }
84
+
85
+ /**
86
+ * @param {Request} request
87
+ * @returns {{ enabled: boolean, checkToken: boolean, failAction?: CsrfFailAction }}
88
+ */
89
+ function getRouteSettings(request) {
90
+ const settings = request.route.settings.plugins?.[CSRF_PLUGIN_NAME]
91
+
92
+ if (settings === false || settings?.enabled === false) {
93
+ return { enabled: false, checkToken: false }
94
+ }
95
+
96
+ return {
97
+ enabled: true,
98
+ checkToken: settings?.token !== false,
99
+ ...(typeof settings?.failAction === 'function' && {
100
+ failAction: settings.failAction
101
+ })
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Only a parsed form or JSON body can carry the field: a streamed or raw body
107
+ * fails the check unless the route opted out of it.
108
+ * @param {unknown} payload
109
+ * @returns {payload is Record<string, unknown>}
110
+ */
111
+ function isParsedObject(payload) {
112
+ if (typeof payload !== 'object' || payload === null) {
113
+ return false
114
+ }
115
+
116
+ const prototype = Object.getPrototypeOf(payload)
117
+
118
+ return prototype === Object.prototype || prototype === null
119
+ }
120
+
121
+ /**
122
+ * @param {unknown} submitted
123
+ * @param {string | undefined} expected
124
+ * @returns {boolean}
125
+ */
126
+ function tokensMatch(submitted, expected) {
127
+ if (
128
+ typeof submitted !== 'string' ||
129
+ typeof expected !== 'string' ||
130
+ expected === ''
131
+ ) {
132
+ return false
133
+ }
134
+
135
+ const submittedBytes = Buffer.from(submitted)
136
+ const expectedBytes = Buffer.from(expected)
137
+
138
+ return (
139
+ submittedBytes.length === expectedBytes.length &&
140
+ timingSafeEqual(submittedBytes, expectedBytes)
141
+ )
142
+ }
143
+
144
+ /**
145
+ * @param {Request} request
146
+ * @param {ResponseToolkit} h
147
+ * @param {'origin' | 'token'} check
148
+ * @param {CsrfFailAction} [failAction]
149
+ * @returns {Promise<unknown>} the route's failAction response, taken over, or a 403
150
+ */
151
+ async function reject(request, h, check, failAction) {
152
+ logger.warn(
153
+ {
154
+ http: { request: { method: request.method } },
155
+ url: { path: request.path },
156
+ event: { reason: `csrf-${check}` }
157
+ },
158
+ `CSRF ${check} check failed`
159
+ )
160
+
161
+ if (!failAction) {
162
+ return Boom.forbidden()
163
+ }
164
+
165
+ const result = await failAction(request, h)
166
+
167
+ if (result === h.continue) {
168
+ return Boom.forbidden()
169
+ }
170
+
171
+ return typeof result?.takeover === 'function' ? result.takeover() : result
172
+ }
173
+
174
+ /**
175
+ * @param {(request: Request) => string | undefined} resolveToken
176
+ * @returns {(request: Request, h: ResponseToolkit) => Promise<unknown>}
177
+ */
178
+ function createOnPostAuth(resolveToken) {
179
+ return async function protectAgainstCsrf(request, h) {
180
+ const token = resolveToken(request)
181
+ request.plugins[CSRF_PLUGIN_NAME] = { token }
182
+
183
+ const { enabled, checkToken, failAction } = getRouteSettings(request)
184
+
185
+ if (!enabled || SAFE_METHODS.has(request.method)) {
186
+ return h.continue
187
+ }
188
+
189
+ if (!isSameOriginRequest(request)) {
190
+ return reject(request, h, 'origin', failAction)
191
+ }
192
+
193
+ if (!checkToken) {
194
+ return h.continue
195
+ }
196
+
197
+ const { payload } = request
198
+
199
+ if (
200
+ !isParsedObject(payload) ||
201
+ !tokensMatch(payload[CSRF_FIELD_NAME], token)
202
+ ) {
203
+ return reject(request, h, 'token', failAction)
204
+ }
205
+
206
+ // Route payload validation runs after onPostAuth, so it never sees the field.
207
+ delete payload[CSRF_FIELD_NAME]
208
+
209
+ return h.continue
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Drops the session's token, so the next signed-in request is issued a new
215
+ * one. Call it when a user signs in, unless the session is reset there.
216
+ * @param {Request} request
217
+ */
218
+ export function clearCsrfToken(request) {
219
+ request.yar?.clear(CSRF_SESSION_KEY)
220
+ request.plugins[CSRF_PLUGIN_NAME] = {}
221
+ }
222
+
223
+ /**
224
+ * Refuses a POST, PUT, PATCH or DELETE that is not from this origin or does
225
+ * not carry the token in its `_csrf` field. The token is resolved after
226
+ * authentication on every request and published at
227
+ * `request.plugins['lis-csrf'].token` for views and the hub proxy.
228
+ * @type {Plugin<CsrfPluginOptions>}
229
+ */
230
+ export const csrfPlugin = {
231
+ name: CSRF_PLUGIN_NAME,
232
+ register(server, { tokenSource } = {}) {
233
+ if (!Object.hasOwn(tokenResolvers, tokenSource ?? '')) {
234
+ throw new Error(
235
+ `${CSRF_PLUGIN_NAME} tokenSource must be 'session' or 'header'`
236
+ )
237
+ }
238
+
239
+ if (tokenSource === 'session') {
240
+ server.dependency('@hapi/yar')
241
+ }
242
+
243
+ server.ext('onPostAuth', createOnPostAuth(tokenResolvers[tokenSource]))
244
+ }
245
+ }
@@ -0,0 +1,284 @@
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
+ clearCsrfToken,
14
+ csrfPlugin
15
+ } from './index.js'
16
+
17
+ // The shared logger's methods are getters, so it cannot be spied on.
18
+ const { logger } = vi.hoisted(() => ({ logger: { warn: vi.fn() } }))
19
+
20
+ vi.mock('@defra/lis-hubs-infra-core', () => ({ logger }))
21
+
22
+ const sameOrigin = { host: 'lis.test', 'sec-fetch-site': 'same-origin' }
23
+ const signedIn = { 'x-test-user': 'keeper' }
24
+
25
+ const tokenRoute = {
26
+ method: 'GET',
27
+ path: '/token',
28
+ handler: (request) => ({
29
+ token: request.plugins[CSRF_PLUGIN_NAME].token ?? null
30
+ })
31
+ }
32
+
33
+ function echoRoute(options = {}) {
34
+ return {
35
+ method: 'POST',
36
+ path: '/submit',
37
+ options,
38
+ handler: (request) => request.payload ?? { streamed: true }
39
+ }
40
+ }
41
+
42
+ let server
43
+
44
+ async function createServer({ tokenSource, routes }) {
45
+ server = hapi.server()
46
+ server.validator(Joi)
47
+ await server.register({
48
+ plugin: yar,
49
+ options: {
50
+ storeBlank: false,
51
+ maxCookieSize: 0,
52
+ cookieOptions: {
53
+ password: randomBytes(32).toString('hex'),
54
+ isSecure: false
55
+ }
56
+ }
57
+ })
58
+ server.auth.scheme('test', () => ({
59
+ authenticate: (request, h) =>
60
+ request.headers['x-test-user']
61
+ ? h.authenticated({ credentials: { user: { id: 'keeper' } } })
62
+ : h.unauthenticated(Boom.unauthorized())
63
+ }))
64
+ server.auth.strategy('test', 'test')
65
+ server.auth.default({ strategy: 'test', mode: 'try' })
66
+ await server.register({ plugin: csrfPlugin, options: { tokenSource } })
67
+ server.route(routes)
68
+ await server.initialize()
69
+ }
70
+
71
+ async function signInAndGetToken() {
72
+ const response = await server.inject({
73
+ method: 'GET',
74
+ url: '/token',
75
+ headers: signedIn
76
+ })
77
+
78
+ return {
79
+ token: response.result.token,
80
+ cookie: response.headers['set-cookie'][0].split(';')[0]
81
+ }
82
+ }
83
+
84
+ describe('csrfPlugin', () => {
85
+ afterEach(async () => {
86
+ vi.clearAllMocks()
87
+ await server?.stop()
88
+ server = undefined
89
+ })
90
+
91
+ describe('registration', () => {
92
+ test.each([undefined, 'cookie', 'constructor'])(
93
+ 'refuses tokenSource %s',
94
+ async (tokenSource) => {
95
+ // Arrange
96
+ let error
97
+
98
+ // Act
99
+ try {
100
+ await createServer({ tokenSource, routes: [tokenRoute] })
101
+ } catch (e) {
102
+ error = e
103
+ }
104
+
105
+ // Assert
106
+ expect(error.message).toBe(
107
+ "lis-csrf tokenSource must be 'session' or 'header'"
108
+ )
109
+ }
110
+ )
111
+
112
+ test('requires yar in session mode', async () => {
113
+ // Arrange
114
+ server = hapi.server()
115
+ await server.register({
116
+ plugin: csrfPlugin,
117
+ options: { tokenSource: 'session' }
118
+ })
119
+ let error
120
+
121
+ // Act
122
+ try {
123
+ await server.initialize()
124
+ } catch (e) {
125
+ error = e
126
+ }
127
+
128
+ // Assert
129
+ expect(error.message).toContain('@hapi/yar')
130
+ })
131
+ })
132
+
133
+ describe('session token', () => {
134
+ test('issues a 32-byte base64url token to a signed-in session', async () => {
135
+ // Arrange
136
+ await createServer({ tokenSource: 'session', routes: [tokenRoute] })
137
+
138
+ // Act
139
+ const response = await server.inject({
140
+ method: 'GET',
141
+ url: '/token',
142
+ headers: signedIn
143
+ })
144
+
145
+ // Assert
146
+ expect(response.result.token).toMatch(/^[\w-]{43}$/)
147
+ expect(response.headers['set-cookie']).toBeDefined()
148
+ })
149
+
150
+ test('reuses the session token on later requests', async () => {
151
+ // Arrange
152
+ await createServer({ tokenSource: 'session', routes: [tokenRoute] })
153
+ const { token, cookie } = await signInAndGetToken()
154
+
155
+ // Act
156
+ const response = await server.inject({
157
+ method: 'GET',
158
+ url: '/token',
159
+ headers: { ...signedIn, cookie }
160
+ })
161
+
162
+ // Assert
163
+ expect(response.result.token).toBe(token)
164
+ })
165
+
166
+ test('gives an anonymous visitor no token and no session', async () => {
167
+ // Arrange
168
+ await createServer({ tokenSource: 'session', routes: [tokenRoute] })
169
+
170
+ // Act
171
+ const response = await server.inject({ method: 'GET', url: '/token' })
172
+
173
+ // Assert
174
+ expect(response.result.token).toBeNull()
175
+ expect(response.headers['set-cookie']).toBeUndefined()
176
+ })
177
+
178
+ test('accepts a same-origin post carrying the session token', async () => {
179
+ // Arrange
180
+ await createServer({
181
+ tokenSource: 'session',
182
+ routes: [tokenRoute, echoRoute()]
183
+ })
184
+ const { token, cookie } = await signInAndGetToken()
185
+
186
+ // Act
187
+ const response = await server.inject({
188
+ method: 'POST',
189
+ url: '/submit',
190
+ headers: { ...signedIn, ...sameOrigin, cookie },
191
+ payload: { [CSRF_FIELD_NAME]: token, name: 'Daisy' }
192
+ })
193
+
194
+ // Assert
195
+ expect(response.statusCode).toBe(200)
196
+ expect(response.result).toEqual({ name: 'Daisy' })
197
+ })
198
+
199
+ test('issues a new token once the old one is cleared', async () => {
200
+ // Arrange
201
+ await createServer({
202
+ tokenSource: 'session',
203
+ routes: [
204
+ tokenRoute,
205
+ {
206
+ method: 'GET',
207
+ path: '/clear',
208
+ handler(request) {
209
+ clearCsrfToken(request)
210
+ return { token: request.plugins[CSRF_PLUGIN_NAME].token ?? null }
211
+ }
212
+ }
213
+ ]
214
+ })
215
+ const { token, cookie } = await signInAndGetToken()
216
+
217
+ // Act
218
+ const cleared = await server.inject({
219
+ method: 'GET',
220
+ url: '/clear',
221
+ headers: { ...signedIn, cookie }
222
+ })
223
+ const reissued = await server.inject({
224
+ method: 'GET',
225
+ url: '/token',
226
+ headers: { ...signedIn, cookie }
227
+ })
228
+
229
+ // Assert
230
+ expect(cleared.result.token).toBeNull()
231
+ expect(reissued.result.token).toMatch(/^[\w-]{43}$/)
232
+ expect(reissued.result.token).not.toBe(token)
233
+ })
234
+ })
235
+
236
+ describe('header token', () => {
237
+ test('publishes the token from the hub header', async () => {
238
+ // Arrange
239
+ await createServer({ tokenSource: 'header', routes: [tokenRoute] })
240
+
241
+ // Act
242
+ const response = await server.inject({
243
+ method: 'GET',
244
+ url: '/token',
245
+ headers: { [CSRF_TOKEN_HEADER]: 'hub-token' }
246
+ })
247
+
248
+ // Assert
249
+ expect(response.result.token).toBe('hub-token')
250
+ })
251
+
252
+ test.each([
253
+ ['a matching token', 'hub-token', 'hub-token', 200],
254
+ ['a different token of the same length', 'hub-token', 'bad-token', 403],
255
+ ['a shorter token', 'hub-token', 'hub', 403],
256
+ ['a longer token', 'hub-token', 'hub-token-and-more', 403],
257
+ ['no submitted token', 'hub-token', undefined, 403],
258
+ ['two submitted tokens', 'hub-token', ['hub-token', 'hub-token'], 403],
259
+ ['no hub token', undefined, 'hub-token', 403],
260
+ ['two empty tokens', '', '', 403]
261
+ ])(
262
+ 'answers a same-origin post with %s',
263
+ async (_label, headerToken, submitted, expectedStatus) => {
264
+ // Arrange
265
+ await createServer({ tokenSource: 'header', routes: [echoRoute()] })
266
+ const headers = {
267
+ ...sameOrigin,
268
+ ...(headerToken !== undefined && { [CSRF_TOKEN_HEADER]: headerToken })
269
+ }
270
+
271
+ // Act
272
+ const response = await server.inject({
273
+ method: 'POST',
274
+ url: '/submit',
275
+ headers,
276
+ payload: { [CSRF_FIELD_NAME]: submitted, name: 'Daisy' }
277
+ })
278
+
279
+ // Assert
280
+ expect(response.statusCode).toBe(expectedStatus)
281
+ }
282
+ )
283
+ })
284
+ })
@@ -0,0 +1,42 @@
1
+ /** @import { Request } from '@hapi/hapi' */
2
+
3
+ /**
4
+ * @param {Request} request
5
+ * @returns {string} the host the browser addressed, as the hub proxy forwards it
6
+ */
7
+ export function getRequestHost(request) {
8
+ return (request.headers['x-forwarded-host'] ?? request.headers.host ?? '')
9
+ .split(',')[0]
10
+ .trim()
11
+ }
12
+
13
+ /**
14
+ * Whether a request came from a page on this origin, by the browser's own
15
+ * signals. Same-site is not enough: deployed hubs share a platform domain with
16
+ * other tenants. Sec-Fetch-Site `none` is a navigation the user started (e.g.
17
+ * from the address bar), which can never be a form posted from a page, so it
18
+ * fails too. A request with neither header fails: current browsers send at
19
+ * least one of them on a form POST, but those that send neither on a
20
+ * same-origin one (IE11, Firefox before 70) are refused.
21
+ * @param {Request} request
22
+ * @returns {boolean}
23
+ */
24
+ export function isSameOriginRequest(request) {
25
+ const fetchSite = request.headers['sec-fetch-site']
26
+
27
+ if (typeof fetchSite === 'string') {
28
+ return fetchSite === 'same-origin'
29
+ }
30
+
31
+ const origin = request.headers.origin
32
+
33
+ if (typeof origin !== 'string' || origin === '') {
34
+ return false
35
+ }
36
+
37
+ try {
38
+ return new URL(origin).host === getRequestHost(request)
39
+ } catch {
40
+ return false
41
+ }
42
+ }
@@ -0,0 +1,72 @@
1
+ import { describe, expect, test } from 'vitest'
2
+
3
+ import { getRequestHost, isSameOriginRequest } from './same-origin.js'
4
+
5
+ describe('isSameOriginRequest()', () => {
6
+ test.each([
7
+ ['sec-fetch-site same-origin', { 'sec-fetch-site': 'same-origin' }, true],
8
+ ['sec-fetch-site same-site', { 'sec-fetch-site': 'same-site' }, false],
9
+ ['sec-fetch-site cross-site', { 'sec-fetch-site': 'cross-site' }, false],
10
+ ['sec-fetch-site none', { 'sec-fetch-site': 'none' }, false],
11
+ [
12
+ 'sec-fetch-site same-site with a matching origin',
13
+ { 'sec-fetch-site': 'same-site', origin: 'https://lis.test' },
14
+ false
15
+ ],
16
+ ['no signals at all', {}, false],
17
+ ['an empty origin', { origin: '' }, false],
18
+ ['matching origin', { origin: 'https://lis.test' }, true],
19
+ ['foreign origin', { origin: 'https://evil.example' }, false],
20
+ ['sibling subdomain origin', { origin: 'https://other.lis.test' }, false],
21
+ ['opaque origin', { origin: 'null' }, false],
22
+ ['unparseable origin', { origin: 'not a url' }, false],
23
+ [
24
+ 'origin matching the forwarded host',
25
+ {
26
+ host: 'internal:3001',
27
+ 'x-forwarded-host': 'lis.test',
28
+ origin: 'https://lis.test'
29
+ },
30
+ true
31
+ ],
32
+ [
33
+ 'origin matching only the internal host behind a proxy',
34
+ {
35
+ host: 'internal:3001',
36
+ 'x-forwarded-host': 'lis.test',
37
+ origin: 'https://internal:3001'
38
+ },
39
+ false
40
+ ]
41
+ ])('handles %s', (_label, headers, expected) => {
42
+ // Arrange
43
+ const request = { headers: { host: 'lis.test', ...headers } }
44
+
45
+ // Act
46
+ const actual = isSameOriginRequest(request)
47
+
48
+ // Assert
49
+ expect(actual).toBe(expected)
50
+ })
51
+ })
52
+
53
+ describe('getRequestHost()', () => {
54
+ test.each([
55
+ ['the host header', { host: 'lis.test' }, 'lis.test'],
56
+ [
57
+ 'the first forwarded host',
58
+ { host: 'internal:3001', 'x-forwarded-host': 'lis.test, proxy.test' },
59
+ 'lis.test'
60
+ ],
61
+ ['nothing when neither is sent', {}, '']
62
+ ])('returns %s', (_label, headers, expected) => {
63
+ // Arrange
64
+ const request = { headers }
65
+
66
+ // Act
67
+ const actual = getRequestHost(request)
68
+
69
+ // Assert
70
+ expect(actual).toBe(expected)
71
+ })
72
+ })