diff --git a/migrations/pg/2026-04-17_suppression-list-hashed.sql b/migrations/pg/2026-04-17_suppression-list-hashed.sql new file mode 100644 index 0000000..5e6706a --- /dev/null +++ b/migrations/pg/2026-04-17_suppression-list-hashed.sql @@ -0,0 +1,13 @@ +-- Schritt 1: Hash-Spalte hinzufügen (additiv, kein Breaking Change) +ALTER TABLE suppression_list ADD COLUMN IF NOT EXISTS email_hash TEXT; + +CREATE INDEX IF NOT EXISTS idx_suppression_list_email_hash ON suppression_list(email_hash); + +-- Schritt 2: Bestehende Einträge hashen (PostgreSQL-native SHA256) +UPDATE suppression_list + SET email_hash = encode(sha256(lower(trim(email))::bytea), 'hex') + WHERE email_hash IS NULL; + +-- Schritt 3 (nach Validierung separat ausführen): +-- ALTER TABLE suppression_list ALTER COLUMN email_hash SET NOT NULL; +-- ALTER TABLE suppression_list DROP COLUMN email; diff --git a/src/server/suppression/check.test.ts b/src/server/suppression/check.test.ts index edb6dd6..fc03a95 100644 --- a/src/server/suppression/check.test.ts +++ b/src/server/suppression/check.test.ts @@ -12,7 +12,7 @@ vi.mock('../db/tenant', () => ({ import { checkSuppression } from './check' describe('checkSuppression', () => { - beforeEach(() => vi.clearAllMocks()) + beforeEach(() => mockClient.query.mockReset()) it('gibt true zurück wenn E-Mail in Suppression-Liste', async () => { mockClient.query.mockResolvedValueOnce([{ exists: true }]) @@ -26,21 +26,41 @@ describe('checkSuppression', () => { expect(result).toBe(false) }) - it('normalisiert E-Mail (lowercase + trim) vor dem Check', async () => { + it('sucht per SHA256-Hash, nicht per Klartext-E-Mail', async () => { + mockClient.query.mockResolvedValueOnce([]) + await checkSuppression('tenant1', 'Test@Example.com') + const [sql, params] = mockClient.query.mock.calls[0] as [string, string[]] + expect(sql).toContain('email_hash') + expect(sql).not.toContain('email =') + // SHA256-Hex ist 64 Zeichen + expect(params[0]).toMatch(/^[a-f0-9]{64}$/) + }) + + it('normalisiert E-Mail vor dem Hashing (lowercase + trim)', async () => { mockClient.query.mockResolvedValueOnce([]) await checkSuppression('tenant1', ' TEST@EXAMPLE.COM ') - expect(mockClient.query).toHaveBeenCalledWith( - expect.any(String), - ['test@example.com'] - ) + + mockClient.query.mockResolvedValueOnce([]) + await checkSuppression('tenant1', 'test@example.com') + + const hash1 = (mockClient.query.mock.calls[0] as [string, string[]])[1][0] + const hash2 = (mockClient.query.mock.calls[1] as [string, string[]])[1][0] + expect(hash1).toBe(hash2) }) it('Query ist parametrisiert (kein String-Interpolation)', async () => { mockClient.query.mockResolvedValueOnce([]) await checkSuppression('tenant1', 'x@x.com') - const call = mockClient.query.mock.calls[0] + const call = mockClient.query.mock.calls[0] as [string, string[]] expect(call[0]).toContain('$1') - expect(call[1]).toEqual(['x@x.com']) + expect(call[1]).toHaveLength(1) + }) + + it('sendet nie die Klartext-E-Mail an die Datenbank', async () => { + mockClient.query.mockResolvedValueOnce([]) + await checkSuppression('tenant1', 'secret@example.com') + const [, params] = mockClient.query.mock.calls[0] as [string, string[]] + expect(params[0]).not.toContain('secret@example.com') }) it('propagiert Fehler bei ungültiger tenantId nach oben', async () => { diff --git a/src/server/suppression/check.ts b/src/server/suppression/check.ts index 4185596..e1916d6 100644 --- a/src/server/suppression/check.ts +++ b/src/server/suppression/check.ts @@ -1,11 +1,12 @@ import { withTenant } from '../db/tenant' +import { hashEmail } from '../../lib/crypto' export async function checkSuppression(tenantId: string, email: string): Promise { - const normalized = email.toLowerCase().trim() + const hash = hashEmail(email) const rows = await withTenant(tenantId, (client) => client.query<{ '?column?': number }>( - 'SELECT 1 FROM suppression_list WHERE email = $1 LIMIT 1', - [normalized] + 'SELECT 1 FROM suppression_list WHERE email_hash = $1 LIMIT 1', + [hash] ) ) return rows.length > 0