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

@@ -53,10 +53,7 @@ export async function POST(req: NextRequest) {
const ip = clientIp(req);
const rl = await checkRateLimit(ip, rateLimiters.auth);
if (!rl.allowed) {
return NextResponse.json(
{ email: '' },
{ status: 429, headers: rateLimitHeaders(rl) },
);
return NextResponse.json({ email: '' }, { status: 429, headers: rateLimitHeaders(rl) });
}
const body = (await req.json().catch(() => ({}))) as { identifier?: string };

View File

@@ -106,10 +106,7 @@ export const GET = withAuth(
let baseline: RolePermissions | null = null;
if (!profile.isSuperAdmin) {
const portRole = await db.query.userPortRoles.findFirst({
where: and(
eq(userPortRoles.userId, targetUserId),
eq(userPortRoles.portId, portId),
),
where: and(eq(userPortRoles.userId, targetUserId), eq(userPortRoles.portId, portId)),
});
if (portRole) {
const role = await db.query.roles.findFirst({
@@ -171,10 +168,7 @@ export const PUT = withAuth(
// never apply, but it still consumes a unique slot and confuses
// future audits.
const targetPortRole = await db.query.userPortRoles.findFirst({
where: and(
eq(userPortRoles.userId, targetUserId),
eq(userPortRoles.portId, portId),
),
where: and(eq(userPortRoles.userId, targetUserId), eq(userPortRoles.portId, portId)),
});
if (!targetPortRole) {
throw new NotFoundError('User not assigned to this port');

View File

@@ -21,12 +21,7 @@ const updateProfileSchema = z.object({
* Uniqueness is checked below before the UPDATE — collisions surface
* as a 409 with a friendly message.
*/
username: z
.union([
z.string().transform((s) => s.trim().toLowerCase()),
z.null(),
])
.optional(),
username: z.union([z.string().transform((s) => s.trim().toLowerCase()), z.null()]).optional(),
phone: z.string().nullable().optional(),
// Refuse `javascript:` / `data:` schemes — z.string().url() lets them
// through and `<a href={avatarUrl}>` would otherwise be a stored-XSS

View File

@@ -212,150 +212,150 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
</TabsContent>
<TabsContent value="profile" className="mt-4">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="user-first-name">First name</Label>
<Input
id="user-first-name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="Jane"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="user-last-name">Last name</Label>
<Input
id="user-last-name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Doe"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="user-display-name">Display name</Label>
<Input
id="user-display-name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder={fullName || 'Jane Doe'}
required
/>
<p className="text-xs text-muted-foreground">
How this user appears across the app usually their full name, but they can pick a
nickname.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="user-email">Email</Label>
<Input
id="user-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="user@example.com"
required
/>
{isEdit && email.trim().toLowerCase() !== originalEmail.toLowerCase() ? (
<p className="text-xs text-amber-600">
You&apos;ll be asked to confirm the original address will receive an automated
notice that you, the admin, changed their sign-in email.
</p>
) : isEdit ? (
<p className="text-xs text-muted-foreground">
Changing this address is an admin-only override; the user will be notified at the
old address.
</p>
) : null}
</div>
{!isEdit && (
<div className="space-y-2">
<Label htmlFor="user-password">Password</Label>
<Input
id="user-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min 12 characters"
minLength={12}
required
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="user-phone">Phone</Label>
<PhoneInput
id="user-phone"
value={phoneValue}
onChange={setPhoneValue}
placeholder="Phone number"
/>
</div>
<div className="space-y-2">
<Label htmlFor="user-role">Role</Label>
<Select value={roleId} onValueChange={setRoleId} required>
<SelectTrigger id="user-role">
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
{roles.map((r) => (
<SelectItem key={r.id} value={r.id}>
{formatRole(r.name)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label htmlFor="user-residential">Residential access</Label>
<p className="text-xs text-muted-foreground">
Grant this user access to residential clients and interests in addition to their
primary role.
</p>
</div>
<Switch
id="user-residential"
checked={residentialAccess}
onCheckedChange={setResidentialAccess}
/>
</div>
{isEdit && (
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label htmlFor="user-active">Account active</Label>
<p className="text-xs text-muted-foreground">Disabled users cannot sign in.</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="user-first-name">First name</Label>
<Input
id="user-first-name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="Jane"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="user-last-name">Last name</Label>
<Input
id="user-last-name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Doe"
required
/>
</div>
</div>
<Switch id="user-active" checked={isActive} onCheckedChange={setIsActive} />
</div>
)}
{error && <p className="whitespace-pre-line text-sm text-destructive">{error}</p>}
<div className="space-y-2">
<Label htmlFor="user-display-name">Display name</Label>
<Input
id="user-display-name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder={fullName || 'Jane Doe'}
required
/>
<p className="text-xs text-muted-foreground">
How this user appears across the app usually their full name, but they can pick
a nickname.
</p>
</div>
<SheetFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button type="submit" disabled={loading || !displayName.trim() || !roleId}>
{loading ? 'Saving...' : isEdit ? 'Save changes' : 'Create user'}
</Button>
</SheetFooter>
</form>
<div className="space-y-2">
<Label htmlFor="user-email">Email</Label>
<Input
id="user-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="user@example.com"
required
/>
{isEdit && email.trim().toLowerCase() !== originalEmail.toLowerCase() ? (
<p className="text-xs text-amber-600">
You&apos;ll be asked to confirm the original address will receive an automated
notice that you, the admin, changed their sign-in email.
</p>
) : isEdit ? (
<p className="text-xs text-muted-foreground">
Changing this address is an admin-only override; the user will be notified at
the old address.
</p>
) : null}
</div>
{!isEdit && (
<div className="space-y-2">
<Label htmlFor="user-password">Password</Label>
<Input
id="user-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min 12 characters"
minLength={12}
required
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="user-phone">Phone</Label>
<PhoneInput
id="user-phone"
value={phoneValue}
onChange={setPhoneValue}
placeholder="Phone number"
/>
</div>
<div className="space-y-2">
<Label htmlFor="user-role">Role</Label>
<Select value={roleId} onValueChange={setRoleId} required>
<SelectTrigger id="user-role">
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
{roles.map((r) => (
<SelectItem key={r.id} value={r.id}>
{formatRole(r.name)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label htmlFor="user-residential">Residential access</Label>
<p className="text-xs text-muted-foreground">
Grant this user access to residential clients and interests in addition to their
primary role.
</p>
</div>
<Switch
id="user-residential"
checked={residentialAccess}
onCheckedChange={setResidentialAccess}
/>
</div>
{isEdit && (
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label htmlFor="user-active">Account active</Label>
<p className="text-xs text-muted-foreground">Disabled users cannot sign in.</p>
</div>
<Switch id="user-active" checked={isActive} onCheckedChange={setIsActive} />
</div>
)}
{error && <p className="whitespace-pre-line text-sm text-destructive">{error}</p>}
<SheetFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button type="submit" disabled={loading || !displayName.trim() || !roleId}>
{loading ? 'Saving...' : isEdit ? 'Save changes' : 'Create user'}
</Button>
</SheetFooter>
</form>
</TabsContent>
</Tabs>

View File

@@ -2,7 +2,12 @@
import { useEffect, useState } from 'react';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';

View File

@@ -961,8 +961,7 @@ export function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
bucket: 'residentialInterests',
icon: TrendingUp,
label: i.clientName,
sub:
STAGE_LABELS[i.pipelineStage as PipelineStage] ?? i.pipelineStage.replace(/_/g, ' '),
sub: STAGE_LABELS[i.pipelineStage as PipelineStage] ?? i.pipelineStage.replace(/_/g, ' '),
href: `/${portSlug}/residential/interests/${i.id}`,
});
}

View File

@@ -165,7 +165,9 @@ export function UserSettings() {
setOriginalUsername(next ?? '');
setUsername(next ?? '');
setUsernameMsg(
next ? `Username updated. You can now sign in with @${next} or your email.` : 'Username cleared.',
next
? `Username updated. You can now sign in with @${next} or your email.`
: 'Username cleared.',
);
} catch (err: unknown) {
setUsernameMsg(err instanceof Error ? err.message : 'Failed to save username');
@@ -377,11 +379,13 @@ export function UserSettings() {
>
{saving === 'username' ? 'Saving…' : 'Save username'}
</Button>
{usernameMsg && <span className="text-xs text-muted-foreground">{usernameMsg}</span>}
{usernameMsg && (
<span className="text-xs text-muted-foreground">{usernameMsg}</span>
)}
</div>
<p className="text-xs text-muted-foreground">
Optional alias you can use to sign in instead of your email. 230 lowercase
letters, digits, dot, underscore, or hyphen.
Optional alias you can use to sign in instead of your email. 230 lowercase letters,
digits, dot, underscore, or hyphen.
</p>
</div>
<div className="space-y-2 pt-2 border-t">

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;