audit: 33-agent comprehensive audit + critical fixes

Full team audit run, all reports verbatim in docs/AUDIT-2026-05-12.md
(5900+ lines, 30+ critical findings). Already-fixed this commit:
- permission-overrides PUT: self-target block + RolePermissions allow-list + cross-tenant guard
- /api/auth/resolve-identifier: rate-limit + synthetic miss-email kill enumeration
- admin email-change: rotates account.accountId + revokes sessions
- middleware: token-gated email confirm/cancel routes whitelisted
- NAV_CATALOG: 10 dead-link sweeps to existing /admin/<x> targets

Feature work landing same commit: optional username sign-in
(migration 0054), per-user permission overrides (0055) with three-state
matrix tabbed inside UserForm, user disable button, role + outcome +
stage label normalisation across the platform, admin email-change
with auto-notification template.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 16:52:35 +02:00
parent 660553c074
commit 4b9743a594
31 changed files with 7042 additions and 81 deletions

View File

@@ -0,0 +1,93 @@
/**
* Resolves an email-or-username sign-in identifier to a canonical email
* that Better Auth's email/password flow accepts.
*
* Public endpoint by design — the login form calls it BEFORE the user is
* authenticated, so it can't sit behind `withAuth`.
*
* **Anti-enumeration:** the response shape is identical for hit and
* miss. On a miss we return a synthetic `@auth.invalid` email derived
* from the input so Better Auth's `signIn.email` call fails uniformly
* with "invalid credentials" — an attacker can't tell whether the
* username exists from this endpoint's response. (Previously a miss
* returned the bare input string, which lacked an `@` and was visibly
* different from a hit's real email.)
*
* **Rate limiting:** shares the `auth` bucket (5/15min/ip), so an
* attacker can't iterate a wordlist faster than they could brute-force
* passwords directly.
*/
import { NextResponse, type NextRequest } from 'next/server';
import { sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { user, userProfiles } from '@/lib/db/schema/users';
import { eq } from 'drizzle-orm';
import { checkRateLimit, rateLimitHeaders, rateLimiters } from '@/lib/rate-limit';
const EMAIL_HINT = /@/;
/** Synthetic, definitively-invalid email used for the miss path. The
* `.invalid` TLD is reserved by RFC 2606 — no real domain can use it,
* so a downstream signIn call always fails as "invalid credentials"
* without ever leaking the lookup outcome. */
function syntheticEmail(raw: string): string {
const slug = raw.replace(/[^a-z0-9._-]/gi, '').slice(0, 30) || 'unknown';
return `${slug}@auth.invalid`;
}
function clientIp(req: NextRequest): string {
return (
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
req.headers.get('x-real-ip') ??
'unknown'
);
}
export async function POST(req: NextRequest) {
try {
// Rate-limit on IP — same 5/15min bucket the actual sign-in uses.
// Without this an attacker can wordlist usernames at full HTTP
// bandwidth and only funnel the validated emails into the slower
// signIn flow.
const ip = clientIp(req);
const rl = await checkRateLimit(ip, rateLimiters.auth);
if (!rl.allowed) {
return NextResponse.json(
{ email: '' },
{ status: 429, headers: rateLimitHeaders(rl) },
);
}
const body = (await req.json().catch(() => ({}))) as { identifier?: string };
const raw = (body.identifier ?? '').trim();
if (!raw) return NextResponse.json({ email: syntheticEmail('empty') });
// Looks like an email → already canonical. Hand it straight back.
if (EMAIL_HINT.test(raw)) {
return NextResponse.json({ email: raw });
}
// Otherwise treat the input as a username and look up the linked
// Better Auth email. Case-insensitive match against the
// `LOWER(username)` unique index.
const normalized = raw.toLowerCase();
const rows = await db
.select({ email: user.email })
.from(userProfiles)
.innerJoin(user, eq(userProfiles.userId, user.id))
.where(sql`LOWER(${userProfiles.username}) = ${normalized}`)
.limit(1);
if (rows.length === 0) {
// Synthetic `.invalid` email — indistinguishable from a hit in
// shape (has `@`, has a tld), guaranteed to fail downstream auth.
return NextResponse.json({ email: syntheticEmail(normalized) });
}
return NextResponse.json({ email: rows[0]!.email });
} catch {
// Defensive — never expose internals from a public endpoint.
return NextResponse.json({ email: syntheticEmail('error') }, { status: 200 });
}
}