feat(backend): Public-Domain-Guard + Mail-Detection (spins/%-Pattern)

Public-Domain-Guard (icloud.com/gmail.com etc. nie blockbar/veröffentlichbar):
- neue utils/public-email-domains.ts (shared Freemail-Liste)
- custom-domains/index.post + custom-domains/suggest + curated-domains/suggest
  lehnen Public-Domains mit 400 PUBLIC_DOMAIN ab (defense-in-depth)

Mail-Detection (mo): "spins" zu GAMBLING_KEYWORDS + Subject-%-Pattern (Score 10)
→ fängt "Spins + 400% Bonus"-Spam von Freemail-Absendern. 61/61 Tests grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-30 01:06:06 +02:00
parent e5eff6778f
commit 38811820e6
7 changed files with 150 additions and 2 deletions

View File

@ -1,4 +1,5 @@
import { usePrisma } from "../../utils/prisma"; import { usePrisma } from "../../utils/prisma";
import { isPublicEmailDomain } from "../../utils/public-email-domains";
// Domain muss mindestens eine TLD haben (z.B. "mbet216.com"). // Domain muss mindestens eine TLD haben (z.B. "mbet216.com").
const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
@ -32,6 +33,10 @@ export default defineEventHandler(async (event) => {
if (!domain || domain.length > 253 || !DOMAIN_RE.test(domain)) { if (!domain || domain.length > 253 || !DOMAIN_RE.test(domain)) {
throw createError({ statusCode: 400, data: { error: "INVALID_DOMAIN" } }); throw createError({ statusCode: 400, data: { error: "INVALID_DOMAIN" } });
} }
// Public-/Freemail-Domains nie in die kuratierte VIP-Liste (icloud.com etc.).
if (isPublicEmailDomain(domain)) {
throw createError({ statusCode: 400, data: { error: "PUBLIC_DOMAIN" } });
}
if (!VALID_COUNTRIES.includes(country)) { if (!VALID_COUNTRIES.includes(country)) {
throw createError({ statusCode: 400, data: { error: "INVALID_COUNTRY" } }); throw createError({ statusCode: 400, data: { error: "INVALID_COUNTRY" } });
} }

View File

@ -8,6 +8,7 @@ import {
import { getProfile } from "../../db/profile"; import { getProfile } from "../../db/profile";
import { getPlanLimits } from "../../utils/plan-features"; import { getPlanLimits } from "../../utils/plan-features";
import { usePrisma } from "../../utils/prisma"; import { usePrisma } from "../../utils/prisma";
import { isPublicEmailDomain } from "../../utils/public-email-domains";
// Regex: Domain muss mindestens eine TLD haben (z.B. "casino.de", "x.co.uk") // Regex: Domain muss mindestens eine TLD haben (z.B. "casino.de", "x.co.uk")
const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
@ -168,6 +169,21 @@ export default defineEventHandler(async (event) => {
}); });
} }
// Public-/Freemail-Domains (icloud.com, gmail.com …) hart ablehnen — web UND
// mail. Realer Vorfall: User kopiert eine Casino-Spam-Adresse `xyz@icloud.com`
// komplett ins Feld → wir extrahierten `icloud.com`. Das zu blocken würde die
// gesamte Mail/Webmail des Users sperren. Siehe public-email-domains.ts.
if (isPublicEmailDomain(value)) {
throw createError({
statusCode: 400,
data: {
error: "PUBLIC_DOMAIN",
message:
"Das ist ein öffentlicher E-Mail-Anbieter — den können wir nicht blocken, sonst wäre deine ganze Mail betroffen. Blocke stattdessen die konkrete Casino-Domain aus dem Link der Mail.",
},
});
}
// Ist die Domain schon in der globalen Layer-1-Blocklist? // Ist die Domain schon in der globalen Layer-1-Blocklist?
const db = usePrisma(); const db = usePrisma();
const globalMatch = await db.blocklistDomain.findFirst({ const globalMatch = await db.blocklistDomain.findFirst({

View File

@ -1,5 +1,6 @@
import { usePrisma } from "../../utils/prisma"; import { usePrisma } from "../../utils/prisma";
import { suggestCuratedDomain } from "../../db/curatedDomains"; import { suggestCuratedDomain } from "../../db/curatedDomains";
import { isPublicEmailDomain } from "../../utils/public-email-domains";
// Unterstützte Ländercodes für Layer-2-Listen // Unterstützte Ländercodes für Layer-2-Listen
const SUPPORTED_COUNTRIES = ["DE", "GB", "FR", "TN"] as const; const SUPPORTED_COUNTRIES = ["DE", "GB", "FR", "TN"] as const;
@ -33,6 +34,15 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, data: { error: "INVALID_DOMAIN" } }); throw createError({ statusCode: 400, data: { error: "INVALID_DOMAIN" } });
} }
// Public-/Freemail-Domains dürfen NIE in die kuratierte Country-Liste —
// sonst landet z.B. icloud.com global (genau der gemeldete Vorfall).
if (isPublicEmailDomain(rawDomain)) {
throw createError({
statusCode: 400,
data: { error: "PUBLIC_DOMAIN" },
});
}
if (!(SUPPORTED_COUNTRIES as readonly string[]).includes(rawCountry)) { if (!(SUPPORTED_COUNTRIES as readonly string[]).includes(rawCountry)) {
throw createError({ throw createError({
statusCode: 400, statusCode: 400,

View File

@ -22,7 +22,7 @@ export const GAMBLING_KEYWORDS = [
// Generic Begriffe // Generic Begriffe
"sportwetten", "jackpot", "freispiel", "free spin", "bonus code", "sportwetten", "jackpot", "freispiel", "free spin", "bonus code",
"auszahlung", "glücksspiel", "slots", "roulette", "auszahlung", "glücksspiel", "slots", "roulette", "spins",
// ⚠️ Risk: matcht auch unschuldige Wörter (Mo's Finding #5) // ⚠️ Risk: matcht auch unschuldige Wörter (Mo's Finding #5)
// TODO Whitelist: "wette" matcht "wettervorhersage" → False-Positive // TODO Whitelist: "wette" matcht "wettervorhersage" → False-Positive

View File

@ -77,6 +77,7 @@ export const SCORE_WEIGHTS = {
// Subject-Indikatoren // Subject-Indikatoren
SUBJECT_GAMBLING_KEYWORD: 50, // Keyword im Betreff (casino, jackpot, freispiel …) SUBJECT_GAMBLING_KEYWORD: 50, // Keyword im Betreff (casino, jackpot, freispiel …)
SUBJECT_MONEY_PATTERN: 20, // €/$ + Zahl (z.B. "100€ Bonus") SUBJECT_MONEY_PATTERN: 20, // €/$ + Zahl (z.B. "100€ Bonus")
SUBJECT_PERCENT_PATTERN: 10, // Zahl+% im Betreff (z.B. "400% Bonus") — allein nie Block-fähig
SUBJECT_URGENCY: 15, // "Nur heute", "Letzte Chance", "Ablaufdatum" SUBJECT_URGENCY: 15, // "Nur heute", "Letzte Chance", "Ablaufdatum"
SUBJECT_ALL_CAPS_WORD: 5, // EINZELNES ALL-CAPS-WORT im Betreff SUBJECT_ALL_CAPS_WORD: 5, // EINZELNES ALL-CAPS-WORT im Betreff
@ -277,6 +278,15 @@ export function computeScore(
score += SCORE_WEIGHTS.SUBJECT_MONEY_PATTERN; score += SCORE_WEIGHTS.SUBJECT_MONEY_PATTERN;
} }
// ── Prozent-Pattern im Betreff (z.B. "400% Bonus", "200% Einzahlung") ──
// Separates Flag mit reduziertem Score: % allein (10 Punkte) reicht nie
// für einen Block (Threshold 50) — verhindert FP bei "10% Rabatt"-Mails.
// Ergänzt das Currency-Pattern oben, das nur € / $ / £ kennt.
if (/\d\s*%/.test(subject)) {
styleFlags.push("percent-pattern");
score += SCORE_WEIGHTS.SUBJECT_PERCENT_PATTERN;
}
// ── Urgency-Wörter im Betreff ── // ── Urgency-Wörter im Betreff ──
const URGENCY_PATTERNS = [ const URGENCY_PATTERNS = [
"nur heute", "letzte chance", "läuft ab", "ablaufdatum", "nur heute", "letzte chance", "läuft ab", "ablaufdatum",

View File

@ -0,0 +1,53 @@
/**
* Public-/Freemail-Provider-Domains.
*
* Diese Domains dürfen NIEMALS als Custom-Domain (web ODER mail) geblockt oder
* in die kuratierte Country-Liste vorgeschlagen werden:
* - web: icloud.com/gmail.com blocken würde die Webmail des Users sperren
* - mail: würde JEDE Mail von diesem Provider löschen (Kontakte, Banken, alles)
*
* Realer Vorfall: User hat eine Casino-Spam-Mail von `xyz@icloud.com` geöffnet,
* die komplette Adresse kopiert + ins Add-Feld gepackt wir extrahierten
* `icloud.com` und hätten es geblockt/veröffentlicht. Das muss hart verhindert
* werden auf JEDER Ebene (Frontend-Hinweis + Backend-Reject).
*
* Spiegel-Liste im Frontend: `apps/rebreak-native/hooks/useCustomDomains.ts`
* (PUBLIC_EMAIL_DOMAINS) bei Änderungen beide synchron halten.
*/
export const PUBLIC_EMAIL_DOMAINS: ReadonlySet<string> = new Set([
// Google
"gmail.com", "googlemail.com",
// Apple
"icloud.com", "me.com", "mac.com",
// Microsoft
"outlook.com", "outlook.de", "hotmail.com", "hotmail.de", "hotmail.co.uk",
"hotmail.fr", "live.com", "live.de", "msn.com",
// Yahoo
"yahoo.com", "yahoo.de", "yahoo.co.uk", "yahoo.fr", "ymail.com", "rocketmail.com",
// GMX / United Internet (DACH)
"gmx.de", "gmx.net", "gmx.at", "gmx.ch", "gmx.com", "web.de",
// AOL
"aol.com", "aim.com",
// Proton / Tutanota / Posteo / Mailbox (privacy)
"proton.me", "protonmail.com", "pm.me", "tutanota.com", "tutanota.de",
"tuta.io", "posteo.de", "posteo.net", "mailbox.org", "hey.com",
// Deutsche ISP-Freemail
"t-online.de", "freenet.de", "arcor.de",
// Generische Freemail
"mail.com", "mail.de", "email.de", "zoho.com", "fastmail.com", "fastmail.fm",
"hushmail.com",
// Yandex / Mail.ru
"yandex.com", "yandex.ru", "mail.ru",
// Frankreich (FR-Markt)
"laposte.net", "orange.fr", "free.fr", "sfr.fr", "wanadoo.fr",
// Asien
"qq.com", "163.com", "126.com", "naver.com", "daum.net",
]);
/**
* True wenn die (bereits normalisierte, lowercase) Domain ein
* Public-/Freemail-Provider ist und daher nicht blockbar/vorschlagbar.
*/
export function isPublicEmailDomain(domain: string): boolean {
return PUBLIC_EMAIL_DOMAINS.has(domain.trim().toLowerCase());
}

View File

@ -26,7 +26,7 @@ vi.mock("../../server/utils/gambling-keywords.mjs", () => ({
GAMBLING_KEYWORDS: [ GAMBLING_KEYWORDS: [
"casino", "bet365", "bwin", "tipico", "unibet", "betway", "casino", "bet365", "bwin", "tipico", "unibet", "betway",
"pokerstars", "jackpot", "freispiel", "free spin", "bonus code", "pokerstars", "jackpot", "freispiel", "free spin", "bonus code",
"auszahlung", "glücksspiel", "slots", "roulette", "wette", "auszahlung", "glücksspiel", "slots", "roulette", "spins", "wette",
"stake", "rolletto", "vbet", "1xbet", "melbet", "mostbet", "stake", "rolletto", "vbet", "1xbet", "melbet", "mostbet",
"luckyvibe", "spinz", "casinoly", "rabona", "justcasino", "luckyvibe", "spinz", "casinoly", "rabona", "justcasino",
"getslots", "rocketplay", "freshcasino", "betano", "leovegas", "getslots", "rocketplay", "freshcasino", "betano", "leovegas",
@ -531,6 +531,60 @@ describe("classifyMail() — End-to-End Pipeline", () => {
expect(result.features.keywordHitsSubject).toHaveLength(0); expect(result.features.keywordHitsSubject).toHaveLength(0);
}); });
// ─── Fix: "spins" + Prozent-Pattern (Steffanie-Heier-Fall) ──────────────────
it("Steffanie-Heier-Fall: 'Fettes Angebot: Spins + 400% Bonus' → BLOCK (score=60)", async () => {
// hotmail.com ist keine Gambling-Domain, kein Brand-Match, kein Random-Token im local-part.
// Vor Fix: Score 0 → PASS.
// Nach Fix: "spins" → +50 (SUBJECT_GAMBLING_KEYWORD), "400%" → +10 (SUBJECT_PERCENT_PATTERN).
// Score = 60 >= SCORE_BLOCK_MIDRANGE (50) → BLOCK.
const result = await classifyMail({
mail: {
senderEmail: "xpslyjzbt6630@hotmail.com",
senderName: "Steffanie Heier",
subject: "Fettes Angebot: Spins + 400% Bonus",
},
blockedDomainSet: new Set<string>(),
});
expect(result.action).toBe("blocked");
expect(result.triggerSource).toMatch(/^score:/);
expect(result.score).toBe(60);
expect(result.features.keywordHitsSubject).toContain("spins");
expect(result.features.styleFlags).toContain("percent-pattern");
});
it("FP-Guard: '10% Rabatt auf deine Bestellung' ohne Gambling-Keyword → PASS", async () => {
// Prozent-Pattern allein: +10 Punkte < SCORE_PASS_BELOW (25) → triggerSource 'no-signal' → PASS.
// Sicherstellt dass legitime Rabatt-Mails nicht durch das %-Pattern geblockt werden.
const result = await classifyMail({
mail: {
senderEmail: "newsletter@shop-example.de",
senderName: "Mein Shop",
subject: "10% Rabatt auf deine Bestellung",
},
blockedDomainSet: new Set<string>(),
});
expect(result.action).toBe("passed");
expect(result.triggerSource).toBe("no-signal");
expect(result.score).toBe(10);
expect(result.features.styleFlags).toContain("percent-pattern");
expect(result.features.keywordHitsSubject).toHaveLength(0);
});
it("FP-Guard: 'spins' in Recovery-Kontext ('Spielsucht') → PASS via Whitelist", async () => {
// "spielsucht" ist in GAMBLING_WHITELIST → Layer 1 greift, bevor 'spins' gewertet wird.
const result = await classifyMail({
mail: {
senderEmail: "info@spielsucht-hilfe.de",
senderName: "Spielsucht-Hilfe",
subject: "Weg von Spins und Jackpots — Hilfe bei Spielsucht",
},
blockedDomainSet: new Set<string>(),
});
expect(result.action).toBe("passed");
expect(result.triggerSource).toBe("whitelist");
});
// ─── v1.0: Display-Name-only Signale → kein Score-Beitrag ──────────────── // ─── v1.0: Display-Name-only Signale → kein Score-Beitrag ────────────────
it("v1.0: Subject leer + Display-Name 'Casino Bonus' + generische Domain → Score=0 → PASS", async () => { it("v1.0: Subject leer + Display-Name 'Casino Bonus' + generische Domain → Score=0 → PASS", async () => {