Skip to content

Commit 300736f

Browse files
authored
Merge pull request #1493 from maxmind/dallas/automate-headers
Create automation for easier to read CSP diffs
2 parents 73afcf6 + a320629 commit 300736f

File tree

14 files changed

+311
-28
lines changed

14 files changed

+311
-28
lines changed

.github/workflows/hugo.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ jobs:
2828
- name: Install Dart Sass
2929
run: sudo snap install dart-sass
3030

31+
- name: Setup Node.js
32+
uses: actions/setup-node@v6
33+
with:
34+
node-version: '22.6'
35+
36+
- name: Install Node dependencies
37+
run: npm ci
38+
3139
- name: Build site
3240
run: ./build.sh
3341
env:

.github/workflows/lint.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ jobs:
4141

4242
- uses: actions/setup-node@v6
4343
with:
44-
node-version: '22'
44+
node-version: '22.6'
4545

4646
- name: Get cached dependencies
4747
uses: actions/cache@v4

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,6 @@ firebase.json
7474
# Misc
7575
.tmp
7676
.lycheecache
77+
78+
# Cloudflare Pages headers
79+
static/_headers

.nvmrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
v22
1+
v22.6.0
File renamed without changes.

README.md

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@
2727

2828
### Minimum Requirements
2929

30-
The minimum Node and NPM versions can be found in the
31-
[package.json file](package.json) under `engines`.
30+
The minimum versions can be found in the [package.json file](package.json)
31+
under `engines`.
3232

3333
If you need help installing and/or managing Node and NPM versions, check out
3434
[NVM](https://github.com/nvm-sh/nvm).
@@ -85,6 +85,28 @@ when files change.
8585
hugo server
8686
```
8787

88+
#### Cloudflare Pages HTTP Headers Configuration
89+
90+
The `static/_headers` file is automatically generated from
91+
`bin/_headers.config.ts` during the build process. **Do not edit `static/_headers`
92+
directly**.
93+
94+
##### Making Changes to Headers
95+
96+
1. Edit `bin/_headers.config.ts` (the source of truth with readable format
97+
and TypeScript type safety)
98+
2. Test your changes locally by generating the headers file:
99+
```sh
100+
npm run build:headers
101+
```
102+
3. Commit only `bin/_headers.config.ts` - the `_headers` file will be
103+
generated automatically during deployment
104+
105+
##### Build-Time Generation
106+
107+
The headers file is generated automatically during deployment via `build.sh`.
108+
You can also generate it manually for local testing with `npm run build:headers`.
109+
88110
### Updating Release Notes for the New Year
89111

90112
Whenever you create your first release note for a product category for a new

bin/_headers.config.ts

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/**
2+
* HTTP Headers Configuration for Cloudflare Pages
3+
* This file is the source of truth for static/_headers generation
4+
* Run: npm run build:headers
5+
*/
6+
7+
interface HeadersConfig {
8+
paths: Array<{
9+
pattern: string;
10+
headers: Record<string, string[] | Record<string, string[]>>;
11+
}>;
12+
}
13+
14+
const config: HeadersConfig = {
15+
paths: [
16+
{
17+
pattern: '/*',
18+
headers: {
19+
'Content-Security-Policy': {
20+
'connect-src': [
21+
"'self'",
22+
'https://status.maxmind.com',
23+
'https://www.maxmind.com',
24+
25+
// eslint-disable-next-line max-len
26+
// https://knowledge.hubspot.com/domains-and-urls/ssl-and-domain-security-in-hubspot#content-security-policy
27+
28+
// HubSpot API
29+
'https://api.hubspot.com',
30+
31+
// HubSpot static assets (conversations embed)
32+
'https://static.hsappstatic.net',
33+
34+
'https://*.googleapis.com',
35+
36+
// eslint-disable-next-line max-len
37+
// https://developers.google.com/tag-platform/security/guides/csp#google_analytics_4_google_analytics
38+
'https://*.google-analytics.com',
39+
'https://*.analytics.google.com',
40+
'https://*.googletagmanager.com',
41+
42+
// https://developers.google.com/tag-platform/security/guides/csp#google_ads
43+
'https://*.g.doubleclick.net',
44+
45+
// Google domains (various TLDs for international support)
46+
'https://*.google.com',
47+
],
48+
'default-src': ["'self'"],
49+
'font-src': [
50+
"'self'",
51+
52+
// Loaded indirectly by Google Vertex search
53+
'https://fonts.gstatic.com',
54+
],
55+
'form-action': ["'self'"],
56+
'frame-ancestors': ["'self'"],
57+
'frame-src': [
58+
"'self'",
59+
60+
// eslint-disable-next-line max-len
61+
// https://knowledge.hubspot.com/domains-and-urls/ssl-and-domain-security-in-hubspot#content-security-policy
62+
63+
// HubSpot calls-to-action (pop-ups) and chatflows
64+
'https://app.hubspot.com',
65+
66+
// https://developers.google.com/tag-platform/security/guides/csp#google_ads
67+
'https://www.googletagmanager.com',
68+
69+
// Google Vertex search
70+
'https://www.google.com',
71+
],
72+
'img-src': ["'self'", 'data:', 'https:'],
73+
'object-src': ["'none'"],
74+
'script-src': [
75+
"'self'",
76+
"'report-sample'",
77+
"'unsafe-inline'",
78+
79+
// eslint-disable-next-line max-len
80+
// https://knowledge.hubspot.com/domains-and-urls/ssl-and-domain-security-in-hubspot#content-security-policy
81+
82+
// HubSpot tracking code
83+
'https://js.hs-scripts.com',
84+
85+
// HubSpot Analytics
86+
'https://js.hs-analytics.net',
87+
88+
// HubSpot cookie banner
89+
'https://js.hs-banner.com',
90+
91+
// HubSpot Conversations and Chatflows
92+
'https://js.usemessages.com',
93+
94+
// MaxMind marketing site
95+
'https://www.maxmind.com',
96+
97+
// Google Vertex search
98+
'https://cloud.google.com',
99+
'https://www.gstatic.com',
100+
101+
// https://developers.google.com/tag-platform/security/guides/csp#google_ads_conversions
102+
'https://www.googleadservices.com',
103+
'https://www.google.com',
104+
105+
// Google Tag Manager
106+
'https://*.googletagmanager.com',
107+
],
108+
'style-src': [
109+
"'self'",
110+
"'unsafe-inline'",
111+
112+
// Google Fonts API and Vertex search default styles
113+
'https://fonts.googleapis.com',
114+
115+
// Google static assets
116+
'https://www.gstatic.com',
117+
],
118+
},
119+
'Feature-Policy': [
120+
"accelerometer 'none'",
121+
"autoplay 'none'",
122+
"camera 'none'",
123+
"encrypted-media 'none'",
124+
"fullscreen 'none'",
125+
"geolocation 'none'",
126+
"gyroscope 'none'",
127+
"magnetometer 'none'",
128+
"microphone 'none'",
129+
"midi 'none'",
130+
"payment 'none'",
131+
"picture-in-picture 'none'",
132+
"usb 'none'",
133+
"sync-xhr 'none'",
134+
],
135+
'Permissions-Policy': [
136+
'accelerometer=()',
137+
'ambient-light-sensor=()',
138+
'autoplay=()',
139+
'battery=()',
140+
'camera=()',
141+
'display-capture=()',
142+
'document-domain=()',
143+
'encrypted-media=()',
144+
'execution-while-not-rendered=()',
145+
'execution-while-out-of-viewport=()',
146+
'fullscreen=()',
147+
'gamepad=()',
148+
'geolocation=()',
149+
'gyroscope=()',
150+
'hid=()',
151+
'idle-detection=()',
152+
'magnetometer=()',
153+
'microphone=()',
154+
'midi=()',
155+
'payment=()',
156+
'picture-in-picture=()',
157+
'publickey-credentials-get=()',
158+
'screen-wake-lock=()',
159+
'serial=()',
160+
'speaker-selection=()',
161+
'usb=()',
162+
'web-share=()',
163+
'xr-spatial-tracking=()',
164+
],
165+
'Referrer-Policy': ['strict-origin-when-cross-origin'],
166+
'Strict-Transport-Security': [
167+
'max-age=63072000',
168+
'includeSubDomains',
169+
'preload',
170+
],
171+
'X-Content-Type-Options': ['nosniff'],
172+
'X-Frame-Options': ['DENY'],
173+
'X-XSS-Protection': ['1', 'mode=block'],
174+
},
175+
},
176+
],
177+
};
178+
179+
export default config;

bin/check-shortcode-parity.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import * as fs from 'fs';
22
import * as path from 'path';
3+
import { fileURLToPath } from 'url';
34

4-
const SHORTCODES_DIR = path.join(__dirname, '..', 'layouts', 'shortcodes');
5+
const currentDir = path.dirname(fileURLToPath(import.meta.url));
6+
const SHORTCODES_DIR = path.join(currentDir, '..', 'layouts', 'shortcodes');
57

68
function getShortcodeFiles(dir: string, extension: string): Set<string> {
79
const files = new Set<string>();

bin/generate-headers.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* Generate static/_headers file from bin/_headers.config.ts
3+
*
4+
* This script converts a structured TypeScript configuration into the
5+
* Cloudflare Pages _headers format.
6+
*/
7+
8+
import * as fs from 'fs';
9+
import * as path from 'path';
10+
11+
import config from './_headers.config.ts';
12+
13+
interface PathConfig {
14+
pattern: string;
15+
headers: Record<string, string[] | Record<string, string[]>>;
16+
}
17+
18+
/**
19+
* Generate _headers file content from config
20+
*/
21+
function generateHeaders(config: { paths: PathConfig[] }): string {
22+
let output = '';
23+
24+
// Add warning comment at the top
25+
output += '# ⚠️ DO NOT EDIT THIS FILE DIRECTLY!\n';
26+
output +=
27+
'# This file is automatically generated from bin/_headers.config.ts\n';
28+
output +=
29+
'# To make changes, edit bin/_headers.config.ts and run: npm run build:headers\n';
30+
output += '#\n';
31+
output += '# See README.md for more information\n';
32+
output += '\n';
33+
34+
for (const pathConfig of config.paths) {
35+
// Write path pattern
36+
output += pathConfig.pattern + '\n';
37+
38+
// Process all headers
39+
for (const [header, value] of Object.entries(pathConfig.headers)) {
40+
// Handle Content-Security-Policy (nested object)
41+
if (header === 'Content-Security-Policy') {
42+
const csp = value as Record<string, string[]>;
43+
const directives: string[] = [];
44+
45+
for (const [directive, sources] of Object.entries(csp)) {
46+
directives.push(`${directive} ${sources.join(' ')}`);
47+
}
48+
49+
output += ` Content-Security-Policy: ${directives.join('; ')}\n`;
50+
continue;
51+
}
52+
53+
// Handle all other headers (arrays)
54+
const values = value as string[];
55+
const separator = header === 'Permissions-Policy' ? ', ' : '; ';
56+
output += ` ${header}: ${values.join(separator)}\n`;
57+
}
58+
}
59+
60+
return output;
61+
}
62+
63+
// Main execution
64+
try {
65+
const outputPath = path.join(process.cwd(), 'static', '_headers');
66+
67+
// Generate headers file
68+
const headersContent = generateHeaders(config);
69+
70+
// Write output file
71+
fs.writeFileSync(outputPath, headersContent);
72+
73+
console.log('✅ Generated static/_headers from bin/_headers.config.ts');
74+
} catch (error) {
75+
console.error(
76+
'Error generating headers file:',
77+
error instanceof Error ? error.message : String(error)
78+
);
79+
process.exit(1);
80+
}

build.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,7 @@
22

33
set -eu
44

5+
# Generate _headers file from TypeScript configuration
6+
npm run build:headers
7+
58
hugo --gc --minify -b "$CF_PAGES_URL"

0 commit comments

Comments
 (0)