@defra/lis-infra-ui-services 0.29.0 → 0.30.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defra/lis-infra-ui-services",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "UI-Services shared definitions for the livestock taxonomy topology.",
5
5
  "main": "src/index.js",
6
6
  "repository": {
@@ -34,6 +34,7 @@
34
34
  "./proxy/setup-proxy": "./src/proxy/setup-proxy.js",
35
35
  "./redis-client": "./src/redis-client.js",
36
36
  "./referrer-link": "./src/referrer-link.js",
37
+ "./security-headers": "./src/security-headers.js",
37
38
  "./session-cache": "./src/session-cache.js",
38
39
  "./session-cache/cache-engine": "./src/session-cache/cache-engine.js",
39
40
  "./static-files": "./src/static-files.js",
@@ -0,0 +1,40 @@
1
+ /** @import { Plugin } from '@hapi/hapi' */
2
+
3
+ const HSTS_MAX_AGE_SECONDS = 31536000 // one year
4
+
5
+ // Response headers that complement the CSP. HSTS pins HTTPS, nosniff stops MIME
6
+ // sniffing, and the referrer/permissions policies limit what the browser leaks
7
+ // and which powerful features a page may use. Referrer-Policy is "same-origin"
8
+ // so same-origin referers still reach resolveHubOrigin's referer fallback while
9
+ // cross-origin referer leakage is cut.
10
+ const SECURITY_HEADERS = {
11
+ 'strict-transport-security': `max-age=${HSTS_MAX_AGE_SECONDS}; includeSubDomains`,
12
+ 'x-content-type-options': 'nosniff',
13
+ 'referrer-policy': 'same-origin',
14
+ 'permissions-policy': 'camera=(), microphone=(), geolocation=()'
15
+ }
16
+
17
+ /**
18
+ * Hapi plugin that sets a fixed set of hardening headers on every response,
19
+ * including Boom error responses.
20
+ * @type {{ plugin: Plugin<void> }}
21
+ */
22
+ export const securityHeaders = {
23
+ plugin: {
24
+ name: 'lis-security-headers',
25
+ register(server) {
26
+ server.ext('onPreResponse', (request, h) => {
27
+ const { response } = request
28
+ const headers = response.isBoom
29
+ ? response.output.headers
30
+ : response.headers
31
+
32
+ for (const [name, value] of Object.entries(SECURITY_HEADERS)) {
33
+ headers[name] = value
34
+ }
35
+
36
+ return h.continue
37
+ })
38
+ }
39
+ }
40
+ }
@@ -0,0 +1,51 @@
1
+ import assert from 'node:assert/strict'
2
+ import hapi from '@hapi/hapi'
3
+ import { test } from 'vitest'
4
+
5
+ import { securityHeaders } from './security-headers.js'
6
+
7
+ async function buildServer() {
8
+ const server = hapi.server()
9
+ await server.register(securityHeaders)
10
+ server.route({ method: 'GET', path: '/ok', handler: () => 'ok' })
11
+ server.route({
12
+ method: 'GET',
13
+ path: '/boom',
14
+ handler: () => {
15
+ throw new Error('nope')
16
+ }
17
+ })
18
+ return server
19
+ }
20
+
21
+ test('sets the hardening headers on a normal response', async () => {
22
+ const server = await buildServer()
23
+
24
+ const { statusCode, headers } = await server.inject('/ok')
25
+
26
+ assert.equal(statusCode, 200)
27
+ assert.equal(
28
+ headers['strict-transport-security'],
29
+ 'max-age=31536000; includeSubDomains'
30
+ )
31
+ assert.equal(headers['x-content-type-options'], 'nosniff')
32
+ assert.equal(headers['referrer-policy'], 'same-origin')
33
+ assert.equal(
34
+ headers['permissions-policy'],
35
+ 'camera=(), microphone=(), geolocation=()'
36
+ )
37
+ })
38
+
39
+ test('sets the hardening headers on a Boom error response', async () => {
40
+ const server = await buildServer()
41
+
42
+ const { statusCode, headers } = await server.inject('/boom')
43
+
44
+ assert.equal(statusCode, 500)
45
+ assert.equal(headers['x-content-type-options'], 'nosniff')
46
+ assert.equal(headers['referrer-policy'], 'same-origin')
47
+ assert.equal(
48
+ headers['strict-transport-security'],
49
+ 'max-age=31536000; includeSubDomains'
50
+ )
51
+ })
@@ -1,10 +1,10 @@
1
1
  import yar from '@hapi/yar'
2
2
 
3
3
  /**
4
- * @param {{ sessionConfig: object, isSecure: boolean }} options
4
+ * @param {{ sessionConfig: object }} options
5
5
  * @returns {object}
6
6
  */
7
- export function createSessionCachePlugin({ sessionConfig, isSecure }) {
7
+ export function createSessionCachePlugin({ sessionConfig }) {
8
8
  return {
9
9
  plugin: yar,
10
10
  options: {
@@ -18,7 +18,9 @@ export function createSessionCachePlugin({ sessionConfig, isSecure }) {
18
18
  cookieOptions: {
19
19
  password: sessionConfig.cookie.password,
20
20
  ttl: sessionConfig.cookie.ttl,
21
- isSecure,
21
+ // Secure is a fixed invariant: the browser always reaches the app over
22
+ // HTTPS (Caddy locally, the CDP edge deployed).
23
+ isSecure: true,
22
24
  isSameSite: 'Lax',
23
25
  clearInvalid: true
24
26
  }
@@ -32,7 +34,6 @@ export function createSessionCachePlugin({ sessionConfig, isSecure }) {
32
34
  */
33
35
  export function createSessionCachePluginForConfig(config) {
34
36
  return createSessionCachePlugin({
35
- sessionConfig: config.get('session'),
36
- isSecure: config.get('session.cookie.secure')
37
+ sessionConfig: config.get('session')
37
38
  })
38
39
  }
@@ -19,7 +19,7 @@ test('createSessionCachePlugin builds the expected yar plugin options', () => {
19
19
  }
20
20
  }
21
21
 
22
- const plugin = createSessionCachePlugin({ sessionConfig, isSecure: true })
22
+ const plugin = createSessionCachePlugin({ sessionConfig })
23
23
 
24
24
  assert.equal(plugin.plugin, yar)
25
25
  assert.deepEqual(plugin.options, {
@@ -58,20 +58,16 @@ test('createSessionCachePluginForConfig maps config values into the plugin', ()
58
58
  }
59
59
  }
60
60
  }
61
-
62
- if (key === 'session.cookie.secure') {
63
- return false
64
- }
65
61
  }
66
62
  }
67
63
 
68
64
  const plugin = createSessionCachePluginForConfig(config)
69
65
 
70
- assert.deepEqual(calls, ['session', 'session.cookie.secure'])
66
+ assert.deepEqual(calls, ['session'])
71
67
  assert.equal(plugin.options.name, 'mapped-session')
72
68
  assert.equal(plugin.options.cache.expiresIn, 1000)
73
69
  assert.equal(plugin.options.cookieOptions.password, 'secret')
74
70
  assert.equal(plugin.options.cookieOptions.ttl, 2000)
75
- assert.equal(plugin.options.cookieOptions.isSecure, false)
71
+ assert.equal(plugin.options.cookieOptions.isSecure, true)
76
72
  assert.equal(plugin.options.cookieOptions.isSameSite, 'Lax')
77
73
  })