From ecd461057cf695dd537cde8961380c1322994a55 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Mon, 30 Mar 2026 23:14:22 -0400 Subject: [PATCH] feat(security): add comprehensive security audit and dependency hardening - Fix picomatch vulnerabilities by updating vitest to 4.1.2 - Add Snyk scanning to CI workflow with artifact uploads - Create input sanitization library (URL, path, HTML, shell, JSON) - Add CSP headers configuration for Next.js, Vite, and Express - Create security best practices documentation - Enhance pipeline.config.json with security settings - Add .snyk policy file for vulnerability management Co-Authored-By: Claude Opus 4.5 --- .claude/pipeline.config.json | 24 +- .github/workflows/ci.yml | 46 ++ .snyk | 36 ++ docs/security/SECURITY-BEST-PRACTICES.md | 486 ++++++++++++++++++++ package.json | 2 +- pnpm-lock.yaml | 148 +++--- scripts/lib/sanitize.js | 326 +++++++++++++ templates/shared/security-headers.config.js | 257 +++++++++++ 8 files changed, 1255 insertions(+), 70 deletions(-) create mode 100644 .snyk create mode 100644 docs/security/SECURITY-BEST-PRACTICES.md create mode 100644 scripts/lib/sanitize.js create mode 100644 templates/shared/security-headers.config.js diff --git a/.claude/pipeline.config.json b/.claude/pipeline.config.json index ad7f49d..983b52d 100644 --- a/.claude/pipeline.config.json +++ b/.claude/pipeline.config.json @@ -230,7 +230,29 @@ "enabled": true, "auditLevel": "moderate", "failOnVulnerability": true, - "checkLockfile": true + "checkLockfile": true, + "snyk": { + "enabled": true, + "severityThreshold": "high", + "failOnIssues": false + }, + "csp": { + "enabled": true, + "reportOnly": false, + "reportUri": "/api/csp-report" + }, + "inputSanitization": { + "enabled": true, + "blockPrivateUrls": true, + "allowLocalhostInDev": true + }, + "headers": { + "hsts": true, + "noSniff": true, + "frameOptions": "SAMEORIGIN", + "xssProtection": true, + "referrerPolicy": "strict-origin-when-cross-origin" + } }, "bundleSize": { "enabled": true, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b310ec1..5417cc3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,6 +83,52 @@ jobs: echo "No app source found — skipping token check" fi + security-scan: + name: Security Scanning + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run pnpm audit + run: pnpm audit --audit-level moderate + continue-on-error: true + + - name: Run Snyk security scan + uses: snyk/actions/node@master + continue-on-error: true + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + with: + args: --severity-threshold=high + + - name: Run security anti-pattern check + run: | + if [ -f "scripts/check-security.sh" ]; then + bash scripts/check-security.sh --no-fail + fi + + - name: Upload Snyk report + uses: actions/upload-artifact@v4 + if: always() + with: + name: snyk-report + path: snyk-report.json + if-no-files-found: ignore + retention-days: 30 + visual-regression: name: Visual Regression Test runs-on: ubuntu-latest diff --git a/.snyk b/.snyk new file mode 100644 index 0000000..763e1ff --- /dev/null +++ b/.snyk @@ -0,0 +1,36 @@ +# Snyk (https://snyk.io) policy file +# https://snyk.io/docs/snyk-policy-file/ +version: v1.25.0 + +# Ignores are used to ignore specific vulnerabilities +# Example: +# ignore: +# SNYK-JS-LODASH-567746: +# - '*': +# reason: 'Low severity, no fix available' +# expires: 2026-06-30T00:00:00.000Z + +# Patches are automatic fixes +patch: {} + +# Exclude paths from scanning +exclude: + global: + - node_modules + - .git + - dist + - build + - coverage + - .claude/visual-qa + +# Severity threshold for failing builds +# Options: low, medium, high, critical +severity-threshold: high + +# Fail only on fixable issues +fail-on: upgradable + +# Language settings +language-settings: + javascript: + package-manager: pnpm diff --git a/docs/security/SECURITY-BEST-PRACTICES.md b/docs/security/SECURITY-BEST-PRACTICES.md new file mode 100644 index 0000000..1341976 --- /dev/null +++ b/docs/security/SECURITY-BEST-PRACTICES.md @@ -0,0 +1,486 @@ +# Security Best Practices + +This document outlines security best practices for applications built with the Aurelius framework. Following these guidelines helps protect your application and users from common vulnerabilities. + +## Table of Contents + +- [Dependency Security](#dependency-security) +- [Input Sanitization](#input-sanitization) +- [Content Security Policy](#content-security-policy) +- [Authentication & Authorization](#authentication--authorization) +- [Data Protection](#data-protection) +- [API Security](#api-security) +- [CI/CD Security](#cicd-security) +- [Secure Coding Practices](#secure-coding-practices) +- [Security Checklist](#security-checklist) + +--- + +## Dependency Security + +### Automated Scanning + +The framework includes automated security scanning in CI: + +```bash +# Run local security audit +./scripts/check-security.sh + +# Check with specific severity level +./scripts/check-security.sh --level critical + +# Output as JSON for automation +./scripts/check-security.sh --json +``` + +### Snyk Integration + +Snyk scanning runs automatically on every PR. To set up: + +1. Create a Snyk account at [snyk.io](https://snyk.io) +2. Get your API token from Snyk settings +3. Add `SNYK_TOKEN` to your repository secrets +4. Snyk will scan on every push to `main` and on PRs + +### Keeping Dependencies Updated + +```bash +# Check for outdated packages +pnpm outdated + +# Update all dependencies +pnpm update + +# Update a specific package +pnpm update --latest +``` + +### Lock File Best Practices + +- **Always commit `pnpm-lock.yaml`** — ensures consistent installs +- **Use `--frozen-lockfile` in CI** — prevents unexpected updates +- **Review lock file changes** — watch for unexpected new dependencies + +--- + +## Input Sanitization + +### Using the Sanitization Library + +Import sanitization utilities from `scripts/lib/sanitize.js`: + +```javascript +import { + sanitizeUrl, + sanitizePath, + sanitizeHtml, + sanitizeShellArg, + sanitizeDesignUrl, + sanitizeJson, +} from './scripts/lib/sanitize.js'; + +// Validate URLs before fetching +const result = sanitizeUrl(userProvidedUrl); +if (!result.valid) { + console.error(result.error); + return; +} +fetch(result.url); + +// Validate file paths to prevent directory traversal +const pathResult = sanitizePath(userPath, './uploads'); +if (!pathResult.valid) { + throw new Error(pathResult.error); +} + +// Sanitize HTML to prevent XSS +const safeHtml = sanitizeHtml(userContent); + +// Validate design URLs for pipeline +const designResult = sanitizeDesignUrl(figmaUrl); +if (designResult.type === 'figma') { + // Process Figma design +} +``` + +### Common Vulnerabilities to Prevent + +| Vulnerability | Prevention | +|---------------|------------| +| XSS (Cross-Site Scripting) | Use `sanitizeHtml()`, avoid `dangerouslySetInnerHTML` | +| SQL Injection | Use parameterized queries, ORMs | +| Command Injection | Use `sanitizeShellArg()`, avoid shell commands with user input | +| Path Traversal | Use `sanitizePath()` with a safe base directory | +| SSRF | Use `sanitizeUrl()` to block private IPs | +| Prototype Pollution | Use `sanitizeJson()` for parsing untrusted JSON | + +### React-Specific Security + +```jsx +// BAD: XSS vulnerability +
+ +// GOOD: Sanitize first +import { sanitizeHtml } from './scripts/lib/sanitize.js'; +
+ +// BETTER: Use a dedicated library like DOMPurify +import DOMPurify from 'dompurify'; +
+ +// BEST: Avoid innerHTML entirely +
{userContent}
// React escapes by default +``` + +--- + +## Content Security Policy + +### Configuration + +The framework provides CSP configuration in `templates/shared/security-headers.config.js`: + +```javascript +import { nextjsSecurityHeaders } from './security-headers.config.js'; + +// next.config.js +export default { + async headers() { + return nextjsSecurityHeaders; + }, +}; +``` + +### CSP Directives Explained + +| Directive | Purpose | Recommended Value | +|-----------|---------|-------------------| +| `default-src` | Fallback for other directives | `'self'` | +| `script-src` | JavaScript sources | `'self'` (avoid `'unsafe-inline'`) | +| `style-src` | CSS sources | `'self' 'unsafe-inline'` (for Tailwind) | +| `img-src` | Image sources | `'self' data: https:` | +| `connect-src` | Fetch/XHR destinations | `'self'` + your API domains | +| `frame-ancestors` | Who can embed your page | `'self'` or `'none'` | +| `object-src` | Plugin sources | `'none'` | + +### Using Nonces for Inline Scripts + +```javascript +import { generateCspNonce, getCspWithNonce } from './security-headers.config.js'; + +// Server-side: generate nonce per request +const nonce = generateCspNonce(); +const cspDirectives = getCspWithNonce(nonce); + +// Pass nonce to script tags + +``` + +### Testing CSP + +1. **Report-Only Mode**: Test without blocking + ```javascript + import { buildReportOnlyCsp } from './security-headers.config.js'; + res.setHeader('Content-Security-Policy-Report-Only', buildReportOnlyCsp('/api/csp-report')); + ``` + +2. **Browser DevTools**: Check Console for CSP violations + +3. **CSP Evaluator**: Use [csp-evaluator.withgoogle.com](https://csp-evaluator.withgoogle.com/) + +--- + +## Authentication & Authorization + +### Secure Session Management + +```javascript +// Use secure cookie settings +const sessionOptions = { + httpOnly: true, // Prevents XSS access to cookies + secure: true, // HTTPS only + sameSite: 'strict', // CSRF protection + maxAge: 3600000, // 1 hour expiry +}; +``` + +### JWT Best Practices + +- **Short expiration times** (15 minutes for access tokens) +- **Use refresh tokens** for longer sessions +- **Store in httpOnly cookies**, not localStorage +- **Validate on every request** +- **Include audience and issuer claims** + +### RBAC Implementation + +```typescript +// Define roles and permissions +const permissions = { + admin: ['read', 'write', 'delete', 'manage-users'], + editor: ['read', 'write'], + viewer: ['read'], +}; + +// Check permissions +function hasPermission(userRole: string, action: string): boolean { + return permissions[userRole]?.includes(action) ?? false; +} +``` + +--- + +## Data Protection + +### Environment Variables + +```bash +# .env.example (commit this) +DATABASE_URL=postgresql://localhost/myapp +API_KEY=your-api-key-here + +# .env (NEVER commit this) +DATABASE_URL=postgresql://user:pass@prod-db/myapp +API_KEY=sk-live-xxxxxxxxxxxx +``` + +### Secrets Management + +- **Never commit secrets** to version control +- **Use environment variables** for all sensitive data +- **Rotate secrets regularly** +- **Use secret managers** (AWS Secrets Manager, Vault, etc.) in production + +### Encryption + +```javascript +// Use crypto for sensitive data +import crypto from 'crypto'; + +const algorithm = 'aes-256-gcm'; +const key = crypto.scryptSync(process.env.ENCRYPTION_KEY, 'salt', 32); + +function encrypt(text) { + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(algorithm, key, iv); + const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + return { iv: iv.toString('hex'), data: encrypted.toString('hex'), authTag: authTag.toString('hex') }; +} +``` + +--- + +## API Security + +### Rate Limiting + +```javascript +import { RateLimiter } from './scripts/lib/sanitize.js'; + +const limiter = new RateLimiter(100, 60000); // 100 requests per minute + +function apiHandler(req, res) { + const clientId = req.ip; + const { allowed, remaining, resetMs } = limiter.check(clientId); + + if (!allowed) { + res.status(429).json({ + error: 'Too many requests', + retryAfter: Math.ceil(resetMs / 1000), + }); + return; + } + + res.setHeader('X-RateLimit-Remaining', remaining); + // Handle request... +} +``` + +### CORS Configuration + +```javascript +// Restrictive CORS for production +const corsOptions = { + origin: ['https://myapp.com', 'https://api.myapp.com'], + methods: ['GET', 'POST', 'PUT', 'DELETE'], + allowedHeaders: ['Content-Type', 'Authorization'], + credentials: true, + maxAge: 86400, // 24 hours +}; +``` + +### API Input Validation + +```typescript +import { z } from 'zod'; + +// Define schema +const CreateUserSchema = z.object({ + email: z.string().email(), + password: z.string().min(12).max(128), + name: z.string().min(1).max(100), +}); + +// Validate input +function createUser(req, res) { + const result = CreateUserSchema.safeParse(req.body); + if (!result.success) { + return res.status(400).json({ errors: result.error.issues }); + } + // Process validated data... +} +``` + +--- + +## CI/CD Security + +### GitHub Actions Security + +```yaml +# .github/workflows/ci.yml +jobs: + security-scan: + runs-on: ubuntu-latest + permissions: + contents: read # Minimal permissions + security-events: write + steps: + - uses: actions/checkout@v4 + + # Pin action versions to SHA + - uses: snyk/actions/node@8349f90127... + + # Use secrets, never hardcode + - env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} +``` + +### Secrets in CI + +- **Use GitHub Secrets** for sensitive values +- **Limit secret scope** to specific environments +- **Rotate secrets** after any potential exposure +- **Audit secret access** regularly + +### Dependency Review + +```yaml +# Block PRs that introduce vulnerable dependencies +- name: Dependency Review + uses: actions/dependency-review-action@v4 + with: + fail-on-severity: high + deny-licenses: GPL-3.0, AGPL-3.0 +``` + +--- + +## Secure Coding Practices + +### TypeScript Security + +```typescript +// Use strict mode +// tsconfig.json +{ + "compilerOptions": { + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true + } +} + +// Avoid type assertions that bypass checks +const data = untrustedInput as UserData; // BAD +const data = UserDataSchema.parse(untrustedInput); // GOOD +``` + +### Error Handling + +```typescript +// Don't expose internal errors to users +try { + await sensitiveOperation(); +} catch (error) { + // Log full error internally + logger.error('Operation failed', { error, userId }); + + // Return generic message to user + res.status(500).json({ error: 'An error occurred' }); +} +``` + +### Logging Security + +```typescript +// Never log sensitive data +logger.info('User login', { + userId: user.id, + // email: user.email, // BAD: PII + // password: user.password, // NEVER + // token: authToken, // NEVER +}); +``` + +--- + +## Security Checklist + +### Before Every Release + +- [ ] Run `./scripts/check-security.sh` +- [ ] Check `pnpm audit` for vulnerabilities +- [ ] Review new dependencies for security +- [ ] Verify no secrets in code or logs +- [ ] Test authentication flows +- [ ] Verify authorization on all endpoints +- [ ] Check CSP headers are set correctly +- [ ] Test rate limiting + +### Periodic Reviews + +- [ ] Update all dependencies monthly +- [ ] Rotate API keys and secrets quarterly +- [ ] Review access permissions +- [ ] Audit third-party integrations +- [ ] Review security logs +- [ ] Update security documentation + +### Incident Response + +1. **Identify** — Confirm the security issue +2. **Contain** — Limit the blast radius +3. **Investigate** — Understand scope and impact +4. **Remediate** — Fix the vulnerability +5. **Communicate** — Notify affected parties +6. **Review** — Update processes to prevent recurrence + +--- + +## Resources + +- [OWASP Top 10](https://owasp.org/www-project-top-ten/) +- [OWASP Cheat Sheet Series](https://cheatsheetseries.owasp.org/) +- [Node.js Security Best Practices](https://nodejs.org/en/docs/guides/security/) +- [React Security Best Practices](https://snyk.io/blog/10-react-security-best-practices/) +- [CSP Reference](https://content-security-policy.com/) +- [Snyk Learn](https://learn.snyk.io/) + +--- + +## Reporting Security Issues + +If you discover a security vulnerability: + +1. **Do not** open a public issue +2. Email security concerns to the maintainers +3. Include steps to reproduce +4. Allow time for a fix before disclosure + +--- + +*Last updated: 2026-03-30* diff --git a/package.json b/package.json index 5ca679d..636a385 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,6 @@ "husky": "^9.1.7", "pixelmatch": "^7.1.0", "pngjs": "^7.0.0", - "vitest": "^4.1.0" + "vitest": "^4.1.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 826b1c3..da556a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,8 +27,8 @@ importers: specifier: ^7.0.0 version: 7.0.0 vitest: - specifier: ^4.1.0 - version: 4.1.0(@types/node@25.5.0)(vite@8.0.0(@types/node@25.5.0)(jiti@2.6.1)(yaml@2.8.3)) + specifier: ^4.1.2 + version: 4.1.2(@types/node@25.5.0)(vite@8.0.0(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@25.5.0)(jiti@2.6.1)(yaml@2.8.3)) packages: @@ -137,8 +137,11 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@napi-rs/wasm-runtime@1.1.1': - resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + '@napi-rs/wasm-runtime@1.1.2': + resolution: {integrity: sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 '@oxc-project/runtime@0.115.0': resolution: {integrity: sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==} @@ -271,34 +274,34 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} - '@vitest/expect@4.1.0': - resolution: {integrity: sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==} + '@vitest/expect@4.1.2': + resolution: {integrity: sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==} - '@vitest/mocker@4.1.0': - resolution: {integrity: sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==} + '@vitest/mocker@4.1.2': + resolution: {integrity: sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==} peerDependencies: msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@4.1.0': - resolution: {integrity: sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==} + '@vitest/pretty-format@4.1.2': + resolution: {integrity: sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==} - '@vitest/runner@4.1.0': - resolution: {integrity: sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==} + '@vitest/runner@4.1.2': + resolution: {integrity: sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==} - '@vitest/snapshot@4.1.0': - resolution: {integrity: sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==} + '@vitest/snapshot@4.1.2': + resolution: {integrity: sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==} - '@vitest/spy@4.1.0': - resolution: {integrity: sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==} + '@vitest/spy@4.1.2': + resolution: {integrity: sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==} - '@vitest/utils@4.1.0': - resolution: {integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==} + '@vitest/utils@4.1.2': + resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==} JSONStream@1.3.5: resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} @@ -1016,8 +1019,8 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} pify@2.3.0: @@ -1292,21 +1295,21 @@ packages: yaml: optional: true - vitest@4.1.0: - resolution: {integrity: sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==} + vitest@4.1.2: + resolution: {integrity: sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.0 - '@vitest/browser-preview': 4.1.0 - '@vitest/browser-webdriverio': 4.1.0 - '@vitest/ui': 4.1.0 + '@vitest/browser-playwright': 4.1.2 + '@vitest/browser-preview': 4.1.2 + '@vitest/browser-webdriverio': 4.1.2 + '@vitest/ui': 4.1.2 happy-dom: '*' jsdom: '*' - vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -1527,7 +1530,7 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} - '@napi-rs/wasm-runtime@1.1.1': + '@napi-rs/wasm-runtime@1.1.2(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)': dependencies: '@emnapi/core': 1.9.0 '@emnapi/runtime': 1.9.0 @@ -1574,9 +1577,12 @@ snapshots: '@rolldown/binding-openharmony-arm64@1.0.0-rc.9': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.9': + '@rolldown/binding-wasm32-wasi@1.0.0-rc.9(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)': dependencies: - '@napi-rs/wasm-runtime': 1.1.1 + '@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' optional: true '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9': @@ -1617,44 +1623,44 @@ snapshots: '@types/normalize-package-data@2.4.4': {} - '@vitest/expect@4.1.0': + '@vitest/expect@4.1.2': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.0 - '@vitest/utils': 4.1.0 + '@vitest/spy': 4.1.2 + '@vitest/utils': 4.1.2 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.0(vite@8.0.0(@types/node@25.5.0)(jiti@2.6.1)(yaml@2.8.3))': + '@vitest/mocker@4.1.2(vite@8.0.0(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@25.5.0)(jiti@2.6.1)(yaml@2.8.3))': dependencies: - '@vitest/spy': 4.1.0 + '@vitest/spy': 4.1.2 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.0(@types/node@25.5.0)(jiti@2.6.1)(yaml@2.8.3) + vite: 8.0.0(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@25.5.0)(jiti@2.6.1)(yaml@2.8.3) - '@vitest/pretty-format@4.1.0': + '@vitest/pretty-format@4.1.2': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.1.0': + '@vitest/runner@4.1.2': dependencies: - '@vitest/utils': 4.1.0 + '@vitest/utils': 4.1.2 pathe: 2.0.3 - '@vitest/snapshot@4.1.0': + '@vitest/snapshot@4.1.2': dependencies: - '@vitest/pretty-format': 4.1.0 - '@vitest/utils': 4.1.0 + '@vitest/pretty-format': 4.1.2 + '@vitest/utils': 4.1.2 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.0': {} + '@vitest/spy@4.1.2': {} - '@vitest/utils@4.1.0': + '@vitest/utils@4.1.2': dependencies: - '@vitest/pretty-format': 4.1.0 + '@vitest/pretty-format': 4.1.2 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 @@ -1954,9 +1960,9 @@ snapshots: path-expression-matcher: 1.2.0 strnum: 2.2.2 - fdir@6.5.0(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: - picomatch: 4.0.3 + picomatch: 4.0.4 figures@3.2.0: dependencies: @@ -2326,7 +2332,7 @@ snapshots: picocolors@1.1.1: {} - picomatch@4.0.3: {} + picomatch@4.0.4: {} pify@2.3.0: {} @@ -2407,7 +2413,7 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - rolldown@1.0.0-rc.9: + rolldown@1.0.0-rc.9(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0): dependencies: '@oxc-project/types': 0.115.0 '@rolldown/pluginutils': 1.0.0-rc.9 @@ -2424,9 +2430,12 @@ snapshots: '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.9 '@rolldown/binding-linux-x64-musl': 1.0.0-rc.9 '@rolldown/binding-openharmony-arm64': 1.0.0-rc.9 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.9 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.9(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0) '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.9 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.9 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' safe-buffer@5.1.2: {} @@ -2515,8 +2524,8 @@ snapshots: tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 tinyrainbow@3.1.0: {} @@ -2547,41 +2556,44 @@ snapshots: spdx-correct: 3.2.0 spdx-expression-parse: 3.0.1 - vite@8.0.0(@types/node@25.5.0)(jiti@2.6.1)(yaml@2.8.3): + vite@8.0.0(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@25.5.0)(jiti@2.6.1)(yaml@2.8.3): dependencies: '@oxc-project/runtime': 0.115.0 lightningcss: 1.32.0 - picomatch: 4.0.3 + picomatch: 4.0.4 postcss: 8.5.8 - rolldown: 1.0.0-rc.9 + rolldown: 1.0.0-rc.9(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0) tinyglobby: 0.2.15 optionalDependencies: '@types/node': 25.5.0 fsevents: 2.3.3 jiti: 2.6.1 yaml: 2.8.3 - - vitest@4.1.0(@types/node@25.5.0)(vite@8.0.0(@types/node@25.5.0)(jiti@2.6.1)(yaml@2.8.3)): - dependencies: - '@vitest/expect': 4.1.0 - '@vitest/mocker': 4.1.0(vite@8.0.0(@types/node@25.5.0)(jiti@2.6.1)(yaml@2.8.3)) - '@vitest/pretty-format': 4.1.0 - '@vitest/runner': 4.1.0 - '@vitest/snapshot': 4.1.0 - '@vitest/spy': 4.1.0 - '@vitest/utils': 4.1.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + vitest@4.1.2(@types/node@25.5.0)(vite@8.0.0(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@25.5.0)(jiti@2.6.1)(yaml@2.8.3)): + dependencies: + '@vitest/expect': 4.1.2 + '@vitest/mocker': 4.1.2(vite@8.0.0(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@25.5.0)(jiti@2.6.1)(yaml@2.8.3)) + '@vitest/pretty-format': 4.1.2 + '@vitest/runner': 4.1.2 + '@vitest/snapshot': 4.1.2 + '@vitest/spy': 4.1.2 + '@vitest/utils': 4.1.2 es-module-lexer: 2.0.0 expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.4 std-env: 4.0.0 tinybench: 2.9.0 tinyexec: 1.0.4 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vite: 8.0.0(@types/node@25.5.0)(jiti@2.6.1)(yaml@2.8.3) + vite: 8.0.0(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@25.5.0)(jiti@2.6.1)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.5.0 diff --git a/scripts/lib/sanitize.js b/scripts/lib/sanitize.js new file mode 100644 index 0000000..7980854 --- /dev/null +++ b/scripts/lib/sanitize.js @@ -0,0 +1,326 @@ +/** + * sanitize.js — Input sanitization utilities for pipeline security + * + * Provides functions to sanitize user inputs, URLs, file paths, and other + * potentially dangerous data before processing in the build pipeline. + */ + +/** + * Sanitize a URL to prevent SSRF and injection attacks + * @param {string} url - The URL to sanitize + * @returns {{ valid: boolean, url: string | null, error?: string }} + */ +export function sanitizeUrl(url) { + if (typeof url !== 'string' || !url.trim()) { + return { valid: false, url: null, error: 'URL must be a non-empty string' }; + } + + try { + const parsed = new URL(url.trim()); + + // Only allow http and https protocols + if (!['http:', 'https:'].includes(parsed.protocol)) { + return { + valid: false, + url: null, + error: `Invalid protocol: ${parsed.protocol}. Only http and https are allowed`, + }; + } + + // Block localhost and private IP ranges in production + const hostname = parsed.hostname.toLowerCase(); + const blockedPatterns = [ + /^localhost$/i, + /^127\.\d+\.\d+\.\d+$/, + /^10\.\d+\.\d+\.\d+$/, + /^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$/, + /^192\.168\.\d+\.\d+$/, + /^0\.0\.0\.0$/, + /^::1$/, + /^\[::1\]$/, + ]; + + const isBlocked = blockedPatterns.some((pattern) => pattern.test(hostname)); + + // Allow localhost in development mode + const isDev = process.env.NODE_ENV === 'development'; + if (isBlocked && !isDev) { + return { + valid: false, + url: null, + error: 'Private/local addresses are not allowed', + }; + } + + return { valid: true, url: parsed.href }; + } catch (e) { + return { valid: false, url: null, error: `Invalid URL: ${e.message}` }; + } +} + +/** + * Sanitize a file path to prevent directory traversal attacks + * @param {string} filePath - The file path to sanitize + * @param {string} baseDir - The allowed base directory + * @returns {{ valid: boolean, path: string | null, error?: string }} + */ +export function sanitizePath(filePath, baseDir) { + if (typeof filePath !== 'string' || !filePath.trim()) { + return { valid: false, path: null, error: 'Path must be a non-empty string' }; + } + + if (typeof baseDir !== 'string' || !baseDir.trim()) { + return { valid: false, path: null, error: 'Base directory must be specified' }; + } + + // Normalize paths for cross-platform compatibility + const path = require('path'); + const normalizedBase = path.resolve(baseDir); + const normalizedPath = path.resolve(baseDir, filePath); + + // Ensure the resolved path is within the base directory + if (!normalizedPath.startsWith(normalizedBase + path.sep) && normalizedPath !== normalizedBase) { + return { + valid: false, + path: null, + error: 'Path traversal detected: path escapes base directory', + }; + } + + // Block sensitive file patterns + const blockedPatterns = [ + /\.env$/i, + /\.env\.\w+$/i, + /\.git\//i, + /node_modules\//i, + /\.ssh\//i, + /\.aws\//i, + /credentials/i, + /secrets?\./i, + /\.pem$/i, + /\.key$/i, + /id_rsa/i, + /id_ed25519/i, + ]; + + const isBlocked = blockedPatterns.some((pattern) => pattern.test(filePath)); + if (isBlocked) { + return { + valid: false, + path: null, + error: 'Access to sensitive files is not allowed', + }; + } + + return { valid: true, path: normalizedPath }; +} + +/** + * Sanitize HTML content to prevent XSS attacks + * @param {string} html - The HTML content to sanitize + * @returns {string} - Sanitized HTML with dangerous elements removed + */ +export function sanitizeHtml(html) { + if (typeof html !== 'string') { + return ''; + } + + // Remove script tags and their content + let sanitized = html.replace(/)<[^<]*)*<\/script>/gi, ''); + + // Remove event handlers + sanitized = sanitized.replace(/\s*on\w+\s*=\s*["'][^"']*["']/gi, ''); + sanitized = sanitized.replace(/\s*on\w+\s*=\s*[^\s>]+/gi, ''); + + // Remove javascript: URLs + sanitized = sanitized.replace(/href\s*=\s*["']?\s*javascript:[^"'>\s]*/gi, 'href="#"'); + sanitized = sanitized.replace(/src\s*=\s*["']?\s*javascript:[^"'>\s]*/gi, 'src=""'); + + // Remove data: URLs in src attributes (potential XSS vector) + sanitized = sanitized.replace(/src\s*=\s*["']?\s*data:[^"'>\s]*/gi, 'src=""'); + + // Remove style expressions (IE-specific XSS) + sanitized = sanitized.replace(/expression\s*\([^)]*\)/gi, ''); + + // Remove iframe, object, embed, and form tags + sanitized = sanitized.replace(/<(iframe|object|embed|form)\b[^>]*>/gi, ''); + sanitized = sanitized.replace(/<\/(iframe|object|embed|form)>/gi, ''); + + return sanitized; +} + +/** + * Sanitize shell command arguments to prevent command injection + * @param {string} arg - The argument to sanitize + * @returns {{ valid: boolean, arg: string | null, error?: string }} + */ +export function sanitizeShellArg(arg) { + if (typeof arg !== 'string') { + return { valid: false, arg: null, error: 'Argument must be a string' }; + } + + // Block dangerous shell metacharacters + const dangerousPatterns = [ + /[;&|`$(){}[\]<>]/, + /\n/, + /\r/, + /\0/, + ]; + + const hasDangerous = dangerousPatterns.some((pattern) => pattern.test(arg)); + if (hasDangerous) { + return { + valid: false, + arg: null, + error: 'Argument contains dangerous shell metacharacters', + }; + } + + // Escape remaining special characters for shell safety + const escaped = arg.replace(/'/g, "'\\''"); + + return { valid: true, arg: `'${escaped}'` }; +} + +/** + * Sanitize Figma/Canva design URLs + * @param {string} url - The design URL to validate + * @returns {{ valid: boolean, url: string | null, type: string | null, error?: string }} + */ +export function sanitizeDesignUrl(url) { + const urlResult = sanitizeUrl(url); + if (!urlResult.valid) { + return { ...urlResult, type: null }; + } + + const parsed = new URL(urlResult.url); + const hostname = parsed.hostname.toLowerCase(); + + // Figma URLs + if (hostname === 'www.figma.com' || hostname === 'figma.com') { + const figmaPattern = /^\/(?:file|design|proto)\/([a-zA-Z0-9]+)/; + if (figmaPattern.test(parsed.pathname)) { + return { valid: true, url: urlResult.url, type: 'figma' }; + } + return { valid: false, url: null, type: null, error: 'Invalid Figma URL format' }; + } + + // Canva URLs + if (hostname === 'www.canva.com' || hostname === 'canva.com') { + const canvaPattern = /^\/design\/([a-zA-Z0-9_-]+)/; + if (canvaPattern.test(parsed.pathname)) { + return { valid: true, url: urlResult.url, type: 'canva' }; + } + return { valid: false, url: null, type: null, error: 'Invalid Canva URL format' }; + } + + // Generic URL (for screenshot pipeline) + return { valid: true, url: urlResult.url, type: 'generic' }; +} + +/** + * Sanitize JSON input to prevent prototype pollution + * @param {string} jsonString - The JSON string to parse safely + * @returns {{ valid: boolean, data: any, error?: string }} + */ +export function sanitizeJson(jsonString) { + if (typeof jsonString !== 'string') { + return { valid: false, data: null, error: 'Input must be a string' }; + } + + try { + const data = JSON.parse(jsonString); + + // Check for prototype pollution attempts + const checkPrototypePollution = (obj, path = '') => { + if (obj === null || typeof obj !== 'object') { + return null; + } + + const dangerousKeys = ['__proto__', 'constructor', 'prototype']; + + for (const key of Object.keys(obj)) { + if (dangerousKeys.includes(key)) { + return `Prototype pollution attempt detected at ${path}${key}`; + } + + const nested = checkPrototypePollution(obj[key], `${path}${key}.`); + if (nested) { + return nested; + } + } + + return null; + }; + + const pollutionError = checkPrototypePollution(data); + if (pollutionError) { + return { valid: false, data: null, error: pollutionError }; + } + + return { valid: true, data }; + } catch (e) { + return { valid: false, data: null, error: `Invalid JSON: ${e.message}` }; + } +} + +/** + * Rate limiting helper for pipeline operations + */ +export class RateLimiter { + constructor(maxRequests = 100, windowMs = 60000) { + this.maxRequests = maxRequests; + this.windowMs = windowMs; + this.requests = new Map(); + } + + /** + * Check if a request is allowed + * @param {string} key - Identifier for the requester + * @returns {{ allowed: boolean, remaining: number, resetMs: number }} + */ + check(key) { + const now = Date.now(); + const windowStart = now - this.windowMs; + + // Clean up old entries + const entries = this.requests.get(key) || []; + const validEntries = entries.filter((timestamp) => timestamp > windowStart); + + if (validEntries.length >= this.maxRequests) { + const oldestValid = Math.min(...validEntries); + return { + allowed: false, + remaining: 0, + resetMs: oldestValid + this.windowMs - now, + }; + } + + validEntries.push(now); + this.requests.set(key, validEntries); + + return { + allowed: true, + remaining: this.maxRequests - validEntries.length, + resetMs: this.windowMs, + }; + } + + /** + * Reset rate limit for a key + * @param {string} key - Identifier to reset + */ + reset(key) { + this.requests.delete(key); + } +} + +export default { + sanitizeUrl, + sanitizePath, + sanitizeHtml, + sanitizeShellArg, + sanitizeDesignUrl, + sanitizeJson, + RateLimiter, +}; diff --git a/templates/shared/security-headers.config.js b/templates/shared/security-headers.config.js new file mode 100644 index 0000000..110464e --- /dev/null +++ b/templates/shared/security-headers.config.js @@ -0,0 +1,257 @@ +/** + * security-headers.config.js — Security headers configuration for React apps + * + * This file provides CSP and other security headers for different frameworks. + * Import and apply these headers in your framework's configuration. + */ + +/** + * Content Security Policy directives + * Customize based on your app's requirements + */ +export const cspDirectives = { + // Default fallback for unlisted directives + 'default-src': ["'self'"], + + // JavaScript sources + 'script-src': [ + "'self'", + // Add 'unsafe-inline' only if absolutely necessary (e.g., for inline scripts) + // "'unsafe-inline'", + // Add 'unsafe-eval' only if required (e.g., for some chart libraries) + // "'unsafe-eval'", + ], + + // CSS sources + 'style-src': [ + "'self'", + "'unsafe-inline'", // Required for most CSS-in-JS solutions and Tailwind + ], + + // Image sources + 'img-src': [ + "'self'", + 'data:', // For inline images and base64 + 'blob:', // For dynamically generated images + 'https:', // Allow all HTTPS images + ], + + // Font sources + 'font-src': [ + "'self'", + 'data:', // For inline fonts + ], + + // API and fetch sources + 'connect-src': [ + "'self'", + // Add your API endpoints here + // 'https://api.example.com', + ], + + // Media sources (video, audio) + 'media-src': ["'self'"], + + // Object, embed, applet sources (deprecated technologies) + 'object-src': ["'none'"], + + // Frame ancestors (who can embed this page) + 'frame-ancestors': ["'self'"], + + // Form action targets + 'form-action': ["'self'"], + + // Base URI restriction + 'base-uri': ["'self'"], + + // Upgrade insecure requests + 'upgrade-insecure-requests': [], +}; + +/** + * Additional security headers + */ +export const securityHeaders = { + // Prevent MIME type sniffing + 'X-Content-Type-Options': 'nosniff', + + // Prevent clickjacking + 'X-Frame-Options': 'SAMEORIGIN', + + // Enable XSS filter (legacy browsers) + 'X-XSS-Protection': '1; mode=block', + + // Control referrer information + 'Referrer-Policy': 'strict-origin-when-cross-origin', + + // Permissions Policy (formerly Feature Policy) + 'Permissions-Policy': [ + 'camera=()', + 'microphone=()', + 'geolocation=()', + 'payment=()', + 'usb=()', + 'magnetometer=()', + 'gyroscope=()', + 'accelerometer=()', + ].join(', '), + + // Strict Transport Security (HTTPS only) + 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload', + + // Cross-Origin policies + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', + 'Cross-Origin-Resource-Policy': 'same-origin', +}; + +/** + * Build CSP header string from directives + * @param {Object} directives - CSP directives object + * @returns {string} - CSP header value + */ +export function buildCspHeader(directives = cspDirectives) { + return Object.entries(directives) + .filter(([, values]) => values.length >= 0) + .map(([directive, values]) => { + if (values.length === 0) { + return directive; + } + return `${directive} ${values.join(' ')}`; + }) + .join('; '); +} + +/** + * Next.js security headers configuration + * Add to next.config.js: headers: async () => nextjsSecurityHeaders + */ +export const nextjsSecurityHeaders = [ + { + source: '/:path*', + headers: [ + { + key: 'Content-Security-Policy', + value: buildCspHeader(), + }, + ...Object.entries(securityHeaders).map(([key, value]) => ({ + key, + value, + })), + ], + }, +]; + +/** + * Vite/Express middleware for security headers + * @param {Request} req - Express request + * @param {Response} res - Express response + * @param {Function} next - Next middleware + */ +export function securityHeadersMiddleware(req, res, next) { + res.setHeader('Content-Security-Policy', buildCspHeader()); + + for (const [key, value] of Object.entries(securityHeaders)) { + res.setHeader(key, value); + } + + next(); +} + +/** + * Helmet.js compatible configuration + * Use with: app.use(helmet(helmetConfig)) + */ +export const helmetConfig = { + contentSecurityPolicy: { + directives: Object.fromEntries( + Object.entries(cspDirectives).map(([key, values]) => [ + key.replace(/-([a-z])/g, (_, c) => c.toUpperCase()), + values.length === 0 ? true : values, + ]) + ), + }, + crossOriginEmbedderPolicy: true, + crossOriginOpenerPolicy: { policy: 'same-origin' }, + crossOriginResourcePolicy: { policy: 'same-origin' }, + dnsPrefetchControl: { allow: false }, + frameguard: { action: 'sameorigin' }, + hidePoweredBy: true, + hsts: { + maxAge: 31536000, + includeSubDomains: true, + preload: true, + }, + ieNoOpen: true, + noSniff: true, + originAgentCluster: true, + permittedCrossDomainPolicies: { permittedPolicies: 'none' }, + referrerPolicy: { policy: 'strict-origin-when-cross-origin' }, + xssFilter: true, +}; + +/** + * Development-friendly CSP (more permissive for hot reload, etc.) + */ +export const devCspDirectives = { + ...cspDirectives, + 'script-src': [ + "'self'", + "'unsafe-inline'", + "'unsafe-eval'", // Required for HMR + ], + 'connect-src': [ + "'self'", + 'ws:', // WebSocket for HMR + 'wss:', + 'http://localhost:*', + 'ws://localhost:*', + ], +}; + +/** + * Report-only CSP for testing (logs violations without blocking) + * @param {string} reportUri - Endpoint to receive CSP violation reports + */ +export function buildReportOnlyCsp(reportUri = '/api/csp-report') { + const directives = { + ...cspDirectives, + 'report-uri': [reportUri], + }; + return buildCspHeader(directives); +} + +/** + * CSP nonce generator for inline scripts + * Use with script tags: + */ +export function generateCspNonce() { + const array = new Uint8Array(16); + crypto.getRandomValues(array); + return Buffer.from(array).toString('base64'); +} + +/** + * Add nonce to CSP directives + * @param {string} nonce - The generated nonce + */ +export function getCspWithNonce(nonce) { + return { + ...cspDirectives, + 'script-src': [...cspDirectives['script-src'], `'nonce-${nonce}'`], + 'style-src': [...cspDirectives['style-src'], `'nonce-${nonce}'`], + }; +} + +export default { + cspDirectives, + securityHeaders, + buildCspHeader, + nextjsSecurityHeaders, + securityHeadersMiddleware, + helmetConfig, + devCspDirectives, + buildReportOnlyCsp, + generateCspNonce, + getCspWithNonce, +};