Härtung: Login-Drosselung je IP/Konto, Mail-Anhangfehler ohne Retry, doppeltes Upload-Audit

- rate-limit: neuer Scope login (20/15 min, LOGIN_RATE_LIMIT_PER_15_MIN) je IP und
  je Konto; geprüft in verifyIdentityPassword (Login-Seite + Credentials-Provider),
  gedrosselt verhält sich wie Fehlanmeldung (generisch, konstante Laufzeit) – L10a
- mail/worker: MailAttachmentError wie MailNotConfiguredError unrecoverable
  (fremder Mandant/Prüfsumme/Größe ändern sich nicht durch Warten) – L11
- field/uploads: Dokument-Audit nur noch in storeFile (vorher doppelt) – L10a
- Test test-login-rate-limit

Gate 65/65 grün; komplette Suite mit RLS_ENFORCED=true 65/65 grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 18:59:47 +02:00
co-authored by Claude Opus 5
parent 40c3e8e46b
commit ef2f3f16c6
5 changed files with 79 additions and 12 deletions
+10
View File
@@ -202,6 +202,16 @@ export async function verifyIdentityPassword(
password: string,
): Promise<{ identityId: string; mfaRequired: boolean } | null> {
assertSecureEnv();
// Throttle per IP and per account before touching the identity (password spraying, F-05 follow-up).
// Limited attempts look exactly like a failed login: generic result, constant-time dummy hash.
const { clientIp } = await import("@/server/auth-selfservice");
const { checkRateLimit } = await import("@/server/rate-limit");
const throttle = checkRateLimit("login", { ip: await clientIp(), account: email.toLowerCase() });
if (!throttle.allowed) {
await verifyPassword(await dummyHash(), password);
console.warn(`[auth] login rate limited (retry after ${throttle.retryAfterSeconds}s)`);
return null;
}
const identity = await prisma.identity.findUnique({ where: { email: email.toLowerCase() } });
if (!identity || identity.status !== "ACTIVE") {
await verifyPassword(await dummyHash(), password); // konstante Laufzeit (F-05)
+4 -3
View File
@@ -1,5 +1,5 @@
import { UnrecoverableError, Worker, type Job } from "bullmq";
import { deliverMail, markMailFailed, MailNotConfiguredError } from "./deliver";
import { deliverMail, markMailFailed, MailAttachmentError, MailNotConfiguredError } from "./deliver";
import { closeQueues, getConnection, getDeadLetterQueue, getSchedulerQueue } from "./queue";
import { DUE_REMINDER_JOB, MAIL_QUEUE, SCHEDULER_QUEUE, type MailJob } from "./job";
import { closeMailProvider } from "./provider-smtp";
@@ -34,8 +34,9 @@ export function startMailWorker(): Worker<MailJob> {
const { messageId } = await deliverMail(job.data);
return { messageId };
} catch (err) {
if (err instanceof MailNotConfiguredError) {
// Nicht wiederholen — die Konfiguration ändert sich nicht durch Warten.
if (err instanceof MailNotConfiguredError || err instanceof MailAttachmentError) {
// Nicht wiederholen — Konfiguration bzw. ein unzulässiger/fehlender Anhang
// (fremder Mandant, Prüfsumme, Größe) ändern sich nicht durch Warten.
// BullMQ bricht die Retry-Kette bei UnrecoverableError sofort ab.
throw new UnrecoverableError(err.message);
}
+2
View File
@@ -50,6 +50,8 @@ function perMinute(name: string, fallback: number): number {
export const RATE_LIMITS = {
/** Reset-Anfrage: 5 pro Stunde je IP und je Konto. */
passwordResetRequest: { limit: 5, windowMs: 60 * 60_000 },
/** Login (Passwortprüfung): je IP und je Konto – bremst Password-Spraying über viele Konten. */
login: { limit: perMinute("LOGIN_RATE_LIMIT_PER_15_MIN", 20), windowMs: 15 * 60_000 },
/** Reset-Einlösung: begrenzt das Durchprobieren manipulierter Links. */
passwordResetRedeem: { limit: 10, windowMs: 15 * 60_000 },
/** Alt-Passwort-Prüfung bei Selbständerung. */
+2 -9
View File
@@ -3,7 +3,7 @@ import { storage } from "@/server/storage/adapter";
import { dispatchJob } from "@/server/jobs/dispatch";
import { JOB_QUEUES } from "@/server/jobs/queues";
import { ServiceError, type ServiceCtx } from "@/server/services/context";
import { audit, isUniqueViolation, requireFieldOrder } from "./common";
import { isUniqueViolation, requireFieldOrder } from "./common";
import { storeFile } from "@/server/services/documents/store";
import { sniffMime } from "./mime";
@@ -91,13 +91,6 @@ export async function storeFieldUpload(
}
}
await audit(ctx, "create", "document", doc.id, null, {
workOrderId: wo.id,
category: doc.category,
fileName: doc.fileName,
mimeType: doc.mimeType,
fileSize: doc.fileSize,
checksum: doc.checksum,
});
// The document `create` audit is written by services/documents/store.ts#storeFile.
return { documentId: doc.id, duplicate: false };
}