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:
parent
0c0cecdbbd
commit
df6eb9724d
|
|
@ -1,6 +1,7 @@
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import * as authService from '../services/auth.service.js';
|
import * as authService from '../services/auth.service.js';
|
||||||
import { AuthRequest, ApiResponse } from '../types/index.js';
|
import { AuthRequest, ApiResponse } from '../types/index.js';
|
||||||
|
import prisma from '../lib/prisma.js';
|
||||||
|
|
||||||
// Mitarbeiter-Login
|
// Mitarbeiter-Login
|
||||||
export async function login(req: Request, res: Response): Promise<void> {
|
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> {
|
export async function register(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { email, password, firstName, lastName, roleIds } = req.body;
|
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 { testImapConnection, ImapCredentials } from '../services/imapService.js';
|
||||||
import { testSmtpConnection, SmtpCredentials } from '../services/smtpService.js';
|
import { testSmtpConnection, SmtpCredentials } from '../services/smtpService.js';
|
||||||
import { decrypt } from '../utils/encryption.js';
|
import { decrypt } from '../utils/encryption.js';
|
||||||
|
import { assertAllowedHost } from '../utils/ssrfGuard.js';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
@ -118,6 +119,20 @@ export async function testConnection(req: Request, res: Response): Promise<void>
|
||||||
domain: req.body.domain,
|
domain: req.body.domain,
|
||||||
} : undefined;
|
} : 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 });
|
const result = await emailProviderService.testProviderConnection({ id, testData });
|
||||||
res.json({ success: result.success, data: result } as ApiResponse);
|
res.json({ success: result.success, data: result } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -214,6 +229,20 @@ export async function testMailAccess(req: Request, res: Response): Promise<void>
|
||||||
return;
|
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
|
// IMAP testen
|
||||||
const imapCredentials: ImapCredentials = {
|
const imapCredentials: ImapCredentials = {
|
||||||
host: imapServer,
|
host: imapServer,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ const router = Router();
|
||||||
router.post('/login', loginRateLimiter, authController.login);
|
router.post('/login', loginRateLimiter, authController.login);
|
||||||
router.post('/customer-login', loginRateLimiter, authController.customerLogin);
|
router.post('/customer-login', loginRateLimiter, authController.customerLogin);
|
||||||
router.get('/me', authenticate, authController.me);
|
router.get('/me', authenticate, authController.me);
|
||||||
|
router.post('/logout', authenticate, authController.logout);
|
||||||
router.post('/register', authenticate, requirePermission('users:create'), authController.register);
|
router.post('/register', authenticate, requirePermission('users:create'), authController.register);
|
||||||
|
|
||||||
// Passwort-Reset-Flow
|
// Passwort-Reset-Flow
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
/**
|
||||||
|
* Schutz vor Server-Side Request Forgery (SSRF) bei User-kontrollierten
|
||||||
|
* Hosts/URLs in Endpunkten wie test-connection, test-mail-access.
|
||||||
|
*
|
||||||
|
* Wir blockieren bewusst NICHT die komplette private IP-Range (127.0.0.0/8,
|
||||||
|
* 10.0.0.0/8 etc.), weil legitime On-Premise-Setups häufig Plesk/Dovecot/
|
||||||
|
* Postfix auf 127.0.0.1 oder im internen Netz laufen lassen. Stattdessen
|
||||||
|
* blockieren wir nur:
|
||||||
|
* - Cloud-Metadata-Endpoints (169.254.169.254, fd00:ec2::254)
|
||||||
|
* - 169.254.0.0/16 Link-Local (deckt Cloud-Metadata + APIPA ab)
|
||||||
|
* - 0.0.0.0/8 (ungültiger Source/Routing-Range)
|
||||||
|
* - Multicast / Reserved Ranges (224.0.0.0/4, 240.0.0.0/4)
|
||||||
|
*
|
||||||
|
* Für Defense-in-Depth gegen DNS-Rebinding wäre eine vollständige DNS-
|
||||||
|
* Resolution + IP-Vergleich nötig – das überlassen wir v1.1, weil es
|
||||||
|
* legitimes Caching/CDN-Verhalten brechen kann.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BLOCKED_PATTERNS: RegExp[] = [
|
||||||
|
/^169\.254\./, // Link-Local (AWS/GCP/Azure Metadata, APIPA)
|
||||||
|
/^0\./, // 0.0.0.0/8 reserved
|
||||||
|
/^22[4-9]\./, // 224-229 Multicast
|
||||||
|
/^23[0-9]\./, // 230-239 Multicast
|
||||||
|
/^24[0-9]\./, // 240-249 reserved
|
||||||
|
/^25[0-5]\./, // 250-255 reserved
|
||||||
|
/^fd00:ec2::/i, // AWS IPv6 Metadata
|
||||||
|
/^fe80:/i, // IPv6 Link-Local
|
||||||
|
/^ff/i, // IPv6 Multicast
|
||||||
|
];
|
||||||
|
|
||||||
|
const BLOCKED_HOSTNAMES = new Set([
|
||||||
|
'metadata.google.internal',
|
||||||
|
'metadata.goog',
|
||||||
|
'metadata',
|
||||||
|
'169.254.169.254',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isBlockedSsrfHost(host: string | null | undefined): boolean {
|
||||||
|
if (!host) return false;
|
||||||
|
const h = host.trim().toLowerCase();
|
||||||
|
if (!h) return false;
|
||||||
|
if (BLOCKED_HOSTNAMES.has(h)) return true;
|
||||||
|
for (const pattern of BLOCKED_PATTERNS) {
|
||||||
|
if (pattern.test(h)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wirft einen Fehler, wenn der Host für ausgehende Verbindungen blockiert ist.
|
||||||
|
* Caller sollte den Fehler in 400er Response umsetzen.
|
||||||
|
*/
|
||||||
|
export function assertAllowedHost(host: string | null | undefined, label = 'Host'): void {
|
||||||
|
if (isBlockedSsrfHost(host)) {
|
||||||
|
throw new Error(`${label} verweist auf eine geblockte Adresse (Cloud-Metadata / Link-Local / Reserved).`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -141,6 +141,40 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
||||||
- Provider/Tariff-GETs: `requirePermission('providers:read')` (Portal-Kunden sehen Provider-Liste nicht mehr)
|
- Provider/Tariff-GETs: `requirePermission('providers:read')` (Portal-Kunden sehen Provider-Liste nicht mehr)
|
||||||
- SMTP-Header-Injection: zentrale CRLF-Validierung in `smtpService.sendEmail` (schützt alle Caller)
|
- SMTP-Header-Injection: zentrale CRLF-Validierung in `smtpService.sendEmail` (schützt alle Caller)
|
||||||
- bcrypt cost 10 → 12 (OWASP 2026)
|
- bcrypt cost 10 → 12 (OWASP 2026)
|
||||||
|
- **Runde 7 – Letzter Schliff (SSRF + Logout):**
|
||||||
|
- **SSRF-Schutz** in `test-connection` und `test-mail-access`: ein
|
||||||
|
Admin-User konnte über die Plesk-API-URL bzw. SMTP/IMAP-Server-Felder
|
||||||
|
Connections zu beliebigen IPs auslösen (Cloud-Metadata-Endpoints,
|
||||||
|
Link-Local, AWS/GCP-Metadata-Hosts). Internal-Port-Scanning via
|
||||||
|
Timing-Differenzen war messbar (22/80/3306/5432/6379 unterschiedlich).
|
||||||
|
Fix: neuer Helper `utils/ssrfGuard.ts` blockiert vor jeder ausgehenden
|
||||||
|
Verbindung 169.254.0.0/16, 0.0.0.0/8, Multicast/Reserved-Ranges,
|
||||||
|
AWS-IPv6-Metadata, IPv6-Link-Local und bekannte Cloud-Metadata-
|
||||||
|
Hostnames (metadata.google.internal etc.). Loopback (127.0.0.0/8)
|
||||||
|
bleibt erlaubt für legitime Plesk/Postfix-Setups.
|
||||||
|
- **Logout-Endpoint** `POST /api/auth/logout`: setzt
|
||||||
|
`tokenInvalidatedAt` / `portalTokenInvalidatedAt` auf jetzt. Auth-
|
||||||
|
Middleware prüft das Feld und lehnt Tokens mit `iat` davor ab.
|
||||||
|
JWTs sind stateless – ohne diesen Mechanismus bleibt ein
|
||||||
|
„abgemeldeter" Token bis zum natürlichen Expiry (7d) gültig.
|
||||||
|
- Live-verifiziert: 169.254.169.254/metadata.google.internal/0.0.0.0
|
||||||
|
werden mit 400 abgelehnt; 127.0.0.1 weiter erlaubt; Logout
|
||||||
|
invalidiert den Token sofort (HTTP 401 „Sitzung ungültig").
|
||||||
|
|
||||||
|
**Geprüft + sauber (Runde 7):**
|
||||||
|
- Public Consent (random Hash → 404, kein Brute-Force durch 122-bit-UUID)
|
||||||
|
- Magic-Bytes-Bypass beim Upload (HTML als image/png) → blockiert
|
||||||
|
- PDF-Generation mit injizierten manualValues → kein XSS-Vektor (PDFs sind keine Web-Renderer)
|
||||||
|
- Audit-Logs für Portal-User: 403
|
||||||
|
- Email-Config-Update als Portal: 403
|
||||||
|
- Backup-Endpoints als Portal: 403
|
||||||
|
- Query-Filter-Override (?customerId=X) → vom Portal-Filter ignoriert
|
||||||
|
|
||||||
|
**Bewusst NICHT gefixt (zu invasiv für v1.0):**
|
||||||
|
- Vollständige DNS-Resolution beim SSRF-Guard (gegen DNS-Rebinding) –
|
||||||
|
kann legitimes CDN/Caching brechen, v1.1-Item.
|
||||||
|
- Per-File-Ownership-Check bei `/api/uploads` (siehe Runde 5).
|
||||||
|
|
||||||
- **Runde 6 – Tiefer Live-Pentest (auf Wunsch des Users, „bevor andere es tun"):**
|
- **Runde 6 – Tiefer Live-Pentest (auf Wunsch des Users, „bevor andere es tun"):**
|
||||||
- 🚨 **`GET /api/customers` leakte als Portal-User die komplette
|
- 🚨 **`GET /api/customers` leakte als Portal-User die komplette
|
||||||
Kundendatenbank** (alle Namen, E-Mails, customerNumber etc.). Der
|
Kundendatenbank** (alle Namen, E-Mails, customerNumber etc.). Der
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue