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';
|
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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user