trails/apps/journal/app/routes/api.auth.login.ts
Ullrich Schäfer 769d1b5d31
security: log redaction, magic-link enumeration, federation doc cap
Three Low/Info hardening items from the 2026-06-10 security review.

OAuth/credential log redaction:
- oauth-flow.server.ts logged the raw exception on code-exchange
  failure; a provider error can embed the auth code / token response,
  which would land in logs + Sentry. Log only e.message now.
- manager.markNeedsRelink bounded the provider-supplied reason string
  to 200 chars before logging.

Magic-link account enumeration:
- createMagicToken now returns null (instead of throwing "No account
  found for this email") when no account matches; the login route
  always responds { step: "magic-link-sent" }, minting a token and
  sending mail only for a real account. The public login form can no
  longer be used to probe which emails are registered. Registration's
  email/username "already in use/taken" messages are intentionally
  unchanged — standard signup UX, and the passkey ceremony can't be
  made to fake-succeed.

Federation remote-document size cap:
- assertRemoteDocSize rejects an actor/outbox document over 4 MB once
  serialized, applied in the ingest fetchJson seam. Fedify owns the
  transfer (with its own SSRF + redirect limits) and our poll uses the
  authenticated loader for secure-mode instances, so this is a
  downstream guard on iteration/persistence, complementing the existing
  per-poll item cap.

Tests: assertRemoteDocSize unit cases; a gated (FEDERATION_INTEGRATION=1)
integration test asserting an unsigned POST to /users/:u/inbox is
rejected with 401 — a regression guard for Fedify's signature
verification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 07:52:34 +02:00

112 lines
4.2 KiB
TypeScript

import { data } from "react-router";
import { getOrigin } from "~/lib/config.server";
import type { Route } from "./+types/api.auth.login";
import { startAuthentication, finishAuthentication, createMagicToken, verifyLoginCode } from "~/lib/auth.server";
import { completeAuth } from "~/lib/auth/completion.server";
import { sendMagicLink } from "~/lib/email.server";
import { consumeRateLimit, clientIp } from "~/lib/rate-limit.server";
function tooManyRequests(resetMs: number) {
return data(
{ error: `Too many attempts. Try again in ${Math.ceil(resetMs / 1000)}s.` },
{ status: 429, headers: { "Retry-After": String(Math.ceil(resetMs / 1000)) } },
);
}
export async function action({ request }: Route.ActionArgs) {
const body = await request.json();
const { step, response, challenge, email, code, returnTo } = body;
const ip = clientIp(request);
// Per-IP cap on every auth attempt regardless of step — defends
// against a single attacker fan-out across step types.
const ipLimit = consumeRateLimit({
scope: "auth-attempt-ip",
key: ip,
limit: 30,
windowMs: 60_000,
});
if (!ipLimit.allowed) return tooManyRequests(ipLimit.resetMs);
try {
if (step === "start-passkey") {
const options = await startAuthentication();
return data({ step: "challenge", options });
}
if (step === "finish-passkey") {
// Tighter per-IP cap on credential-consuming steps — passkey
// assertions don't usefully retry that fast.
const verify = consumeRateLimit({
scope: "passkey-verify",
key: ip,
limit: 10,
windowMs: 60_000,
});
if (!verify.allowed) return tooManyRequests(verify.resetMs);
const userId = await finishAuthentication(response, challenge);
return completeAuth({ userId, request, returnTo, mode: "json" });
}
if (step === "magic-link") {
// Cap magic-link generation per email so an attacker can't spam a
// victim's inbox with codes, and per IP so a single client can't
// fan out across many addresses.
if (typeof email === "string" && email.length > 0) {
const perEmail = consumeRateLimit({
scope: "magic-link-email",
key: email.toLowerCase(),
limit: 3,
windowMs: 5 * 60_000,
});
if (!perEmail.allowed) return tooManyRequests(perEmail.resetMs);
}
const perIp = consumeRateLimit({
scope: "magic-link-ip",
key: ip,
limit: 10,
windowMs: 5 * 60_000,
});
if (!perIp.allowed) return tooManyRequests(perIp.resetMs);
// Always respond "sent" regardless of whether the account exists,
// so the response can't probe which emails are registered. A token
// is only minted (and mail only sent) for a real account.
const minted = await createMagicToken(email);
if (minted) {
const origin = getOrigin();
const link = `${origin}/auth/verify?token=${minted.token}`;
// In dev, return the link and code directly for a real account.
if (process.env.NODE_ENV !== "production") {
console.log(`[Magic Link] ${email}: ${link} (code: ${minted.code})`);
return data({ step: "magic-link-sent", devLink: link, code: minted.code });
}
await sendMagicLink(email, link, minted.code);
}
return data({ step: "magic-link-sent" });
}
if (step === "verify-code") {
if (!email || !code) {
return data({ error: "Email and code are required" }, { status: 400 });
}
// Per-email cap to defeat 6-digit code brute force — 10 attempts
// per 15 minutes gives legitimate users plenty of room while making
// a brute-force search (~10^6 codes) infeasible before the code
// expires.
const codeLimit = consumeRateLimit({
scope: "verify-code",
key: String(email).toLowerCase(),
limit: 10,
windowMs: 15 * 60_000,
});
if (!codeLimit.allowed) return tooManyRequests(codeLimit.resetMs);
const userId = await verifyLoginCode(email, code);
return completeAuth({ userId, request, returnTo, mode: "json" });
}
return data({ error: "Invalid step" }, { status: 400 });
} catch (e) {
return data({ error: (e as Error).message }, { status: 400 });
}
}