Compare commits

...
2 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5.5 5a88f3dcd6 fix: Client-IP haerten (CF-Connecting-IP, X-Real-IP, Admin-Session)
Docker-Images bauen und veroeffentlichen / build-and-push (push) Successful in 8m8s
- CF-Connecting-IP gilt nur noch als oeffentliche Unicast-Adresse
  (nicht 0/8, 10/8, 100.64/10, 127/8, 169.254/16, 172.16/12,
  192.168/16, 240/4, Broadcast, ::, ::1, fc00::/7, fe80::/10,
  ::ffff:privat); sonst gilt die Gegenstelle.
- X-Real-IP ohne X-Forwarded-For nur noch ausserhalb von Produktion;
  in Produktion ergibt das "unknown".
- Neuer Helfer headersWithClientIp, von withClientIp und der
  Admin-Session-Pruefung genutzt: ein vom Client gesendeter
  x-artista-client-ip-Wert wird nie geglaubt.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:26:56 +02:00
Nick SzaunigandClaude Opus 5.5 34ccbc3b9a fix: SMTP-Fehler nur bei Empfaengerablehnung endgueltig
Bisher setzte jeder SMTP-5xx die Mail sofort auf FAILED, auch Anmelde-
und Richtlinienfehler (EAUTH, EMESSAGE, 552). Ein kaputtes SMTP-Secret
haette alle Buchungs- und Zahlungsmails verworfen, ohne Rueckweg
(dedupeKey verhindert erneutes Einreihen).

Endgueltig ist jetzt nur EENVELOPE mit 550, 551, 553, 556 oder 5xx mit
Status 5.1.x. Alles andere bleibt PENDING und wird bis
MAX_EMAIL_ATTEMPTS wiederholt.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:20:34 +02:00
5 changed files with 127 additions and 15 deletions

No files matched your search

+2 -1
View File
@@ -7,6 +7,7 @@ import { headers } from "next/headers";
import { redirect } from "next/navigation";
import type { Actor } from "./audit";
import { getAuth } from "./auth";
import { headersWithClientIp } from "./rate-limit";
export type AdminUser = { id: string; email: string; name: string; twoFactorEnabled: boolean };
@@ -15,7 +16,7 @@ async function currentAdmin(): Promise<AdminUser | null> {
// Sonst versucht "next build" sie vorab zu rendern und scheitert ohne
// Secrets im Container (getAuth() braucht BETTER_AUTH_SECRET).
const requestHeaders = await headers();
const session = await getAuth().api.getSession({ headers: requestHeaders });
const session = await getAuth().api.getSession({ headers: headersWithClientIp(requestHeaders) });
if (!session) return null;
const user = session.user as typeof session.user & { twoFactorEnabled?: boolean | null };
return { id: user.id, email: user.email, name: user.name, twoFactorEnabled: Boolean(user.twoFactorEnabled) };
+12 -5
View File
@@ -85,14 +85,21 @@ async function deliver(message: { id: string; toAddress: string; subject: string
});
}
const RECIPIENT_REJECTION_CODES = new Set([550, 551, 553, 556]);
/**
* SMTP-Antworten 5xx sind endgueltig (z. B. Empfaenger abgelehnt, Domain ohne
* Mailserver): erneutes Senden aendert nichts und liesse jeden Worker-Lauf
* scheitern. 4xx und Verbindungsfehler bleiben wiederholbar.
* Endgueltig ist nur eine Ablehnung des Empfaengers (nodemailer EENVELOPE, RCPT TO):
* 550/551/553/556 oder 5xx mit Status 5.1.x (Adresse/Domain falsch). Alles andere
* (Anmeldung EAUTH, Richtlinie/Blocklist EMESSAGE, 552, 4xx, Verbindung) liegt am
* Server oder Absender; die Mail bleibt PENDING und wird bis MAX_EMAIL_ATTEMPTS
* wiederholt, damit ein kaputtes SMTP-Secret keine Mails endgueltig verwirft.
*/
export function isPermanentMailError(error: unknown): boolean {
const code = typeof error === "object" && error !== null ? (error as { responseCode?: unknown }).responseCode : undefined;
return typeof code === "number" && code >= 500 && code < 600;
if (typeof error !== "object" || error === null) return false;
const { code, responseCode, response } = error as { code?: unknown; responseCode?: unknown; response?: unknown };
if (code !== "EENVELOPE" || typeof responseCode !== "number") return false;
if (RECIPIENT_REJECTION_CODES.has(responseCode)) return true;
return responseCode >= 500 && responseCode < 600 && typeof response === "string" && /(^|\s)5\.1\.\d+(\s|$)/.test(response);
}
/** SMTP-Fehlermeldungen nennen den Empfaenger; ins Log und in lastError kommt er nicht. */
+31 -5
View File
@@ -67,6 +67,26 @@ const CLOUDFLARE: [string, number, "ipv4" | "ipv6"][] = [
const cloudflare = new BlockList();
for (const [net, prefix, family] of CLOUDFLARE) cloudflare.addSubnet(net, prefix, family);
/** Nicht oeffentliche Bereiche; 240/4 nutzt Cloudflare fuer "Pseudo IPv4". */
const NON_PUBLIC_V4 = ["0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16", "172.16.0.0/12", "192.168.0.0/16", "240.0.0.0/4"];
const nonPublic = new BlockList();
for (const cidr of NON_PUBLIC_V4) nonPublic.addSubnet(cidr.split("/")[0]!, Number(cidr.split("/")[1]), "ipv4");
nonPublic.addAddress("255.255.255.255", "ipv4");
nonPublic.addAddress("::", "ipv6");
nonPublic.addAddress("::1", "ipv6");
nonPublic.addSubnet("fc00::", 7, "ipv6");
nonPublic.addSubnet("fe80::", 10, "ipv6");
function isPublicUnicast(ip: string): boolean {
if (isIP(ip) === 4) return !nonPublic.check(ip, "ipv4");
const groups = ipv6Groups(ip);
if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff) {
const [high = 0, low = 0] = groups.slice(6);
return !nonPublic.check([high >> 8, high & 0xff, low >> 8, low & 0xff].join("."), "ipv4");
}
return !nonPublic.check(ip, "ipv6");
}
function validIp(value: string | null | undefined): string | null {
const ip = value?.trim();
return ip && isIP(ip) ? ip : null;
@@ -82,10 +102,11 @@ function validIp(value: string | null | undefined): string | null {
*/
export function clientIp(headers: Headers): string {
const forwarded = headers.get("x-forwarded-for");
const peer = forwarded === null ? validIp(headers.get("x-real-ip")) : validIp(forwarded.split(",").at(-1));
const realIp = process.env.NODE_ENV === "production" ? null : validIp(headers.get("x-real-ip"));
const peer = forwarded === null ? realIp : validIp(forwarded.split(",").at(-1));
if (!peer) return "unknown";
const viaCloudflare = validIp(headers.get("cf-connecting-ip"));
return viaCloudflare && cloudflare.check(peer, isIP(peer) === 6 ? "ipv6" : "ipv4") ? viaCloudflare : peer;
return viaCloudflare && isPublicUnicast(viaCloudflare) && cloudflare.check(peer, isIP(peer) === 6 ? "ipv6" : "ipv4") ? viaCloudflare : peer;
}
/** Die acht 16-Bit-Gruppen einer gueltigen IPv6-Adresse; eine IPv4-Endung (::ffff:a.b.c.d) wird umgerechnet. */
@@ -119,9 +140,14 @@ export function rateLimitSubject(ip: string): string {
return `${groups.slice(0, 4).map((group) => group.toString(16)).join(":")}::/64`;
}
/** Kopie der Header mit CLIENT_IP_HEADER aus clientIp(); ein mitgeschickter Wert wird ersetzt. */
export function headersWithClientIp(source: Headers): Headers {
const headers = new Headers(source);
headers.set(CLIENT_IP_HEADER, clientIp(source));
return headers;
}
/** Kopie der Anfrage mit CLIENT_IP_HEADER aus clientIp(); ein mitgeschickter Wert wird ersetzt. */
export function withClientIp(request: Request): Request {
const headers = new Headers(request.headers);
headers.set(CLIENT_IP_HEADER, clientIp(request.headers));
return new Request(request, { headers });
return new Request(request, { headers: headersWithClientIp(request.headers) });
}
+46 -2
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
import { CLIENT_IP_HEADER, clientIp, rateLimitSubject, withClientIp } from "@/lib/rate-limit";
import { afterEach, describe, expect, test, vi } from "vitest";
import { CLIENT_IP_HEADER, clientIp, headersWithClientIp, rateLimitSubject, withClientIp } from "@/lib/rate-limit";
const h = (values: Record<string, string>) => new Headers(values);
@@ -42,3 +42,47 @@ describe("clientIp hinter Traefik", () => {
expect(withClientIp(request).headers.get(CLIENT_IP_HEADER)).toBe("198.51.100.9");
});
});
describe("CF-Connecting-IP nur als öffentliche Unicast-Adresse", () => {
const edge = "172.70.1.2";
const viaEdge = (cf: string) => clientIp(h({ "x-forwarded-for": `1.2.3.4, ${edge}`, "cf-connecting-ip": cf }));
test.each([
"0.1.2.3", "10.0.0.5", "100.64.0.1", "100.127.255.255", "127.0.0.1", "169.254.1.1", "172.16.0.1", "172.31.255.255",
"192.168.1.1", "240.0.0.1", "255.255.255.255", "::", "::1", "fc00::1", "fd12::1", "fe80::1", "::ffff:10.0.0.1", "::ffff:192.168.0.1",
])("%s wird verworfen, die Gegenstelle gilt", (cf) => {
expect(viaEdge(cf)).toBe(edge);
});
test.each(["203.0.113.7", "100.63.255.255", "172.32.0.1", "2001:db8::1", "::ffff:203.0.113.7"])("%s wird übernommen", (cf) => {
expect(viaEdge(cf)).toBe(cf);
});
});
describe("X-Real-IP in Produktion", () => {
afterEach(() => vi.unstubAllEnvs());
test("ohne X-Forwarded-For ergibt X-Real-IP in Produktion „unknown“", () => {
vi.stubEnv("NODE_ENV", "production");
expect(clientIp(h({ "x-real-ip": "198.51.100.9" }))).toBe("unknown");
expect(clientIp(h({ "x-forwarded-for": "198.51.100.9" }))).toBe("198.51.100.9");
});
test("außerhalb von Produktion gilt X-Real-IP weiter", () => {
vi.stubEnv("NODE_ENV", "development");
expect(clientIp(h({ "x-real-ip": "198.51.100.9" }))).toBe("198.51.100.9");
});
});
describe("headersWithClientIp", () => {
test("überschreibt einen mitgeschickten Wert und lässt das Original unverändert", () => {
const original = h({ "x-forwarded-for": "1.2.3.4, 198.51.100.9", [CLIENT_IP_HEADER]: "1.2.3.4" });
const result = headersWithClientIp(original);
expect(result.get(CLIENT_IP_HEADER)).toBe("198.51.100.9");
expect(original.get(CLIENT_IP_HEADER)).toBe("1.2.3.4");
});
test("ohne ermittelbare IP steht „unknown“ im Header, nie der Clientwert", () => {
expect(headersWithClientIp(h({ [CLIENT_IP_HEADER]: "1.2.3.4" })).get(CLIENT_IP_HEADER)).toBe("unknown");
});
});
+36 -2
View File
@@ -13,8 +13,8 @@ vi.mock("nodemailer", async (importOriginal) => {
return { ...actual, default: { ...actual.default, createTransport } };
});
function smtpError(message: string, responseCode?: number) {
return Object.assign(new Error(message), { code: "EENVELOPE", responseCode });
function smtpError(message: string, responseCode?: number, code = "EENVELOPE") {
return Object.assign(new Error(message), { code, responseCode });
}
async function queueMail(to = "[email protected]") {
@@ -79,4 +79,38 @@ describe("Postausgang: Fehler beim SMTP-Versand", () => {
expect(await db.emailMessage.findUniqueOrThrow({ where: { id } })).toMatchObject({ status: "PENDING", attempts: 1 });
}
});
test("Server- und Absenderfehler (Anmeldung, Richtlinie) bleiben PENDING statt FAILED", async () => {
const auth = await queueMail("[email protected]");
const policy = await queueMail("[email protected]");
sendMail
.mockRejectedValueOnce(smtpError("535 5.7.8 Authentication failed", 535, "EAUTH"))
.mockRejectedValueOnce(smtpError("554 5.7.1 Message rejected as spam", 554, "EMESSAGE"));
vi.spyOn(console, "error").mockImplementation(() => {});
expect(await flushEmails(db)).toEqual({ sent: 0, failed: 2 });
for (const id of [auth.id, policy.id]) {
expect(await db.emailMessage.findUniqueOrThrow({ where: { id } })).toMatchObject({ status: "PENDING", attempts: 1 });
}
});
test("EENVELOPE mit 550/551/553/556 oder 5.1.x ist endgueltig, 552 und 4xx nicht", async () => {
const cases: [number, string, boolean][] = [
[550, "550 5.1.1 User unknown", true],
[551, "551 User not local", true],
[553, "553 Mailbox name not allowed", true],
[554, "554 5.1.2 Bad destination system address", true],
[552, "552 Mailbox full", false],
[451, "451 4.1.1 Try later", false],
];
vi.spyOn(console, "error").mockImplementation(() => {});
for (const [index, [code, response, permanent]] of cases.entries()) {
await resetDb();
const mail = await queueMail(`r${index}@example.org`);
sendMail.mockRejectedValueOnce(Object.assign(smtpError(response, code), { response }));
await flushEmails(db);
const after = await db.emailMessage.findUniqueOrThrow({ where: { id: mail.id } });
expect(after.status, response).toBe(permanent ? "FAILED" : "PENDING");
}
});
});