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:
@@ -1,5 +1,49 @@
|
||||
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 = {
|
||||
output: 'standalone',
|
||||
serverExternalPackages: [
|
||||
@@ -24,6 +68,14 @@ const nextConfig: NextConfig = {
|
||||
// process.cwd() requires the file to be traced explicitly.
|
||||
'/api/v1/document-templates/**': ['./assets/eoi-template.pdf'],
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/:path*',
|
||||
headers: securityHeaders,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -59,6 +59,8 @@ export async function registerRecurringJobs(): Promise<void> {
|
||||
{ queue: 'maintenance', name: 'ai-usage-retention', pattern: '0 5 * * *' },
|
||||
// Migration 0040 contract: error_events older than 90 days get pruned.
|
||||
{ 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) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { formSubmissions } from '@/lib/db/schema/documents';
|
||||
import { gdprExports } from '@/lib/db/schema/gdpr';
|
||||
import { aiUsageLedger } from '@/lib/db/schema/ai-usage';
|
||||
import { errorEvents } from '@/lib/db/schema/system';
|
||||
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStorageBackend } from '@/lib/storage';
|
||||
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
|
||||
* this contract; the worker had no implementation until now. */
|
||||
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(
|
||||
'maintenance',
|
||||
@@ -133,6 +138,23 @@ export const maintenanceWorker = new Worker(
|
||||
);
|
||||
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:
|
||||
logger.warn({ jobName: job.name }, 'Unknown maintenance job');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user