fix(ops): security headers (CSP / XFO / HSTS / etc) + website_submissions retention

Two audit-pass-#3 prod-readiness gaps.

Security headers
  next.config.ts now emits CSP, X-Frame-Options=DENY,
  X-Content-Type-Options=nosniff, Referrer-Policy, Permissions-Policy
  on every response, plus HSTS in production. CSP allows the small
  set of inline-style/inline-script + unsafe-eval (dev-only) needed
  by Tailwind, Radix, and Next dev HMR; img-src/connect-src kept
  reasonably wide for s3.portnimara.com branding + Socket.IO. Verified
  via curl -I that headers ship and that the dashboard route still
  serves correctly.

website_submissions retention
  Adds 'website-submissions-retention' case to the maintenance worker
  with a 180-day window and schedules it at 07:00 daily. Raw inquiry
  payloads include reCAPTCHA + IP + UA metadata; keeping them
  indefinitely was a privacy + storage gap that audit-pass-#3 flagged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-06 15:16:47 +02:00
parent 8690352c56
commit f10334683d
3 changed files with 76 additions and 0 deletions

View File

@@ -1,5 +1,49 @@
import type { NextConfig } from 'next'; import type { NextConfig } from 'next';
const isProd = process.env.NODE_ENV === 'production';
/**
* Security headers applied to every response. Per audit-pass-#3 finding:
* the previous config emitted no CSP, X-Frame-Options, HSTS, or
* X-Content-Type-Options — the app was open to clickjacking + MIME
* sniffing.
*
* CSP notes:
* - 'unsafe-inline' on style-src is required by Tailwind's runtime
* style injection and Radix; revisit when Tailwind v4 ships a
* nonce story.
* - 'unsafe-eval' on script-src is dev-only — Next dev uses eval for
* HMR. Production drops it.
* - connect-src allows ws/wss for Socket.IO and https: for outgoing
* fetches; tighten in prod via per-port branding URLs once we move
* the s3 image references into a known allowlist.
* - img-src https: is wide because port branding pulls from
* s3.portnimara.com plus per-port image URLs configured at runtime.
*/
const csp = [
"default-src 'self'",
`script-src 'self' 'unsafe-inline'${isProd ? '' : " 'unsafe-eval'"}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"font-src 'self' data:",
"connect-src 'self' ws: wss: https:",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
].join('; ');
const securityHeaders = [
{ key: 'Content-Security-Policy', value: csp },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(self), microphone=(), geolocation=()' },
...(isProd
? [{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' }]
: []),
];
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'standalone', output: 'standalone',
serverExternalPackages: [ serverExternalPackages: [
@@ -24,6 +68,14 @@ const nextConfig: NextConfig = {
// process.cwd() requires the file to be traced explicitly. // process.cwd() requires the file to be traced explicitly.
'/api/v1/document-templates/**': ['./assets/eoi-template.pdf'], '/api/v1/document-templates/**': ['./assets/eoi-template.pdf'],
}, },
async headers() {
return [
{
source: '/:path*',
headers: securityHeaders,
},
];
},
}; };
export default nextConfig; export default nextConfig;

View File

@@ -59,6 +59,8 @@ export async function registerRecurringJobs(): Promise<void> {
{ queue: 'maintenance', name: 'ai-usage-retention', pattern: '0 5 * * *' }, { queue: 'maintenance', name: 'ai-usage-retention', pattern: '0 5 * * *' },
// Migration 0040 contract: error_events older than 90 days get pruned. // Migration 0040 contract: error_events older than 90 days get pruned.
{ queue: 'maintenance', name: 'error-events-retention', pattern: '0 6 * * *' }, { queue: 'maintenance', name: 'error-events-retention', pattern: '0 6 * * *' },
// Raw website inquiry payloads — 180-day retention.
{ queue: 'maintenance', name: 'website-submissions-retention', pattern: '0 7 * * *' },
]; ];
for (const job of recurring) { for (const job of recurring) {

View File

@@ -8,6 +8,7 @@ import { formSubmissions } from '@/lib/db/schema/documents';
import { gdprExports } from '@/lib/db/schema/gdpr'; import { gdprExports } from '@/lib/db/schema/gdpr';
import { aiUsageLedger } from '@/lib/db/schema/ai-usage'; import { aiUsageLedger } from '@/lib/db/schema/ai-usage';
import { errorEvents } from '@/lib/db/schema/system'; import { errorEvents } from '@/lib/db/schema/system';
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { getStorageBackend } from '@/lib/storage'; import { getStorageBackend } from '@/lib/storage';
import { QUEUE_CONFIGS } from '@/lib/queue'; import { QUEUE_CONFIGS } from '@/lib/queue';
@@ -17,6 +18,10 @@ const AI_USAGE_RETENTION_DAYS = 90;
/** error_events rows older than this are pruned. Migration 0040 declares /** error_events rows older than this are pruned. Migration 0040 declares
* this contract; the worker had no implementation until now. */ * this contract; the worker had no implementation until now. */
const ERROR_EVENTS_RETENTION_DAYS = 90; const ERROR_EVENTS_RETENTION_DAYS = 90;
/** Raw website inquiry payloads (website_submissions) — kept long enough
* to investigate "why didn't this lead reach the CRM" inbound questions
* but not indefinitely. 180d aligns with the typical sales cycle. */
const WEBSITE_SUBMISSIONS_RETENTION_DAYS = 180;
export const maintenanceWorker = new Worker( export const maintenanceWorker = new Worker(
'maintenance', 'maintenance',
@@ -133,6 +138,23 @@ export const maintenanceWorker = new Worker(
); );
break; break;
} }
case 'website-submissions-retention': {
// Raw inquiry payloads from the marketing-site dual-write. Keep
// long enough to debug capture issues but not forever — these
// rows include reCAPTCHA + IP + UA metadata.
const cutoff = new Date(
Date.now() - WEBSITE_SUBMISSIONS_RETENTION_DAYS * 24 * 60 * 60 * 1000,
);
const result = await db
.delete(websiteSubmissions)
.where(lt(websiteSubmissions.receivedAt, cutoff))
.returning({ id: websiteSubmissions.id });
logger.info(
{ deleted: result.length, retentionDays: WEBSITE_SUBMISSIONS_RETENTION_DAYS },
'Website submissions retention sweep complete',
);
break;
}
default: default:
logger.warn({ jobName: job.name }, 'Unknown maintenance job'); logger.warn({ jobName: job.name }, 'Unknown maintenance job');
} }