Skip to content

Commit f09752f

Browse files
authored
Merge pull request #4 from vbarzana/feat/nonce-and-test-coverage
feat: generating nonce by default and cleaning up the tests
2 parents 8009a68 + fc15aaf commit f09752f

File tree

8 files changed

+578
-323
lines changed

8 files changed

+578
-323
lines changed

.github/workflows/tests.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ jobs:
1919
run: bun run lint
2020

2121
- name: Run tests
22-
run: bun run test
22+
run: bun test

README.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ csp-generator <url> [options]
5959
| `--allow-unsafe-eval` | boolean | false | Add 'unsafe-eval' to 'script-src' |
6060
| `--require-trusted-types` | boolean | false | Add "require-trusted-types-for 'script'" to the CSP |
6161
| `--use-strict-dynamic` | boolean | false | Add 'strict-dynamic' to script-src |
62-
| `--use-nonce` | boolean | false | Generate and use nonces for inline scripts |
62+
| `--use-nonce` | boolean | true | Generate and use a random nonce for inline scripts (recommended) |
63+
| `--custom-nonce` | string | | Use a custom nonce value instead of a random one |
6364
| `--use-hashes` | boolean | false | Generate hashes for inline content |
6465
| `--upgrade-insecure-requests` | boolean | true | Force HTTPS upgrades |
6566
| `--block-mixed-content` | boolean | true | Block mixed content |
@@ -78,6 +79,16 @@ Generate CSP with default settings:
7879
csp-generator https://example.com
7980
```
8081

82+
Use a custom nonce:
83+
```bash
84+
csp-generator https://example.com --custom-nonce my-custom-nonce
85+
```
86+
87+
Or with environment variable:
88+
```bash
89+
CSP_CUSTOM_NONCE=my-custom-nonce csp-generator https://example.com
90+
```
91+
8192
Enable unsafe inline styles and strict dynamic:
8293
```bash
8394
csp-generator https://example.com \
@@ -174,7 +185,8 @@ The browser version provides the same functionality as the CLI but uses native b
174185
### Security Options
175186

176187
- `CSP_USE_STRICT_DYNAMIC`: Add 'strict-dynamic' to script-src (default: false)
177-
- `CSP_USE_NONCE`: Generate and use nonces for inline scripts (default: false)
188+
- `CSP_USE_NONCE`: Generate and use nonces for inline scripts (default: true)
189+
- `CSP_CUSTOM_NONCE`: Use a custom nonce value instead of a random one
178190
- `CSP_USE_HASHES`: Generate hashes for inline content (default: false)
179191
- `CSP_UPGRADE_INSECURE_REQUESTS`: Force HTTPS upgrades (default: true)
180192
- `CSP_BLOCK_MIXED_CONTENT`: Block mixed content (default: true)

src/cli.ts

Lines changed: 79 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -91,12 +91,31 @@ export function getOptions(): SecureCSPGeneratorOptions {
9191

9292
const finalUrl = positionals[0] || process.env.CSP_URL || ''
9393

94+
// Validate URL format
95+
if (finalUrl) {
96+
try {
97+
new URL(finalUrl)
98+
} catch {
99+
throw new Error('Invalid URL format')
100+
}
101+
}
102+
94103
const parseBoolean = (
95-
value: string | undefined,
96-
envVar: string | undefined,
104+
value: string | boolean | undefined,
105+
envVar: string | boolean | undefined,
106+
defaultValue: boolean = false,
97107
) => {
98-
if (value !== undefined) return value === 'true'
99-
return envVar === 'true'
108+
if (typeof value === 'boolean') return value
109+
if (typeof value === 'string') {
110+
if (value.trim() === '') return defaultValue
111+
return value === 'true'
112+
}
113+
if (typeof envVar === 'boolean') return envVar
114+
if (typeof envVar === 'string') {
115+
if (envVar.trim() === '') return defaultValue
116+
return envVar === 'true'
117+
}
118+
return defaultValue
100119
}
101120

102121
const parseNumber = (
@@ -110,6 +129,13 @@ export function getOptions(): SecureCSPGeneratorOptions {
110129
return isNaN(num) ? defaultValue : num
111130
}
112131

132+
// Validate format value
133+
const validFormats = ['header', 'raw', 'json', 'csp-only']
134+
const outputFormat = format || process.env.CSP_OUTPUT_FORMAT || 'header'
135+
if (!validFormats.includes(outputFormat)) {
136+
console.warn(`Invalid format "${outputFormat}", defaulting to "header"`)
137+
}
138+
113139
return {
114140
url: finalUrl,
115141
allowHttp: parseBoolean(allowHttp, process.env.CSP_ALLOW_HTTP),
@@ -132,59 +158,66 @@ export function getOptions(): SecureCSPGeneratorOptions {
132158
requireTrustedTypes: parseBoolean(
133159
requireTrustedTypes,
134160
process.env.CSP_REQUIRE_TRUSTED_TYPES,
161+
true,
135162
),
136163
maxBodySize: parseNumber(maxBodySize, process.env.CSP_MAX_BODY_SIZE, 0),
137164
timeoutMs: parseNumber(timeoutMs, process.env.CSP_TIMEOUT_MS, 8000),
138165
presets: parsePresets(presets || process.env.CSP_PRESETS),
139166
fetchOptions: parseFetchOptions(
140167
fetchOptions || process.env.CSP_FETCH_OPTIONS,
141168
),
142-
outputFormat: (format ||
143-
process.env.CSP_OUTPUT_FORMAT ||
144-
'header') as SecureCSPGeneratorOptions['outputFormat'],
169+
outputFormat: (validFormats.includes(outputFormat)
170+
? outputFormat
171+
: 'header') as SecureCSPGeneratorOptions['outputFormat'],
145172
}
146173
}
147174

148175
export async function main() {
149-
const options = getOptions()
150-
151-
if (!options.url) {
152-
console.error('Usage: csp-generator <url> [options]')
153-
console.error('\nOptions:')
154-
console.error(
155-
' --allow-http <true|false> Allow HTTP URLs in addition to HTTPS',
156-
)
157-
console.error(
158-
' --allow-private-origins <true|false> Permit private IP / localhost origins',
159-
)
160-
console.error(
161-
' --allow-unsafe-inline-script <true|false> Add unsafe-inline to script-src',
162-
)
163-
console.error(
164-
' --allow-unsafe-inline-style <true|false> Add unsafe-inline to style-src',
165-
)
166-
console.error(
167-
' --allow-unsafe-eval <true|false> Add unsafe-eval to script-src',
168-
)
169-
console.error(
170-
' --require-trusted-types <true|false> Add require-trusted-types-for script',
171-
)
172-
console.error(
173-
' --max-body-size <bytes> Maximum allowed bytes for HTML download',
174-
)
175-
console.error(' --timeout-ms <milliseconds> Timeout for fetch requests')
176-
console.error(' --presets <presets> User-provided source lists')
177-
console.error(
178-
' --fetch-options <json> Options to forward to fetch',
179-
)
180-
console.error(
181-
' --format, -f <format> Output format (header, raw, json, csp-only)',
182-
)
183-
console.error('\nExample: csp-generator https://example.com --format json')
184-
process.exit(1)
185-
}
186-
187176
try {
177+
const options = getOptions()
178+
179+
if (!options.url) {
180+
console.error('Usage: csp-generator <url> [options]')
181+
console.error('\nOptions:')
182+
console.error(
183+
' --allow-http <true|false> Allow HTTP URLs in addition to HTTPS',
184+
)
185+
console.error(
186+
' --allow-private-origins <true|false> Permit private IP / localhost origins',
187+
)
188+
console.error(
189+
' --allow-unsafe-inline-script <true|false> Add unsafe-inline to script-src',
190+
)
191+
console.error(
192+
' --allow-unsafe-inline-style <true|false> Add unsafe-inline to style-src',
193+
)
194+
console.error(
195+
' --allow-unsafe-eval <true|false> Add unsafe-eval to script-src',
196+
)
197+
console.error(
198+
' --require-trusted-types <true|false> Add require-trusted-types-for script',
199+
)
200+
console.error(
201+
' --max-body-size <bytes> Maximum allowed bytes for HTML download',
202+
)
203+
console.error(
204+
' --timeout-ms <milliseconds> Timeout for fetch requests',
205+
)
206+
console.error(
207+
' --presets <presets> User-provided source lists',
208+
)
209+
console.error(
210+
' --fetch-options <json> Options to forward to fetch',
211+
)
212+
console.error(
213+
' --format, -f <format> Output format (header, raw, json, csp-only)',
214+
)
215+
console.error(
216+
'\nExample: csp-generator https://example.com --format json',
217+
)
218+
process.exit(1)
219+
}
220+
188221
const generator = new SecureCSPGenerator(options.url, {
189222
allowHttp: options.allowHttp,
190223
allowPrivateOrigins: options.allowPrivateOrigins,
@@ -201,7 +234,7 @@ export async function main() {
201234
const csp = await generator.generate()
202235
console.log(formatOutput(csp, options))
203236
} catch (error: any) {
204-
console.error('Error:', error)
237+
console.error('Error:', error.message || error)
205238
process.exit(1)
206239
}
207240
}

src/csp-generator.ts

Lines changed: 53 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export class SecureCSPGenerator {
5050
private detectedInlineScript = false
5151
private detectedInlineStyle = false
5252
private detectedEval = false
53+
private nonce: string = ''
5354

5455
/**
5556
* @param inputUrl - URL of the page to analyze (must be non-empty)
@@ -74,8 +75,13 @@ export class SecureCSPGenerator {
7475
timeoutMs = 8_000,
7576
logger = console,
7677
requireTrustedTypes = false,
78+
useNonce = true,
79+
customNonce = '',
7780
} = opts
7881

82+
// Generate or use custom nonce
83+
this.nonce = customNonce || (useNonce ? this.generateNonce() : '')
84+
7985
// Enforce HTTPS unless overridden
8086
if (!allowHttp && this.url.protocol !== 'https:') {
8187
throw new Error(
@@ -111,6 +117,16 @@ export class SecureCSPGenerator {
111117
}
112118
}
113119

120+
/**
121+
* Generates a cryptographically secure random nonce.
122+
* @returns A base64-encoded random string suitable for CSP nonces
123+
*/
124+
private generateNonce(): string {
125+
const buffer = new Uint8Array(16)
126+
crypto.getRandomValues(buffer)
127+
return Buffer.from(buffer).toString('base64')
128+
}
129+
114130
/**
115131
* Downloads HTML via fetch, respecting timeouts and size limits.
116132
* @throws Error if HTTP status not OK, type mismatch, or size exceeded
@@ -142,25 +158,19 @@ export class SecureCSPGenerator {
142158
throw new Error('Response too large – aborting')
143159
}
144160

145-
// Stream response body to string
146-
const reader = response.body?.getReader()
147-
if (!reader) throw new Error('Failed to read response body')
148-
149-
const chunks: Uint8Array[] = []
150-
let received = 0
151-
while (true) {
152-
const {done, value} = await reader.read()
153-
if (done) break
154-
if (value) {
155-
received += value.byteLength
156-
if (maxBodySize && received > maxBodySize) {
157-
ac.abort()
158-
throw new Error('Response exceeded maxBodySize')
159-
}
160-
chunks.push(value)
161+
try {
162+
// Get the response text directly
163+
this.html = await response.text()
164+
165+
// Check size after getting text
166+
if (maxBodySize && this.html.length > maxBodySize) {
167+
ac.abort()
168+
throw new Error('Response exceeded maxBodySize')
161169
}
170+
} catch (err) {
171+
ac.abort()
172+
throw err
162173
}
163-
this.html = Buffer.concat(chunks).toString('utf8')
164174
}
165175

166176
/**
@@ -269,6 +279,20 @@ export class SecureCSPGenerator {
269279
await this.extractCssUrls(styleEl.textContent || '', 'style-src')
270280
}
271281

282+
// Extract base URI
283+
let baseUriSet = false
284+
const baseEl = doc.querySelector('base[href]')
285+
if (baseEl) {
286+
const baseHref = baseEl.getAttribute('href')
287+
if (baseHref) {
288+
await this.resolveAndAdd('base-uri', baseHref)
289+
baseUriSet = true
290+
}
291+
}
292+
if (!baseUriSet) {
293+
this.ensureSet('base-uri').add("'self'")
294+
}
295+
272296
// Inline scripts hashing and nonce/integrity reuse
273297
for (const scr of Array.from(doc.querySelectorAll('script'))) {
274298
if (scr.hasAttribute('src')) continue
@@ -326,6 +350,18 @@ export class SecureCSPGenerator {
326350
await this.fetchHtml()
327351
await this.parse()
328352

353+
// Add nonce to script-src if enabled
354+
if (this.nonce) {
355+
this.ensureSet('script-src').add(`'nonce-${this.nonce}'`)
356+
this.ensureSet('script-src').add("'strict-dynamic'")
357+
}
358+
359+
// Always add 'unsafe-inline' if 'strict-dynamic' is present (for backward compatibility)
360+
const scriptSrc = this.sources.get('script-src')
361+
if (scriptSrc && scriptSrc.has("'strict-dynamic'")) {
362+
scriptSrc.add("'unsafe-inline'")
363+
}
364+
329365
// Conditionally allow unsafe directives
330366
if (this.detectedInlineScript && this.opts.allowUnsafeInlineScript) {
331367
this.ensureSet('script-src').add("'unsafe-inline'")

src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,11 @@ export interface SecureCSPGeneratorOptions {
118118
*/
119119
useNonce?: boolean
120120

121+
/**
122+
* Custom nonce value to use instead of generating one
123+
*/
124+
customNonce?: string
125+
121126
/**
122127
* If true, generates and adds hashes for inline scripts
123128
*/

0 commit comments

Comments
 (0)