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:
parent
e5eff6778f
commit
38811820e6
@ -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" } });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
53
backend/server/utils/public-email-domains.ts
Normal file
53
backend/server/utils/public-email-domains.ts
Normal 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());
|
||||||
|
}
|
||||||
@ -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 () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user