Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a88f3dcd6 | ||
|
|
34ccbc3b9a |
No files matched your search
@@ -7,6 +7,7 @@ import { headers } from "next/headers";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import type { Actor } from "./audit";
|
import type { Actor } from "./audit";
|
||||||
import { getAuth } from "./auth";
|
import { getAuth } from "./auth";
|
||||||
|
import { headersWithClientIp } from "./rate-limit";
|
||||||
|
|
||||||
export type AdminUser = { id: string; email: string; name: string; twoFactorEnabled: boolean };
|
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
|
// Sonst versucht "next build" sie vorab zu rendern und scheitert ohne
|
||||||
// Secrets im Container (getAuth() braucht BETTER_AUTH_SECRET).
|
// Secrets im Container (getAuth() braucht BETTER_AUTH_SECRET).
|
||||||
const requestHeaders = await headers();
|
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;
|
if (!session) return null;
|
||||||
const user = session.user as typeof session.user & { twoFactorEnabled?: boolean | 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) };
|
return { id: user.id, email: user.email, name: user.name, twoFactorEnabled: Boolean(user.twoFactorEnabled) };
|
||||||
|
|||||||
+12
-5
@@ -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
|
* Endgueltig ist nur eine Ablehnung des Empfaengers (nodemailer EENVELOPE, RCPT TO):
|
||||||
* Mailserver): erneutes Senden aendert nichts und liesse jeden Worker-Lauf
|
* 550/551/553/556 oder 5xx mit Status 5.1.x (Adresse/Domain falsch). Alles andere
|
||||||
* scheitern. 4xx und Verbindungsfehler bleiben wiederholbar.
|
* (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 {
|
export function isPermanentMailError(error: unknown): boolean {
|
||||||
const code = typeof error === "object" && error !== null ? (error as { responseCode?: unknown }).responseCode : undefined;
|
if (typeof error !== "object" || error === null) return false;
|
||||||
return typeof code === "number" && code >= 500 && code < 600;
|
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. */
|
/** SMTP-Fehlermeldungen nennen den Empfaenger; ins Log und in lastError kommt er nicht. */
|
||||||
|
|||||||
+31
-5
@@ -67,6 +67,26 @@ const CLOUDFLARE: [string, number, "ipv4" | "ipv6"][] = [
|
|||||||
const cloudflare = new BlockList();
|
const cloudflare = new BlockList();
|
||||||
for (const [net, prefix, family] of CLOUDFLARE) cloudflare.addSubnet(net, prefix, family);
|
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 {
|
function validIp(value: string | null | undefined): string | null {
|
||||||
const ip = value?.trim();
|
const ip = value?.trim();
|
||||||
return ip && isIP(ip) ? ip : null;
|
return ip && isIP(ip) ? ip : null;
|
||||||
@@ -82,10 +102,11 @@ function validIp(value: string | null | undefined): string | null {
|
|||||||
*/
|
*/
|
||||||
export function clientIp(headers: Headers): string {
|
export function clientIp(headers: Headers): string {
|
||||||
const forwarded = headers.get("x-forwarded-for");
|
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";
|
if (!peer) return "unknown";
|
||||||
const viaCloudflare = validIp(headers.get("cf-connecting-ip"));
|
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. */
|
/** 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`;
|
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. */
|
/** Kopie der Anfrage mit CLIENT_IP_HEADER aus clientIp(); ein mitgeschickter Wert wird ersetzt. */
|
||||||
export function withClientIp(request: Request): Request {
|
export function withClientIp(request: Request): Request {
|
||||||
const headers = new Headers(request.headers);
|
return new Request(request, { headers: headersWithClientIp(request.headers) });
|
||||||
headers.set(CLIENT_IP_HEADER, clientIp(request.headers));
|
|
||||||
return new Request(request, { headers });
|
|
||||||
}
|
}
|
||||||
+46
-2
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
import { CLIENT_IP_HEADER, clientIp, rateLimitSubject, withClientIp } from "@/lib/rate-limit";
|
import { CLIENT_IP_HEADER, clientIp, headersWithClientIp, rateLimitSubject, withClientIp } from "@/lib/rate-limit";
|
||||||
|
|
||||||
const h = (values: Record<string, string>) => new Headers(values);
|
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");
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,8 +13,8 @@ vi.mock("nodemailer", async (importOriginal) => {
|
|||||||
return { ...actual, default: { ...actual.default, createTransport } };
|
return { ...actual, default: { ...actual.default, createTransport } };
|
||||||
});
|
});
|
||||||
|
|
||||||
function smtpError(message: string, responseCode?: number) {
|
function smtpError(message: string, responseCode?: number, code = "EENVELOPE") {
|
||||||
return Object.assign(new Error(message), { code: "EENVELOPE", responseCode });
|
return Object.assign(new Error(message), { code, responseCode });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function queueMail(to = "[email protected]") {
|
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 });
|
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");
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user