@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
package/README.md
CHANGED
|
@@ -321,6 +321,19 @@ import { DatePicker } from '@defra/lis-infra-ui-services/components/date-picker'
|
|
|
321
321
|
createAll(DatePicker)
|
|
322
322
|
```
|
|
323
323
|
|
|
324
|
+
#### Form
|
|
325
|
+
|
|
326
|
+
The `lisForm` template component renders a `novalidate` form with the CSRF token in a hidden `_csrf` field (see [CSRF protection](#csrf-protection)) around the caller's body. It takes `action` (left out when not given, so the form posts to the page itself), `method` (default `post`), `classes` and `attributes`. Import it `with context` so it reads `csrfToken` from the page, or pass `csrfToken` yourself. A `GET` form gets no token field, which would otherwise end up in the URL. A `POST` form with no token gets no field either, so its submission is refused with a 403: that usually means the macro was imported without `with context`, or the CSRF plugin is not registered on the server.
|
|
327
|
+
|
|
328
|
+
```njk
|
|
329
|
+
{% from "form/macro.njk" import lisForm with context %}
|
|
330
|
+
|
|
331
|
+
{% call lisForm({ action: "/holdings/12/345/6789/edit", classes: "lis-edit-form" }) %}
|
|
332
|
+
{{ govukInput({ id: "nickname", name: "nickname", label: { text: "Nickname" } }) }}
|
|
333
|
+
{{ govukButton({ text: "Save" }) }}
|
|
334
|
+
{% endcall %}
|
|
335
|
+
```
|
|
336
|
+
|
|
324
337
|
### Styles
|
|
325
338
|
|
|
326
339
|
Import the Sass files you need from your app's stylesheet: `components/autocomplete.scss`,
|
|
@@ -381,7 +394,43 @@ await server.register(
|
|
|
381
394
|
)
|
|
382
395
|
```
|
|
383
396
|
|
|
384
|
-
`GET /cookies` shows the page, with a form to change the choice. `POST /cookies` records a choice (`analytics=yes|no`), ignoring
|
|
397
|
+
`GET /cookies` shows the page, with a form to change the choice. `POST /cookies` records a choice (`analytics=yes|no`), ignoring any post that is not same-origin (it opts out of the CSRF plugin, as a signed-out visitor has no token), and redirects to the page with a confirmation and a link back to the same-host page the visitor came from. The `CookieBanner` component is part of `layoutComponents` (see [Layout scripts](#layout-scripts)), so a hub that already initialises those needs no further client-side change. `getCookiesPolicy(request)` from `@defra/lis-infra-ui-services/cookies/cookies-policy` reads the recorded choice server-side.
|
|
398
|
+
|
|
399
|
+
### CSRF protection
|
|
400
|
+
|
|
401
|
+
`csrfPlugin` from `@defra/lis-infra-ui-services/csrf` refuses, with a 403 and a warning log naming the failed check, every request other than `GET`, `HEAD` and `OPTIONS` that is not same-origin or does not carry the token. Register it after authentication and, on a hub, after the session:
|
|
402
|
+
|
|
403
|
+
```js
|
|
404
|
+
import { csrfPlugin } from '@defra/lis-infra-ui-services/csrf'
|
|
405
|
+
|
|
406
|
+
// hub: the token lives in the yar session, issued once the user is signed in
|
|
407
|
+
await server.register({
|
|
408
|
+
plugin: csrfPlugin,
|
|
409
|
+
options: { tokenSource: 'session' }
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
// spoke: the token comes from the hub in the x-lis-csrf-token header (CSRF_TOKEN_HEADER)
|
|
413
|
+
await server.register({
|
|
414
|
+
plugin: csrfPlugin,
|
|
415
|
+
options: { tokenSource: 'header' }
|
|
416
|
+
})
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
- **Origin check**: `Sec-Fetch-Site` must be `same-origin` (`same-site`, `cross-site` and `none` fail, as other tenants share the platform domain). Without it, the `Origin` host must match the host the browser addressed (`x-forwarded-host`, then `host`); the scheme is not compared, as TLS ends before the hub and spoke. A request with neither fails. `isSameOriginRequest(request)` exposes the check.
|
|
420
|
+
- **Token check**: the `_csrf` field (`CSRF_FIELD_NAME`) of a parsed form or JSON body must match the request's token, compared in constant time. A streamed or raw body fails. The field is removed from `request.payload` before route validation, so schemas need not allow it.
|
|
421
|
+
- The token is published at `request.plugins['lis-csrf'].token` and given to every view as `csrfToken`. `clearCsrfToken(request)` drops it from the session, so the next signed-in request gets a new one.
|
|
422
|
+
|
|
423
|
+
**Known limitation:** browsers that send neither `Sec-Fetch-Site` nor `Origin` on a same-origin form `POST` (Internet Explorer 11, Firefox before 70) are refused with a 403.
|
|
424
|
+
|
|
425
|
+
**Stricter `isSameSiteRequest`:** `isSameSiteRequest` from `@defra/lis-infra-ui-services/cookies` is kept for existing callers but deprecated, and now behaves as `isSameOriginRequest`: a `same-site` request, or one with neither `Sec-Fetch-Site` nor `Origin`, which it used to accept, is now refused.
|
|
426
|
+
|
|
427
|
+
A route opts out through `options.plugins['lis-csrf']`: `false` or `{ enabled: false }` skips every check; `{ token: false }` keeps only the origin check, for a proxied (streamed) body or a form a signed-out visitor submits.
|
|
428
|
+
|
|
429
|
+
A route can answer a failed check itself with `options.plugins['lis-csrf'] = { failAction: (request, h) => h.redirect('/') }`: the warning is still logged, then the plugin takes over with what `failAction` returns (a response or an error) in place of the 403. Returning `h.continue` still gets a 403, so a failed request never reaches the handler. Hub sign-out uses it to send an expired or forged sign-out home.
|
|
430
|
+
|
|
431
|
+
The token is kept in the hub session under `lis-csrf-token` (`CSRF_SESSION_KEY`), which `@defra/lis-hubs-infra-access` clears at sign-in. A hub proxy sends it to spokes in `x-lis-csrf-token` and leaves the token check to the spoke.
|
|
432
|
+
|
|
433
|
+
The base layout's header Sign out and the `error/not-authorised` view's Sign out (given a `signOutHref`) are links to `/signout`. A hub answers that `GET` with the `auth/sign-out` view, which asks the user to confirm: an `Are you sure you want to sign out?` heading, a `Sign out` button in a form posting the token to `signOutHref`, and a `Cancel` link to `/`. Pass `pageTitle` and `signOutHref`; render it only when there is a `csrfToken`.
|
|
385
434
|
|
|
386
435
|
### Environment variables
|
|
387
436
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@defra/lis-infra-ui-services",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.51.0",
|
|
4
4
|
"description": "UI-Services shared definitions for the livestock taxonomy topology.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"repository": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"./config-formats": "./src/config-formats.js",
|
|
14
14
|
"./cookies": "./src/cookies/index.js",
|
|
15
15
|
"./cookies/cookies-policy": "./src/cookies/cookies-policy.js",
|
|
16
|
+
"./csrf": "./src/csrf/index.js",
|
|
16
17
|
"./components/autocomplete": "./src/components/autocomplete.js",
|
|
17
18
|
"./components/cookie-banner": "./src/components/cookie-banner.js",
|
|
18
19
|
"./components/date-picker": "./src/components/date-picker.js",
|
|
@@ -63,6 +64,7 @@
|
|
|
63
64
|
"author": "Defra DDTS",
|
|
64
65
|
"license": "OGL-UK-3.0",
|
|
65
66
|
"dependencies": {
|
|
67
|
+
"@hapi/boom": "10.0.1",
|
|
66
68
|
"@hapi/catbox-memory": "6.0.2",
|
|
67
69
|
"@hapi/catbox-redis": "7.0.2",
|
|
68
70
|
"@hapi/vision": "7.0.3",
|
|
@@ -96,6 +98,7 @@
|
|
|
96
98
|
"eslint": "^9.0.0",
|
|
97
99
|
"govuk-frontend": "6.5.0",
|
|
98
100
|
"husky": "^9.1.7",
|
|
101
|
+
"joi": "17.13.8",
|
|
99
102
|
"jsdom": "^29.1.1",
|
|
100
103
|
"prettier": "^3.8.4",
|
|
101
104
|
"vitest": "^4.1.10"
|
package/src/cookies/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
/** @import { Plugin, Request, ResponseToolkit } from '@hapi/hapi' */
|
|
2
|
+
import { CSRF_PLUGIN_NAME } from '../csrf/constants.js'
|
|
3
|
+
import { isSameOriginRequest } from '../csrf/same-origin.js'
|
|
2
4
|
import { getSameHostReferrerPath } from '../referrer-link.js'
|
|
3
5
|
import {
|
|
4
6
|
COOKIES_PATH,
|
|
@@ -67,40 +69,14 @@ export function getReturnPath(request) {
|
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
/**
|
|
70
|
-
* @
|
|
71
|
-
*
|
|
72
|
-
|
|
73
|
-
function getRequestHost(request) {
|
|
74
|
-
return (request.headers['x-forwarded-host'] ?? request.headers.host ?? '')
|
|
75
|
-
.split(',')[0]
|
|
76
|
-
.trim()
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Whether a consent POST came from this site. Hapi has no CSRF token here, so
|
|
81
|
-
* the browser's own signals are used: Sec-Fetch-Site when present, otherwise
|
|
82
|
-
* the Origin header, which browsers always send on a cross-site form POST.
|
|
72
|
+
* @deprecated Use isSameOriginRequest from `@defra/lis-infra-ui-services/csrf`.
|
|
73
|
+
* Kept for existing callers; it now requires the same origin, so a same-site
|
|
74
|
+
* request, or one with neither Sec-Fetch-Site nor Origin, no longer passes.
|
|
83
75
|
* @param {Request} request
|
|
84
76
|
* @returns {boolean}
|
|
85
77
|
*/
|
|
86
78
|
export function isSameSiteRequest(request) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (typeof fetchSite === 'string') {
|
|
90
|
-
return fetchSite === 'same-origin' || fetchSite === 'same-site'
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const origin = request.headers.origin
|
|
94
|
-
|
|
95
|
-
if (typeof origin !== 'string' || origin === '') {
|
|
96
|
-
return true
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
try {
|
|
100
|
-
return new URL(origin).host === getRequestHost(request)
|
|
101
|
-
} catch {
|
|
102
|
-
return false
|
|
103
|
-
}
|
|
79
|
+
return isSameOriginRequest(request)
|
|
104
80
|
}
|
|
105
81
|
|
|
106
82
|
/**
|
|
@@ -134,7 +110,7 @@ function createGetHandler(essentialCookies) {
|
|
|
134
110
|
function postConsent(request, h) {
|
|
135
111
|
const analytics = request.payload?.analytics
|
|
136
112
|
|
|
137
|
-
if (!CONSENT_VALUES.has(analytics) || !
|
|
113
|
+
if (!CONSENT_VALUES.has(analytics) || !isSameOriginRequest(request)) {
|
|
138
114
|
return h.redirect(COOKIES_PATH)
|
|
139
115
|
}
|
|
140
116
|
|
|
@@ -176,7 +152,13 @@ export function createCookiesPlugin({ essentialCookies } = {}) {
|
|
|
176
152
|
{
|
|
177
153
|
method: 'POST',
|
|
178
154
|
path: COOKIES_PATH,
|
|
179
|
-
|
|
155
|
+
// A signed-out visitor has no CSRF token, and a refused post
|
|
156
|
+
// returns to the page rather than a 403, so the handler checks the
|
|
157
|
+
// origin itself.
|
|
158
|
+
options: {
|
|
159
|
+
auth: { mode: 'try' },
|
|
160
|
+
plugins: { [CSRF_PLUGIN_NAME]: false }
|
|
161
|
+
},
|
|
180
162
|
handler: postConsent
|
|
181
163
|
}
|
|
182
164
|
])
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
isSameSiteRequest,
|
|
14
14
|
toSameOriginPath
|
|
15
15
|
} from './index.js'
|
|
16
|
+
import { csrfPlugin } from '../csrf/index.js'
|
|
16
17
|
|
|
17
18
|
const packageRoot = path.resolve(
|
|
18
19
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
@@ -40,7 +41,7 @@ const essentialCookies = [
|
|
|
40
41
|
|
|
41
42
|
let server
|
|
42
43
|
|
|
43
|
-
async function createServer() {
|
|
44
|
+
async function createServer({ withCsrf = false } = {}) {
|
|
44
45
|
server = hapi.server()
|
|
45
46
|
await server.register({
|
|
46
47
|
plugin: hapiVision,
|
|
@@ -62,6 +63,12 @@ async function createServer() {
|
|
|
62
63
|
}))
|
|
63
64
|
server.auth.strategy('none', 'none')
|
|
64
65
|
server.auth.default('none')
|
|
66
|
+
if (withCsrf) {
|
|
67
|
+
await server.register({
|
|
68
|
+
plugin: csrfPlugin,
|
|
69
|
+
options: { tokenSource: 'header' }
|
|
70
|
+
})
|
|
71
|
+
}
|
|
65
72
|
await server.register(createCookiesPlugin({ essentialCookies }))
|
|
66
73
|
await server.initialize()
|
|
67
74
|
|
|
@@ -156,7 +163,11 @@ describe('createCookiesPlugin()', () => {
|
|
|
156
163
|
const response = await server.inject({
|
|
157
164
|
method: 'POST',
|
|
158
165
|
url: '/cookies',
|
|
159
|
-
headers: {
|
|
166
|
+
headers: {
|
|
167
|
+
host: 'lis.test',
|
|
168
|
+
referer: 'https://lis.test/cattle/home',
|
|
169
|
+
'sec-fetch-site': 'same-origin'
|
|
170
|
+
},
|
|
160
171
|
payload: { analytics: 'yes' }
|
|
161
172
|
})
|
|
162
173
|
|
|
@@ -183,6 +194,7 @@ describe('createCookiesPlugin()', () => {
|
|
|
183
194
|
const response = await server.inject({
|
|
184
195
|
method: 'POST',
|
|
185
196
|
url: '/cookies',
|
|
197
|
+
headers: { 'sec-fetch-site': 'same-origin' },
|
|
186
198
|
payload: { analytics: 'no' }
|
|
187
199
|
})
|
|
188
200
|
|
|
@@ -201,6 +213,7 @@ describe('createCookiesPlugin()', () => {
|
|
|
201
213
|
const response = await server.inject({
|
|
202
214
|
method: 'POST',
|
|
203
215
|
url: '/cookies',
|
|
216
|
+
headers: { 'sec-fetch-site': 'same-origin' },
|
|
204
217
|
payload: { analytics: 'maybe' }
|
|
205
218
|
})
|
|
206
219
|
|
|
@@ -271,10 +284,10 @@ describe('getReturnPath()', () => {
|
|
|
271
284
|
describe('isSameSiteRequest()', () => {
|
|
272
285
|
test.each([
|
|
273
286
|
['sec-fetch-site same-origin', { 'sec-fetch-site': 'same-origin' }, true],
|
|
274
|
-
['sec-fetch-site same-site', { 'sec-fetch-site': 'same-site' },
|
|
287
|
+
['sec-fetch-site same-site', { 'sec-fetch-site': 'same-site' }, false],
|
|
275
288
|
['sec-fetch-site cross-site', { 'sec-fetch-site': 'cross-site' }, false],
|
|
276
289
|
['sec-fetch-site none', { 'sec-fetch-site': 'none' }, false],
|
|
277
|
-
['no signals at all', {},
|
|
290
|
+
['no signals at all', {}, false],
|
|
278
291
|
['matching origin', { origin: 'https://lis.test' }, true],
|
|
279
292
|
['foreign origin', { origin: 'https://evil.example' }, false],
|
|
280
293
|
['unparseable origin', { origin: 'not a url' }, false],
|
|
@@ -302,7 +315,9 @@ describe('isSameSiteRequest()', () => {
|
|
|
302
315
|
describe('POST /cookies from another site', () => {
|
|
303
316
|
test.each([
|
|
304
317
|
['sec-fetch-site cross-site', { 'sec-fetch-site': 'cross-site' }],
|
|
305
|
-
['
|
|
318
|
+
['sec-fetch-site same-site', { 'sec-fetch-site': 'same-site' }],
|
|
319
|
+
['a foreign origin', { origin: 'https://evil.example' }],
|
|
320
|
+
['neither sec-fetch-site nor origin', {}]
|
|
306
321
|
])('ignores a consent POST with %s', async (_label, headers) => {
|
|
307
322
|
// Arrange
|
|
308
323
|
await createServer()
|
|
@@ -321,3 +336,40 @@ describe('POST /cookies from another site', () => {
|
|
|
321
336
|
expect(response.headers['set-cookie']).toBeUndefined()
|
|
322
337
|
})
|
|
323
338
|
})
|
|
339
|
+
|
|
340
|
+
describe('POST /cookies with the CSRF plugin registered', () => {
|
|
341
|
+
test('records a signed-out same-origin choice without a token', async () => {
|
|
342
|
+
// Arrange
|
|
343
|
+
await createServer({ withCsrf: true })
|
|
344
|
+
|
|
345
|
+
// Act
|
|
346
|
+
const response = await server.inject({
|
|
347
|
+
method: 'POST',
|
|
348
|
+
url: '/cookies',
|
|
349
|
+
headers: { 'sec-fetch-site': 'same-origin' },
|
|
350
|
+
payload: { analytics: 'yes' }
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
// Assert
|
|
354
|
+
expect(response.statusCode).toBe(302)
|
|
355
|
+
expect(response.headers.location).toBe('/cookies?saved=true&returnPath=%2F')
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
test('still returns a cross-site post to the page rather than a 403', async () => {
|
|
359
|
+
// Arrange
|
|
360
|
+
await createServer({ withCsrf: true })
|
|
361
|
+
|
|
362
|
+
// Act
|
|
363
|
+
const response = await server.inject({
|
|
364
|
+
method: 'POST',
|
|
365
|
+
url: '/cookies',
|
|
366
|
+
headers: { 'sec-fetch-site': 'cross-site' },
|
|
367
|
+
payload: { analytics: 'yes' }
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
// Assert
|
|
371
|
+
expect(response.statusCode).toBe(302)
|
|
372
|
+
expect(response.headers.location).toBe('/cookies')
|
|
373
|
+
expect(response.headers['set-cookie']).toBeUndefined()
|
|
374
|
+
})
|
|
375
|
+
})
|