Skip to content
2 changes: 1 addition & 1 deletion .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ jobs:
run: bun run lint

- name: Run tests
run: bun run test
run: bun test
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ csp-generator <url> [options]
| `--allow-unsafe-eval` | boolean | false | Add 'unsafe-eval' to 'script-src' |
| `--require-trusted-types` | boolean | false | Add "require-trusted-types-for 'script'" to the CSP |
| `--use-strict-dynamic` | boolean | false | Add 'strict-dynamic' to script-src |
| `--use-nonce` | boolean | false | Generate and use nonces for inline scripts |
| `--use-nonce` | boolean | true | Generate and use a random nonce for inline scripts (recommended) |
| `--custom-nonce` | string | | Use a custom nonce value instead of a random one |
| `--use-hashes` | boolean | false | Generate hashes for inline content |
| `--upgrade-insecure-requests` | boolean | true | Force HTTPS upgrades |
| `--block-mixed-content` | boolean | true | Block mixed content |
Expand All @@ -78,6 +79,16 @@ Generate CSP with default settings:
csp-generator https://example.com
```

Use a custom nonce:
```bash
csp-generator https://example.com --custom-nonce my-custom-nonce
```

Or with environment variable:
```bash
CSP_CUSTOM_NONCE=my-custom-nonce csp-generator https://example.com
```

Enable unsafe inline styles and strict dynamic:
```bash
csp-generator https://example.com \
Expand Down Expand Up @@ -174,7 +185,8 @@ The browser version provides the same functionality as the CLI but uses native b
### Security Options

- `CSP_USE_STRICT_DYNAMIC`: Add 'strict-dynamic' to script-src (default: false)
- `CSP_USE_NONCE`: Generate and use nonces for inline scripts (default: false)
- `CSP_USE_NONCE`: Generate and use nonces for inline scripts (default: true)
- `CSP_CUSTOM_NONCE`: Use a custom nonce value instead of a random one
- `CSP_USE_HASHES`: Generate hashes for inline content (default: false)
- `CSP_UPGRADE_INSECURE_REQUESTS`: Force HTTPS upgrades (default: true)
- `CSP_BLOCK_MIXED_CONTENT`: Block mixed content (default: true)
Expand Down
125 changes: 79 additions & 46 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,31 @@ export function getOptions(): SecureCSPGeneratorOptions {

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

// Validate URL format
if (finalUrl) {
try {
new URL(finalUrl)
} catch {
throw new Error('Invalid URL format')
}
}

const parseBoolean = (
value: string | undefined,
envVar: string | undefined,
value: string | boolean | undefined,
envVar: string | boolean | undefined,
defaultValue: boolean = false,
) => {
if (value !== undefined) return value === 'true'
return envVar === 'true'
if (typeof value === 'boolean') return value
if (typeof value === 'string') {
if (value.trim() === '') return defaultValue
return value === 'true'
}
if (typeof envVar === 'boolean') return envVar
if (typeof envVar === 'string') {
if (envVar.trim() === '') return defaultValue
return envVar === 'true'
}
return defaultValue
}

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

// Validate format value
const validFormats = ['header', 'raw', 'json', 'csp-only']
const outputFormat = format || process.env.CSP_OUTPUT_FORMAT || 'header'
if (!validFormats.includes(outputFormat)) {
console.warn(`Invalid format "${outputFormat}", defaulting to "header"`)
}

return {
url: finalUrl,
allowHttp: parseBoolean(allowHttp, process.env.CSP_ALLOW_HTTP),
Expand All @@ -132,59 +158,66 @@ export function getOptions(): SecureCSPGeneratorOptions {
requireTrustedTypes: parseBoolean(
requireTrustedTypes,
process.env.CSP_REQUIRE_TRUSTED_TYPES,
true,
),
maxBodySize: parseNumber(maxBodySize, process.env.CSP_MAX_BODY_SIZE, 0),
timeoutMs: parseNumber(timeoutMs, process.env.CSP_TIMEOUT_MS, 8000),
presets: parsePresets(presets || process.env.CSP_PRESETS),
fetchOptions: parseFetchOptions(
fetchOptions || process.env.CSP_FETCH_OPTIONS,
),
outputFormat: (format ||
process.env.CSP_OUTPUT_FORMAT ||
'header') as SecureCSPGeneratorOptions['outputFormat'],
outputFormat: (validFormats.includes(outputFormat)
? outputFormat
: 'header') as SecureCSPGeneratorOptions['outputFormat'],
}
}

export async function main() {
const options = getOptions()

if (!options.url) {
console.error('Usage: csp-generator <url> [options]')
console.error('\nOptions:')
console.error(
' --allow-http <true|false> Allow HTTP URLs in addition to HTTPS',
)
console.error(
' --allow-private-origins <true|false> Permit private IP / localhost origins',
)
console.error(
' --allow-unsafe-inline-script <true|false> Add unsafe-inline to script-src',
)
console.error(
' --allow-unsafe-inline-style <true|false> Add unsafe-inline to style-src',
)
console.error(
' --allow-unsafe-eval <true|false> Add unsafe-eval to script-src',
)
console.error(
' --require-trusted-types <true|false> Add require-trusted-types-for script',
)
console.error(
' --max-body-size <bytes> Maximum allowed bytes for HTML download',
)
console.error(' --timeout-ms <milliseconds> Timeout for fetch requests')
console.error(' --presets <presets> User-provided source lists')
console.error(
' --fetch-options <json> Options to forward to fetch',
)
console.error(
' --format, -f <format> Output format (header, raw, json, csp-only)',
)
console.error('\nExample: csp-generator https://example.com --format json')
process.exit(1)
}

try {
const options = getOptions()

if (!options.url) {
console.error('Usage: csp-generator <url> [options]')
console.error('\nOptions:')
console.error(
' --allow-http <true|false> Allow HTTP URLs in addition to HTTPS',
)
console.error(
' --allow-private-origins <true|false> Permit private IP / localhost origins',
)
console.error(
' --allow-unsafe-inline-script <true|false> Add unsafe-inline to script-src',
)
console.error(
' --allow-unsafe-inline-style <true|false> Add unsafe-inline to style-src',
)
console.error(
' --allow-unsafe-eval <true|false> Add unsafe-eval to script-src',
)
console.error(
' --require-trusted-types <true|false> Add require-trusted-types-for script',
)
console.error(
' --max-body-size <bytes> Maximum allowed bytes for HTML download',
)
console.error(
' --timeout-ms <milliseconds> Timeout for fetch requests',
)
console.error(
' --presets <presets> User-provided source lists',
)
console.error(
' --fetch-options <json> Options to forward to fetch',
)
console.error(
' --format, -f <format> Output format (header, raw, json, csp-only)',
)
console.error(
'\nExample: csp-generator https://example.com --format json',
)
process.exit(1)
}

const generator = new SecureCSPGenerator(options.url, {
allowHttp: options.allowHttp,
allowPrivateOrigins: options.allowPrivateOrigins,
Expand All @@ -201,7 +234,7 @@ export async function main() {
const csp = await generator.generate()
console.log(formatOutput(csp, options))
} catch (error: any) {
console.error('Error:', error)
console.error('Error:', error.message || error)
process.exit(1)
}
}
Expand Down
70 changes: 53 additions & 17 deletions src/csp-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export class SecureCSPGenerator {
private detectedInlineScript = false
private detectedInlineStyle = false
private detectedEval = false
private nonce: string = ''

/**
* @param inputUrl - URL of the page to analyze (must be non-empty)
Expand All @@ -74,8 +75,13 @@ export class SecureCSPGenerator {
timeoutMs = 8_000,
logger = console,
requireTrustedTypes = false,
useNonce = true,
customNonce = '',
} = opts

// Generate or use custom nonce
this.nonce = customNonce || (useNonce ? this.generateNonce() : '')

// Enforce HTTPS unless overridden
if (!allowHttp && this.url.protocol !== 'https:') {
throw new Error(
Expand Down Expand Up @@ -111,6 +117,16 @@ export class SecureCSPGenerator {
}
}

/**
* Generates a cryptographically secure random nonce.
* @returns A base64-encoded random string suitable for CSP nonces
*/
private generateNonce(): string {
const buffer = new Uint8Array(16)
crypto.getRandomValues(buffer)
return Buffer.from(buffer).toString('base64')
}

/**
* Downloads HTML via fetch, respecting timeouts and size limits.
* @throws Error if HTTP status not OK, type mismatch, or size exceeded
Expand Down Expand Up @@ -142,25 +158,19 @@ export class SecureCSPGenerator {
throw new Error('Response too large – aborting')
}

// Stream response body to string
const reader = response.body?.getReader()
if (!reader) throw new Error('Failed to read response body')

const chunks: Uint8Array[] = []
let received = 0
while (true) {
const {done, value} = await reader.read()
if (done) break
if (value) {
received += value.byteLength
if (maxBodySize && received > maxBodySize) {
ac.abort()
throw new Error('Response exceeded maxBodySize')
}
chunks.push(value)
try {
// Get the response text directly
this.html = await response.text()

// Check size after getting text
if (maxBodySize && this.html.length > maxBodySize) {
ac.abort()
throw new Error('Response exceeded maxBodySize')
}
} catch (err) {
ac.abort()
throw err
}
this.html = Buffer.concat(chunks).toString('utf8')
}

/**
Expand Down Expand Up @@ -269,6 +279,20 @@ export class SecureCSPGenerator {
await this.extractCssUrls(styleEl.textContent || '', 'style-src')
}

// Extract base URI
let baseUriSet = false
const baseEl = doc.querySelector('base[href]')
if (baseEl) {
const baseHref = baseEl.getAttribute('href')
if (baseHref) {
await this.resolveAndAdd('base-uri', baseHref)
baseUriSet = true
}
}
if (!baseUriSet) {
this.ensureSet('base-uri').add("'self'")
}

// Inline scripts hashing and nonce/integrity reuse
for (const scr of Array.from(doc.querySelectorAll('script'))) {
if (scr.hasAttribute('src')) continue
Expand Down Expand Up @@ -326,6 +350,18 @@ export class SecureCSPGenerator {
await this.fetchHtml()
await this.parse()

// Add nonce to script-src if enabled
if (this.nonce) {
this.ensureSet('script-src').add(`'nonce-${this.nonce}'`)
this.ensureSet('script-src').add("'strict-dynamic'")
}

// Always add 'unsafe-inline' if 'strict-dynamic' is present (for backward compatibility)
const scriptSrc = this.sources.get('script-src')
if (scriptSrc && scriptSrc.has("'strict-dynamic'")) {
scriptSrc.add("'unsafe-inline'")
}

// Conditionally allow unsafe directives
if (this.detectedInlineScript && this.opts.allowUnsafeInlineScript) {
this.ensureSet('script-src').add("'unsafe-inline'")
Expand Down
5 changes: 5 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,11 @@ export interface SecureCSPGeneratorOptions {
*/
useNonce?: boolean

/**
* Custom nonce value to use instead of generating one
*/
customNonce?: string

/**
* If true, generates and adds hashes for inline scripts
*/
Expand Down
Loading