Security-Hardening Runde 7: SSRF-Schutz + Logout-Endpoint
🛡 SSRF-Schutz in test-connection / test-mail-access - Admin-User konnte über apiUrl bzw. SMTP/IMAP-Server-Felder Connections zu Cloud-Metadata-Endpoints (169.254.169.254, metadata.google.internal etc.) auslösen. Internal-Port-Scan über Timing-Differenzen war messbar. - Fix: neuer utils/ssrfGuard.ts blockiert pre-flight 169.254.0.0/16, 0.0.0.0/8, Multicast/Reserved-Ranges, AWS-IPv6-Metadata, IPv6-Link-Local und Cloud-Metadata-Hostnames. Loopback (127.0.0.0/8) bleibt erlaubt – legitime Plesk/Postfix- Setups sollen weiter funktionieren. 🔒 Logout-Endpoint POST /api/auth/logout - Setzt tokenInvalidatedAt / portalTokenInvalidatedAt auf jetzt. Auth-Middleware prüft das Feld bereits und lehnt Tokens mit iat davor ab. Ohne diesen Endpoint blieb ein "abgemeldeter" JWT bis Expiry (7d) gültig. Live-verifiziert: - 169.254.169.254 / metadata.google.internal / 0.0.0.0 → 400 - 127.0.0.1 (Plesk-Fall) weiter erlaubt - /me vor Logout 200, nach Logout 401 "Sitzung ungültig" Geprüft + sauber (Runde 7, kein Bug): - Public Consent (122-bit Random-UUID nicht brute-force-bar) - Magic-Bytes-Bypass beim Upload - PDF manualValues Injection (keine HTML-Render-Surface) - Query-Filter-Override (?customerId=X) – vom Portal-Filter ignoriert - Audit-Logs / Email-Config / Backup-Endpoints als Portal: 403 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Request, Response } from 'express';
|
||||
import * as authService from '../services/auth.service.js';
|
||||
import { AuthRequest, ApiResponse } from '../types/index.js';
|
||||
import prisma from '../lib/prisma.js';
|
||||
|
||||
// Mitarbeiter-Login
|
||||
export async function login(req: Request, res: Response): Promise<void> {
|
||||
@@ -166,6 +167,42 @@ export async function confirmPasswordReset(req: Request, res: Response): Promise
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout: invalidiert den aktuellen JWT serverseitig durch Setzen von
|
||||
* tokenInvalidatedAt / portalTokenInvalidatedAt auf jetzt. Auth-Middleware
|
||||
* prüft dieses Feld und lehnt Tokens ab, deren `iat` davor liegt.
|
||||
*
|
||||
* Hinweis: Da JWTs stateless sind, gibt es keine echte Token-Revocation
|
||||
* ohne dieses Pattern. Logout invalidiert ALLE aktiven Sessions des Users
|
||||
* (auch andere Geräte) – akzeptabel für ein Sicherheits-Logout.
|
||||
*/
|
||||
export async function logout(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const user = req.user as any;
|
||||
if (!user) {
|
||||
res.json({ success: true, message: 'Bereits abgemeldet' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
if (user.isCustomerPortal && user.customerId) {
|
||||
await prisma.customer.update({
|
||||
where: { id: user.customerId },
|
||||
data: { portalTokenInvalidatedAt: new Date() },
|
||||
});
|
||||
} else if (user.userId) {
|
||||
await prisma.user.update({
|
||||
where: { id: user.userId },
|
||||
data: { tokenInvalidatedAt: new Date() },
|
||||
});
|
||||
}
|
||||
res.json({ success: true, message: 'Abgemeldet' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Abmelden',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
export async function register(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { email, password, firstName, lastName, roleIds } = req.body;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ApiResponse } from '../types/index.js';
|
||||
import { testImapConnection, ImapCredentials } from '../services/imapService.js';
|
||||
import { testSmtpConnection, SmtpCredentials } from '../services/smtpService.js';
|
||||
import { decrypt } from '../utils/encryption.js';
|
||||
import { assertAllowedHost } from '../utils/ssrfGuard.js';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
@@ -118,6 +119,20 @@ export async function testConnection(req: Request, res: Response): Promise<void>
|
||||
domain: req.body.domain,
|
||||
} : undefined;
|
||||
|
||||
// SSRF-Guard: testData.apiUrl-Hostname prüfen
|
||||
if (testData?.apiUrl) {
|
||||
try {
|
||||
const url = new URL(testData.apiUrl);
|
||||
assertAllowedHost(url.hostname, 'apiUrl-Host');
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.includes('geblockte')) {
|
||||
res.status(400).json({ success: false, error: err.message } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
// URL-Parse-Fehler ignorieren – Backend reagiert sowieso mit Fehler
|
||||
}
|
||||
}
|
||||
|
||||
const result = await emailProviderService.testProviderConnection({ id, testData });
|
||||
res.json({ success: result.success, data: result } as ApiResponse);
|
||||
} catch (error) {
|
||||
@@ -214,6 +229,20 @@ export async function testMailAccess(req: Request, res: Response): Promise<void>
|
||||
return;
|
||||
}
|
||||
|
||||
// SSRF-Guard: Wenn der Host vom Body kommt, blockieren wir Cloud-Metadata
|
||||
// und Reserved-Ranges. Loopback/Private-Ranges bleiben erlaubt für
|
||||
// legitime Plesk/Postfix-Setups.
|
||||
try {
|
||||
assertAllowedHost(smtpServer, 'SMTP-Server');
|
||||
assertAllowedHost(imapServer, 'IMAP-Server');
|
||||
} catch (err) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : 'Ungültige Server-Adresse',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// IMAP testen
|
||||
const imapCredentials: ImapCredentials = {
|
||||
host: imapServer,
|
||||
|
||||
Reference in New Issue
Block a user