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:
93
src/app/api/auth/resolve-identifier/route.ts
Normal file
93
src/app/api/auth/resolve-identifier/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user