@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 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 cross-site posts, 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.
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.50.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"
@@ -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
- * @param {Request} request
71
- * @returns {string} the host the browser addressed, as the hub proxy forwards it
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
- const fetchSite = request.headers['sec-fetch-site']
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) || !isSameSiteRequest(request)) {
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
- options: { auth: { mode: 'try' } },
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: { host: 'lis.test', referer: 'https://lis.test/cattle/home' },
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' }, true],
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', {}, true],
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
- ['a foreign origin', { origin: 'https://evil.example' }]
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
+ })