Skip to content

Commit f4f7ef8

Browse files
committed
fix: sha hash for CSP policy not being generated dynamically
1 parent d1862cb commit f4f7ef8

File tree

6 files changed

+78
-120
lines changed

6 files changed

+78
-120
lines changed

src/main/assets/js/postcode-lookup.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,12 @@ export function initPostcodeLookup(): void {
8989
{ field: country, value: selected.dataset.country },
9090
];
9191

92-
fieldMappings.forEach(({ field, value }) => {
92+
for (let i = 0; i < fieldMappings.length; i++) {
93+
const { field, value } = fieldMappings[i];
9394
if (field) {
9495
field.value = value || '';
9596
}
96-
});
97+
}
9798

9899
addressLine1?.focus();
99100
};
@@ -194,7 +195,8 @@ export function initPostcodeLookup(): void {
194195
return;
195196
}
196197

197-
containers.forEach(container => {
198+
for (let i = 0; i < containers.length; i++) {
199+
const container = containers[i];
198200
const { postcodeInput, findBtn, select, selectContainer } = getParts(container);
199201
if (!postcodeInput || !findBtn || !select) {
200202
return;
@@ -210,5 +212,5 @@ export function initPostcodeLookup(): void {
210212
}
211213
await performPostcodeLookup(value, select, selectContainer, findBtn);
212214
});
213-
});
215+
}
214216
}

src/main/assets/js/postcode-select.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ export function initPostcodeSelection(): void {
55
// Be robust to environments that cannot safely stub window.location
66
let href = '';
77
// Prefer explicit test hook when present
8-
if ((window as { __testHref?: string }).__testHref) {
9-
href = (window as { __testHref?: string }).__testHref as string;
8+
if ((globalThis as { __testHref?: string }).__testHref) {
9+
href = (globalThis as { __testHref?: string }).__testHref as string;
1010
} else {
1111
try {
12-
href = (window as { location?: { href?: string } })?.location?.href || '';
12+
href = (globalThis as { location?: { href?: string } })?.location?.href || '';
1313
} catch {
1414
href = '';
1515
}
Lines changed: 33 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,44 @@
1+
import { createHash } from 'crypto';
12
import { readFileSync } from 'fs';
2-
import * as path from 'path';
33

4-
const DEFAULT_MANIFEST_PATH = path.resolve(__dirname, '..', '..', 'public', 'csp-script-hashes.json');
5-
const GOVUK_TEMPLATE_SCRIPT_HASH = "'sha256-GUQ5ad8JK5KmEWmROf3LZd9ge94daqNvd8xy9YS1iDw='";
4+
const GOVUK_TEMPLATE_PATH = require.resolve('govuk-frontend/dist/govuk/template.njk');
5+
const SCRIPT_TAG_REGEX = /<script\b[^>]*>([\s\S]*?)<\/script>/gi;
66

7-
function readManifest(manifestPath: string): string[] {
8-
try {
9-
const content = readFileSync(manifestPath, 'utf8');
10-
const parsed = JSON.parse(content);
11-
if (!parsed || !Array.isArray(parsed.scriptHashes)) {
12-
return [];
13-
}
7+
function computeHash(input: string): string {
8+
return `'sha256-${createHash('sha256').update(input, 'utf8').digest('base64')}'`;
9+
}
10+
11+
function extractInlineScriptContent(template: string): string[] {
12+
const scripts: string[] = [];
13+
let match: RegExpExecArray | null;
1414

15-
return parsed.scriptHashes.filter(
16-
(hash: unknown): hash is string => typeof hash === 'string' && hash.startsWith("'sha256-")
17-
);
18-
} catch (error: unknown) {
19-
// eslint-disable-next-line no-console
20-
console.error('Error reading manifest file:', error);
21-
return [];
15+
while ((match = SCRIPT_TAG_REGEX.exec(template)) !== null) {
16+
const content = match[1];
17+
if (content && content.trim().length > 0) {
18+
scripts.push(content);
19+
}
2220
}
21+
22+
return scripts;
2323
}
2424

25-
function buildInlineScriptHashes(manifestPath: string): string[] {
26-
const hashes = new Set<string>([GOVUK_TEMPLATE_SCRIPT_HASH]);
27-
for (const hash of readManifest(manifestPath)) {
28-
hashes.add(hash);
25+
export function getInlineScriptHashes(): string[] {
26+
const templateContent = readFileSync(GOVUK_TEMPLATE_PATH, 'utf8');
27+
const inlineScripts = extractInlineScriptContent(templateContent);
28+
const hashes = new Set<string>();
29+
30+
inlineScripts.forEach(script => hashes.add(computeHash(script)));
31+
32+
if (hashes.size === 0) {
33+
throw new Error(`No inline scripts found in GOV.UK template at ${GOVUK_TEMPLATE_PATH}`);
2934
}
35+
3036
return Array.from(hashes);
3137
}
3238

33-
export function getInlineScriptHashes(): string[] {
34-
return buildInlineScriptHashes(DEFAULT_MANIFEST_PATH);
35-
}
39+
// Exported for testing
40+
export const __testUtils = {
41+
computeHash,
42+
extractInlineScriptContent,
43+
GOVUK_TEMPLATE_PATH,
44+
};
Lines changed: 36 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,46 @@
1-
import { existsSync, unlinkSync, writeFileSync } from 'fs';
2-
import * as path from 'path';
1+
import { readFileSync } from 'fs';
32

4-
const modulePath = '../../../../main/modules/helmet/scriptHashes';
5-
const manifestPath = path.resolve(__dirname, '../../../../main/public/csp-script-hashes.json');
3+
import { __testUtils, getInlineScriptHashes } from '../../../../main/modules/helmet/scriptHashes';
64

75
describe('getInlineScriptHashes', () => {
8-
beforeEach(() => {
9-
jest.resetModules();
10-
if (existsSync(manifestPath)) {
11-
unlinkSync(manifestPath);
12-
}
6+
it('returns hashes for all inline scripts in the GOV.UK template', () => {
7+
const template = readFileSync(__testUtils.GOVUK_TEMPLATE_PATH, 'utf8');
8+
const inlineScripts = __testUtils.extractInlineScriptContent(template);
9+
const hashes = getInlineScriptHashes();
10+
11+
expect(inlineScripts.length).toBeGreaterThan(0);
12+
inlineScripts.forEach(script => {
13+
const expected = __testUtils.computeHash(script);
14+
expect(hashes).toContain(expected);
15+
});
1316
});
1417

15-
afterAll(() => {
16-
if (existsSync(manifestPath)) {
17-
unlinkSync(manifestPath);
18-
}
19-
});
20-
21-
it('includes hashes from the webpack manifest and the GOV.UK template hash', () => {
22-
writeFileSync(manifestPath, JSON.stringify({ scriptHashes: ["'sha256-inline-from-manifest'"] }), 'utf8');
23-
24-
const { getInlineScriptHashes } = require(modulePath);
18+
it('produces values suitable for CSP script-src', () => {
19+
const hashes = getInlineScriptHashes();
2520

26-
expect(getInlineScriptHashes()).toEqual([
27-
"'sha256-GUQ5ad8JK5KmEWmROf3LZd9ge94daqNvd8xy9YS1iDw='",
28-
"'sha256-inline-from-manifest'",
29-
]);
21+
hashes.forEach(hash => {
22+
expect(hash.startsWith("'sha256-")).toBe(true);
23+
expect(hash.endsWith("'")).toBe(true);
24+
expect(hash.length).toBeGreaterThan("'sha256-".length + 1);
25+
});
3026
});
27+
});
3128

32-
it('returns only the GOV.UK template hash when the manifest is missing', () => {
33-
const { getInlineScriptHashes } = require(modulePath);
34-
35-
expect(getInlineScriptHashes()).toEqual(["'sha256-GUQ5ad8JK5KmEWmROf3LZd9ge94daqNvd8xy9YS1iDw='"]);
36-
});
37-
38-
it('ignores malformed manifest content', () => {
39-
writeFileSync(manifestPath, JSON.stringify({ scriptHashes: 'not-an-array' }), 'utf8');
40-
41-
const { getInlineScriptHashes } = require(modulePath);
42-
43-
expect(getInlineScriptHashes()).toEqual(["'sha256-GUQ5ad8JK5KmEWmROf3LZd9ge94daqNvd8xy9YS1iDw='"]);
29+
describe('extractInlineScriptContent', () => {
30+
it('captures inline script bodies from a template string', () => {
31+
const scripts = __testUtils.extractInlineScriptContent(`
32+
<html>
33+
<head></head>
34+
<body>
35+
<script>console.log('alpha');</script>
36+
<script type="text/javascript">
37+
window.test = true;
38+
</script>
39+
<script src="/bundle.js"></script>
40+
</body>
41+
</html>
42+
`);
43+
44+
expect(scripts).toEqual(["console.log('alpha');", '\n window.test = true;\n ']);
4445
});
4546
});

webpack.config.js

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ const sourcePath = path.resolve(__dirname, 'src/main/assets/js');
55
const govukFrontend = require(path.resolve(__dirname, 'webpack/govukFrontend'));
66
const scss = require(path.resolve(__dirname, 'webpack/scss'));
77
const HtmlWebpack = require(path.resolve(__dirname, 'webpack/htmlWebpack'));
8-
const CspHashPlugin = require(path.resolve(__dirname, 'webpack/cspHashPlugin'));
98

109
const locales = path.resolve(__dirname, 'src/main/assets/locales');
1110

@@ -18,7 +17,6 @@ module.exports = {
1817
...govukFrontend.plugins,
1918
...scss.plugins,
2019
...HtmlWebpack.plugins,
21-
new CspHashPlugin(),
2220
new CopyWebpackPlugin({
2321
patterns: [{ from: locales, to: 'locales' }],
2422
}),

webpack/cspHashPlugin.js

Lines changed: 0 additions & 52 deletions
This file was deleted.

0 commit comments

Comments
 (0)