fix: Suppression-Check nutzt SHA256-Hash statt Klartext-E-Mail (Finding 1)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 17:36:11 +00:00
parent e5db71ead1
commit ee83705527
3 changed files with 45 additions and 11 deletions

View File

@@ -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;

View File

@@ -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 () => {

View File

@@ -1,11 +1,12 @@
import { withTenant } from '../db/tenant'
import { hashEmail } from '../../lib/crypto'
export async function checkSuppression(tenantId: string, email: string): Promise<boolean> {
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