@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,172 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
|
|
5
|
+
import hapi from '@hapi/hapi'
|
|
6
|
+
import { afterEach, describe, expect, test } from 'vitest'
|
|
7
|
+
|
|
8
|
+
import { CSRF_TOKEN_HEADER, csrfPlugin } from '../../../csrf/index.js'
|
|
9
|
+
import { createNunjucksConfig } from '../../plugin.js'
|
|
10
|
+
|
|
11
|
+
const projectRoot = path.resolve(
|
|
12
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
13
|
+
'../../../../test/fixtures/form-app'
|
|
14
|
+
)
|
|
15
|
+
const configValues = {
|
|
16
|
+
root: projectRoot,
|
|
17
|
+
assetPath: '/public',
|
|
18
|
+
serviceName: 'Example service',
|
|
19
|
+
isProduction: false,
|
|
20
|
+
'nunjucks.watch': false,
|
|
21
|
+
'nunjucks.noCache': true
|
|
22
|
+
}
|
|
23
|
+
const config = { get: (key) => configValues[key] }
|
|
24
|
+
|
|
25
|
+
let server
|
|
26
|
+
|
|
27
|
+
async function render({ view = 'form', form, token }) {
|
|
28
|
+
server = hapi.server()
|
|
29
|
+
await server.register(createNunjucksConfig({ config, logger: {} }))
|
|
30
|
+
await server.register({
|
|
31
|
+
plugin: csrfPlugin,
|
|
32
|
+
options: { tokenSource: 'header' }
|
|
33
|
+
})
|
|
34
|
+
server.route({
|
|
35
|
+
method: 'GET',
|
|
36
|
+
path: '/form',
|
|
37
|
+
handler: (_request, h) => h.view(view, { form })
|
|
38
|
+
})
|
|
39
|
+
await server.initialize()
|
|
40
|
+
|
|
41
|
+
const response = await server.inject({
|
|
42
|
+
method: 'GET',
|
|
43
|
+
url: '/form',
|
|
44
|
+
headers: token === undefined ? {} : { [CSRF_TOKEN_HEADER]: token }
|
|
45
|
+
})
|
|
46
|
+
document.body.innerHTML = response.result
|
|
47
|
+
|
|
48
|
+
return document.querySelector('form')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('lisForm()', () => {
|
|
52
|
+
afterEach(async () => {
|
|
53
|
+
document.body.innerHTML = ''
|
|
54
|
+
await server?.stop()
|
|
55
|
+
server = undefined
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('renders a novalidate POST form with the request token and the caller body', async () => {
|
|
59
|
+
// Act
|
|
60
|
+
const form = await render({
|
|
61
|
+
form: { action: '/holdings/12/345/6789/edit' },
|
|
62
|
+
token: 'request-token'
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// Assert
|
|
66
|
+
expect(form.getAttribute('action')).toBe('/holdings/12/345/6789/edit')
|
|
67
|
+
expect(form.getAttribute('method')).toBe('post')
|
|
68
|
+
expect(form.hasAttribute('novalidate')).toBe(true)
|
|
69
|
+
expect(form.querySelector('input[type="hidden"]').name).toBe('_csrf')
|
|
70
|
+
expect(form.querySelector('input[name="_csrf"]').value).toBe(
|
|
71
|
+
'request-token'
|
|
72
|
+
)
|
|
73
|
+
expect(form.querySelector('button').textContent).toBe('Save')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('escapes the token, action, classes and attributes', async () => {
|
|
77
|
+
// Arrange
|
|
78
|
+
const hostile = '"><script>alert(1)</script>'
|
|
79
|
+
|
|
80
|
+
// Act
|
|
81
|
+
const form = await render({
|
|
82
|
+
form: {
|
|
83
|
+
action: `/edit${hostile}`,
|
|
84
|
+
classes: `lis-form${hostile}`,
|
|
85
|
+
attributes: { 'data-module': hostile }
|
|
86
|
+
},
|
|
87
|
+
token: `token${hostile}`
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// Assert
|
|
91
|
+
expect(document.querySelectorAll('script')).toHaveLength(0)
|
|
92
|
+
expect(form.getAttribute('action')).toBe(`/edit${hostile}`)
|
|
93
|
+
expect(form.className).toBe(`lis-form${hostile}`)
|
|
94
|
+
expect(form.dataset.module).toBe(hostile)
|
|
95
|
+
expect(form.querySelector('input[name="_csrf"]').value).toBe(
|
|
96
|
+
`token${hostile}`
|
|
97
|
+
)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('renders the classes and attributes', async () => {
|
|
101
|
+
// Act
|
|
102
|
+
const form = await render({
|
|
103
|
+
form: {
|
|
104
|
+
action: '/edit',
|
|
105
|
+
classes: 'lis-form govuk-!-margin-bottom-6',
|
|
106
|
+
attributes: { id: 'edit-holding', 'data-module': 'lis-edit' }
|
|
107
|
+
},
|
|
108
|
+
token: 'request-token'
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// Assert
|
|
112
|
+
expect(form.className).toBe('lis-form govuk-!-margin-bottom-6')
|
|
113
|
+
expect(form.id).toBe('edit-holding')
|
|
114
|
+
expect(form.dataset.module).toBe('lis-edit')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('leaves the action out when none is given, posting to the page itself', async () => {
|
|
118
|
+
// Act
|
|
119
|
+
const form = await render({ form: {}, token: 'request-token' })
|
|
120
|
+
|
|
121
|
+
// Assert
|
|
122
|
+
expect(form.hasAttribute('action')).toBe(false)
|
|
123
|
+
expect(form.getAttribute('method')).toBe('post')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('never puts the token in a GET form', async () => {
|
|
127
|
+
// Act
|
|
128
|
+
const form = await render({
|
|
129
|
+
form: { action: '/search', method: 'GET' },
|
|
130
|
+
token: 'request-token'
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// Assert
|
|
134
|
+
expect(form.getAttribute('method')).toBe('GET')
|
|
135
|
+
expect(form.querySelector('input[name="_csrf"]')).toBeNull()
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('leaves the token field out when the request has no token, so the post is refused', async () => {
|
|
139
|
+
// Act
|
|
140
|
+
const form = await render({ form: { action: '/edit' } })
|
|
141
|
+
|
|
142
|
+
// Assert
|
|
143
|
+
expect(form.getAttribute('method')).toBe('post')
|
|
144
|
+
expect(form.querySelector('input[name="_csrf"]')).toBeNull()
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('takes an explicit csrfToken when imported without context', async () => {
|
|
148
|
+
// Act
|
|
149
|
+
const form = await render({
|
|
150
|
+
view: 'form-without-context',
|
|
151
|
+
form: { action: '/edit', csrfToken: 'explicit-token' },
|
|
152
|
+
token: 'request-token'
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
// Assert
|
|
156
|
+
expect(form.querySelector('input[name="_csrf"]').value).toBe(
|
|
157
|
+
'explicit-token'
|
|
158
|
+
)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('cannot see the request token when imported without context, so renders no token field', async () => {
|
|
162
|
+
// Act
|
|
163
|
+
const form = await render({
|
|
164
|
+
view: 'form-without-context',
|
|
165
|
+
form: { action: '/edit' },
|
|
166
|
+
token: 'request-token'
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
// Assert
|
|
170
|
+
expect(form.querySelector('input[name="_csrf"]')).toBeNull()
|
|
171
|
+
})
|
|
172
|
+
})
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{#
|
|
2
|
+
Import with context so the token comes from the page:
|
|
3
|
+
{% from "form/macro.njk" import lisForm with context %}
|
|
4
|
+
A GET form never carries the token, which would otherwise end up in the URL.
|
|
5
|
+
With no token the field is left out, so the post is refused: the macro was
|
|
6
|
+
imported without context, or the CSRF plugin is not registered.
|
|
7
|
+
#}
|
|
8
|
+
{% from "govuk/macros/attributes.njk" import govukAttributes %}
|
|
9
|
+
|
|
10
|
+
{%- set method = params.method or "post" -%}
|
|
11
|
+
{%- set token = params.csrfToken if params.csrfToken is defined else csrfToken -%}
|
|
12
|
+
|
|
13
|
+
<form{% if params.action %} action="{{ params.action }}"{% endif %} method="{{ method }}"{% if params.classes %} class="{{ params.classes }}"{% endif %}{{ govukAttributes(params.attributes) }} novalidate>
|
|
14
|
+
{%- if method | lower != "get" and token %}
|
|
15
|
+
<input type="hidden" name="_csrf" value="{{ token }}">
|
|
16
|
+
{%- endif %}
|
|
17
|
+
{{ caller() if caller }}
|
|
18
|
+
</form>
|
package/src/nunjucks/context.js
CHANGED
|
@@ -6,6 +6,7 @@ import { HUBS, getModuleById } from '@defra/lis-hubs-infra-registry'
|
|
|
6
6
|
import { getAssetRoot } from '../asset-path.js'
|
|
7
7
|
|
|
8
8
|
import { COOKIES_PATH, getCookiesPolicy } from '../cookies/cookies-policy.js'
|
|
9
|
+
import { CSRF_PLUGIN_NAME } from '../csrf/constants.js'
|
|
9
10
|
|
|
10
11
|
const HUB_SERVICE_NAME_PREFIX = 'lis-hubs-'
|
|
11
12
|
|
|
@@ -116,6 +117,7 @@ export function createNunjucksContextBuilder({
|
|
|
116
117
|
cookiesPath: COOKIES_PATH,
|
|
117
118
|
cookiesPolicy: getCookiesPolicy(request),
|
|
118
119
|
isCookiesPage: request?.path === COOKIES_PATH,
|
|
120
|
+
csrfToken: request?.plugins?.[CSRF_PLUGIN_NAME]?.token,
|
|
119
121
|
errorPages: errorPageLinks,
|
|
120
122
|
getAssetPath(asset) {
|
|
121
123
|
const viteAssetPath = viteManifest?.[asset]?.file
|
|
@@ -607,3 +607,41 @@ describe('createNunjucksContextBuilder() hasPermission()', () => {
|
|
|
607
607
|
expect(error.message).toBe('hasPermission() requires a permission')
|
|
608
608
|
})
|
|
609
609
|
})
|
|
610
|
+
|
|
611
|
+
describe('createNunjucksContextBuilder() csrfToken', () => {
|
|
612
|
+
test('exposes the token the CSRF plugin resolved for the request', () => {
|
|
613
|
+
// Arrange
|
|
614
|
+
const contextBuilder = createNunjucksContextBuilder({
|
|
615
|
+
config: createConfig(),
|
|
616
|
+
logger: {},
|
|
617
|
+
readFileSync: () => '{}'
|
|
618
|
+
})
|
|
619
|
+
const request = {
|
|
620
|
+
headers: {},
|
|
621
|
+
path: '/',
|
|
622
|
+
plugins: { 'lis-csrf': { token: 'resolved-token' } }
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Act
|
|
626
|
+
const context = contextBuilder(request)
|
|
627
|
+
|
|
628
|
+
// Assert
|
|
629
|
+
expect(context.csrfToken).toBe('resolved-token')
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
test('leaves the token undefined when the CSRF plugin is not registered', () => {
|
|
633
|
+
// Arrange
|
|
634
|
+
const contextBuilder = createNunjucksContextBuilder({
|
|
635
|
+
config: createConfig(),
|
|
636
|
+
logger: {},
|
|
637
|
+
readFileSync: () => '{}'
|
|
638
|
+
})
|
|
639
|
+
const request = { headers: {}, path: '/', plugins: {} }
|
|
640
|
+
|
|
641
|
+
// Act
|
|
642
|
+
const context = contextBuilder(request)
|
|
643
|
+
|
|
644
|
+
// Assert
|
|
645
|
+
expect(context.csrfToken).toBeUndefined()
|
|
646
|
+
})
|
|
647
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{#
|
|
2
|
+
The Sign out link lands here: a GET never signs out, so the page asks for
|
|
3
|
+
the POST, carrying the CSRF token. Takes signOutHref, the path to post to.
|
|
4
|
+
#}
|
|
5
|
+
{% extends 'layouts/page.njk' %}
|
|
6
|
+
{% from "form/macro.njk" import lisForm with context %}
|
|
7
|
+
|
|
8
|
+
{% block content %}
|
|
9
|
+
<div class="govuk-grid-row">
|
|
10
|
+
<div class="govuk-grid-column-two-thirds">
|
|
11
|
+
<h1 class="govuk-heading-l">Are you sure you want to sign out?</h1>
|
|
12
|
+
{% call lisForm({ action: signOutHref }) %}
|
|
13
|
+
<div class="govuk-button-group">
|
|
14
|
+
<button type="submit" class="govuk-button" data-module="govuk-button">Sign out</button>
|
|
15
|
+
<a class="govuk-link" href="/">Cancel</a>
|
|
16
|
+
</div>
|
|
17
|
+
{% endcall %}
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
{% endblock %}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
|
|
5
|
+
import nunjucks from 'nunjucks'
|
|
6
|
+
import { describe, expect, test } from 'vitest'
|
|
7
|
+
|
|
8
|
+
const routesRoot = path.resolve(
|
|
9
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
10
|
+
'..'
|
|
11
|
+
)
|
|
12
|
+
const layoutFixtureRoot = path.resolve(
|
|
13
|
+
routesRoot,
|
|
14
|
+
'../../../test/fixtures/nunjucks'
|
|
15
|
+
)
|
|
16
|
+
const componentsRoot = path.resolve(routesRoot, '../components')
|
|
17
|
+
// The form component imports govuk-frontend's attributes macro.
|
|
18
|
+
const govukRoot = path.join(
|
|
19
|
+
path.dirname(
|
|
20
|
+
createRequire(import.meta.url).resolve('govuk-frontend/package.json')
|
|
21
|
+
),
|
|
22
|
+
'dist'
|
|
23
|
+
)
|
|
24
|
+
const environment = new nunjucks.Environment(
|
|
25
|
+
new nunjucks.FileSystemLoader([
|
|
26
|
+
routesRoot,
|
|
27
|
+
layoutFixtureRoot,
|
|
28
|
+
componentsRoot,
|
|
29
|
+
govukRoot
|
|
30
|
+
]),
|
|
31
|
+
{ autoescape: true, trimBlocks: true, lstripBlocks: true }
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
describe('sign out page', () => {
|
|
35
|
+
test('asks the user to confirm with a form posting the CSRF token, or cancel', () => {
|
|
36
|
+
// Arrange
|
|
37
|
+
const context = {
|
|
38
|
+
pageTitle: 'Are you sure you want to sign out?',
|
|
39
|
+
signOutHref: '/signout',
|
|
40
|
+
csrfToken: 'csrf-token'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Act
|
|
44
|
+
const html = environment.render('auth/sign-out.njk', context)
|
|
45
|
+
|
|
46
|
+
// Assert
|
|
47
|
+
expect(html).toContain(
|
|
48
|
+
'<title>Are you sure you want to sign out? - Livestock Information</title>'
|
|
49
|
+
)
|
|
50
|
+
expect(html).toContain(
|
|
51
|
+
'<h1 class="govuk-heading-l">Are you sure you want to sign out?</h1>'
|
|
52
|
+
)
|
|
53
|
+
expect(html).toContain('<form action="/signout" method="post" novalidate>')
|
|
54
|
+
expect(html).toContain(
|
|
55
|
+
'<input type="hidden" name="_csrf" value="csrf-token">'
|
|
56
|
+
)
|
|
57
|
+
expect(html).toContain(
|
|
58
|
+
'<button type="submit" class="govuk-button" data-module="govuk-button">Sign out</button>'
|
|
59
|
+
)
|
|
60
|
+
expect(html).toContain('<a class="govuk-link" href="/">Cancel</a>')
|
|
61
|
+
})
|
|
62
|
+
})
|