audit: Tier 0 quick wins — EMAIL_REDIRECT_TO prod guard + storage routing + metadata masking

Tier 0.2: src/lib/env.ts now refuses boot when NODE_ENV=production AND
EMAIL_REDIRECT_TO is set. Sendmail logs the rewrite at warn (was debug)
so dev/staging windows where someone forgets to unset are immediately
visible.

Tier 0.6: backup_jobs.storage_path added to TABLES_WITH_STORAGE_KEYS in
src/lib/storage/migrate.ts. Flipping the storage backend used to
silently orphan every pg_dump artefact — last-resort recovery path is
now actually portable.

Tier 1.7: createAuditLog now runs metadata through maskSensitiveFields
(was only applied to old/new value diffs). Portal-auth, crm-invite,
hard-delete and email-accounts services were writing raw emails into
this column unbounded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 17:02:10 +02:00
parent a7b72801be
commit 0baca41693
13 changed files with 297 additions and 249 deletions

View File

@@ -143,7 +143,10 @@ export async function createAuditLog(params: AuditLogParams): Promise<void> {
fieldChanged: params.fieldChanged ?? null,
oldValue: maskSensitiveFields(params.oldValue) ?? null,
newValue: maskSensitiveFields(params.newValue) ?? null,
metadata: params.metadata ?? null,
// Mask metadata too — the audit found portal-auth, crm-invite,
// hard-delete, and email-accounts services were writing raw emails
// into this column.
metadata: maskSensitiveFields(params.metadata) ?? null,
ipAddress: params.ipAddress ?? null,
userAgent: params.userAgent ?? null,
severity,

View File

@@ -151,10 +151,21 @@ export async function sendEmail(
...(resolvedAttachments.length > 0 ? { attachments: resolvedAttachments } : {}),
});
logger.debug(
{ messageId: info.messageId, to: effectiveTo, originalTo: requestedTo, subject, portId },
env.EMAIL_REDIRECT_TO ? 'Email sent (redirected)' : 'Email sent',
);
// When EMAIL_REDIRECT_TO is set we elevate to `warn` so the dev-only
// safety net is visible in any logger config. Prod boot already refuses
// when both are set (see env.ts superRefine) — this catches the dev /
// staging window where someone left it in a .env by mistake.
if (env.EMAIL_REDIRECT_TO) {
logger.warn(
{ messageId: info.messageId, to: effectiveTo, originalTo: requestedTo, subject, portId },
'Email sent (REDIRECTED via EMAIL_REDIRECT_TO — recipient overridden)',
);
} else {
logger.debug(
{ messageId: info.messageId, to: effectiveTo, originalTo: requestedTo, subject, portId },
'Email sent',
);
}
return info;
}

View File

@@ -81,6 +81,21 @@ const envSchema = z.object({
.enum(['true', 'false'])
.default('false')
.transform((v) => v === 'true'),
}).superRefine((env, ctx) => {
// CRITICAL safety net: EMAIL_REDIRECT_TO is a dev/test feature that
// silently rewrites every outbound recipient. Leaving it set in prod
// funnels every customer email (invites, EOIs, portal magic links,
// contracts) to a single inbox. The audit caught this had only a
// `logger.debug` line as forensic trail. Refuse boot when both are
// simultaneously set in production.
if (env.NODE_ENV === 'production' && env.EMAIL_REDIRECT_TO) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['EMAIL_REDIRECT_TO'],
message:
'EMAIL_REDIRECT_TO must NOT be set in production — it silently rewrites every outbound email recipient. Unset it before deploying.',
});
}
});
export type Env = z.infer<typeof envSchema>;

View File

@@ -57,6 +57,10 @@ export const TABLES_WITH_STORAGE_KEYS: StorageKeyTable[] = [
{ table: 'berth_pdf_versions', keyColumn: 'storage_key', pkColumn: 'id' },
{ table: 'brochure_versions', keyColumn: 'storage_key', pkColumn: 'id' },
{ table: 'gdpr_exports', keyColumn: 'storage_key', pkColumn: 'id' },
// Last-resort recovery: pg_dump artefacts from the BackupService. The
// audit caught these were missing — flipping the storage backend used
// to silently orphan every backup, dark-blacking the recovery path.
{ table: 'backup_jobs', keyColumn: 'storage_path', pkColumn: 'id' },
];
const ADVISORY_LOCK_KEY = 0xc7000a01;