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