K-03 & K-04 Implementierungsplan¶
🎯 Ziel¶
- K-03: Rate Limiter Bypass via Cold Start beseitigen → echte DDoS-Protection
- K-04: DoS via Exponential Backoff in Triggers verhindern → Function Concurrency Exhaustion vermeiden
🔴 K-03: Rate Limiter Bypass via Cold Start¶
Problem-Analyse¶
Aktueller Code (rate_limiter.js, Zeile 14-87):
// In-memory cache for rate limit data (persists while the Cloud Function instance is warm)
const rateLimitCache = new Map();
async function checkRateLimit(userId, action, options = {}) {
// ...
// Check in-memory cache first (avoids Firestore read on warm instances)
const cached = rateLimitCache.get(rateLimitId);
if (cached) {
// ... Cache-Check Logic
// For non-sensitive ops, trust the cache and increment locally
if (!useFirestoreAuthority) {
cached.attempts += 1;
cached.lastAttempt = now;
admin.firestore().collection('_rateLimits').doc(rateLimitId)
.set(cached).catch((err) => console.error('Rate limit write error:', err));
return;
}
}
// Cache miss – fall back to Firestore transaction (cold start)
// ...
}
Verwendung: 20+ Stellen in Callable Functions (createAuthUser, deleteAuthUser, createOrder, etc.)
Angriffsszenario:
Attacker Goal: Bypass Rate Limit von 10 Requests/Minute
T0: Attacker sendet Request 1 an Region europe-west3
→ Cold Start → neue Function Instance 1 → Cache leer
→ Firestore Transaction: attempts: 1
→ Cache gesetzt: attempts: 1
T1: Attacker sendet Request 2 an gleiche Region
→ Wird zu bestehender Instance 1 geroutet (warm)
→ Cache hit: attempts: 2
✓ Rate Limit funktioniert
ABER:
T2: Attacker sendet 100 parallele Requests
→ Firebase skaliert automatisch auf 10-50 neue Instances
→ Jede neue Instance hat LEEREN Cache
→ Jede Instance macht Firestore Transaction: attempts: 1
→ 100 Requests durchgekommen, obwohl Limit 10/Min!
T3: Attacker wartet 10 Sekunden, wiederholt
→ Weitere 100 Requests durch neue Cold Starts
→ Totaler Bypass des Rate Limiters
Real-World Impact: - Brute-Force auf deleteAuthUser → Account-Takeover-Versuche - DDoS auf createOrder → $10.000+ Firebase-Kosten in 1 Stunde - Credential-Stuffing auf Login (nicht implementiert, aber Risiko)
Kosten bei Angriff: - 10.000 Requests/Min × 60 Min = 600k Requests - 600k × $0.40 per 1M Invocations = $240/Stunde - Bei createOrder: zusätzlich 600k Firestore Writes = $1.080/Stunde - Total: $1.320/Stunde bei ungebremster Attacke
✅ LÖSUNG 1: Distributed Rate Limiter mit Redis¶
Schritt 1: Redis (Memorystore) provisionieren¶
Terraform/Bicep:
# Redis für Distributed Rate Limiting
resource "google_redis_instance" "rate_limiter" {
name = "easysale-rate-limiter"
tier = "BASIC"
memory_size_gb = 1
region = "europe-west3"
redis_version = "REDIS_7_2"
# Authorized Network
authorized_network = google_compute_network.default.id
# Für Serverless VPC Access
connect_mode = "DIRECT_PEERING"
labels = {
environment = "production"
purpose = "rate-limiting"
}
}
# Serverless VPC Access Connector
resource "google_vpc_access_connector" "connector" {
name = "functions-connector"
region = "europe-west3"
network = google_compute_network.default.name
ip_cidr_range = "10.8.0.0/28"
}
output "redis_host" {
value = google_redis_instance.rate_limiter.host
}
output "redis_port" {
value = google_redis_instance.rate_limiter.port
}
Kosten: $45/Monat (1GB Redis Basic) vs. $1.320/Stunde bei Angriff → ROI nach 2 Minuten Angriff
Schritt 2: Redis Client Setup¶
Neue Datei: core/functions/src/utils/redis_client.js
/**
* Redis Client für Distributed Rate Limiting
*
* Verwendet Google Cloud Memorystore (Redis) als single source of truth
* für Rate-Limit-Zähler. Im Gegensatz zu In-Memory-Cache wird Redis
* von ALLEN Function-Instances geteilt → kein Cold-Start-Bypass möglich.
*/
const { createClient } = require('redis');
const secureLogger = require('./secure_logger');
let redisClient = null;
/**
* Initialisiert Redis-Verbindung
* Wird beim ersten Aufruf ausgeführt (Lazy Init)
*/
async function getRedisClient() {
if (redisClient && redisClient.isOpen) {
return redisClient;
}
const REDIS_HOST = process.env.REDIS_HOST || 'localhost';
const REDIS_PORT = process.env.REDIS_PORT || 6379;
const REDIS_PASSWORD = process.env.REDIS_PASSWORD || null;
redisClient = createClient({
socket: {
host: REDIS_HOST,
port: parseInt(REDIS_PORT),
},
password: REDIS_PASSWORD,
// Timeout für Serverless (schnell scheitern wenn Redis down)
socket: {
connectTimeout: 5000,
reconnectStrategy: (retries) => {
if (retries > 3) return false; // Gib auf nach 3 Versuchen
return Math.min(retries * 100, 3000);
},
},
});
redisClient.on('error', (err) => {
secureLogger.error('Redis Client Error', { error: err.message });
});
await redisClient.connect();
secureLogger.info('Redis Client verbunden', { host: REDIS_HOST });
return redisClient;
}
/**
* Inkrementiert Rate-Limit-Counter atomar
*
* @param {string} key - Rate Limit Key (z.B. "ratelimit:user123:createOrder")
* @param {number} windowSeconds - Sliding Window Länge
* @returns {Promise<number>} Aktuelle Anzahl Requests im Window
*/
async function incrementRateLimitCounter(key, windowSeconds) {
const client = await getRedisClient();
// Lua Script für atomare Inkrement + TTL-Setzung
// Verhindert Race Conditions zwischen GET/INCR/EXPIRE
const luaScript = `
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return current
`;
const result = await client.eval(luaScript, {
keys: [key],
arguments: [windowSeconds.toString()],
});
return parseInt(result);
}
/**
* Liest aktuellen Counter-Wert (ohne Inkrement)
*/
async function getRateLimitCounter(key) {
const client = await getRedisClient();
const value = await client.get(key);
return value ? parseInt(value) : 0;
}
/**
* Setzt Rate-Limit-Counter zurück
*/
async function resetRateLimitCounter(key) {
const client = await getRedisClient();
await client.del(key);
}
/**
* Graceful Shutdown (für Tests)
*/
async function closeRedisClient() {
if (redisClient && redisClient.isOpen) {
await redisClient.quit();
redisClient = null;
}
}
module.exports = {
getRedisClient,
incrementRateLimitCounter,
getRateLimitCounter,
resetRateLimitCounter,
closeRedisClient,
};
Schritt 3: Rate Limiter auf Redis umstellen¶
Datei: core/functions/src/utils/rate_limiter.js
ERSETZE komplette Funktion checkRateLimit (Zeile 47-150):
/**
* Prüft ob ein User das Rate Limit erreicht hat (DISTRIBUTED mit Redis)
*
* SECURITY FIX K-03: Ersetzt In-Memory-Cache durch Redis-basiertes
* Distributed Rate Limiting. Verhindert Cold-Start-Bypass-Attacken.
*
* @param {string} userId - User ID
* @param {string} action - Action Name (z.B. 'createJob', 'deleteJob')
* @param {Object} options - Optionen: maxAttempts, windowSeconds, preset
* @throws {HttpsError} wenn Rate Limit erreicht
*/
async function checkRateLimit(userId, action, options = {}) {
const preset = options.preset || null;
const baseConfig = preset ? RATE_LIMITS[preset] : {
maxAttempts: 30,
windowSeconds: 60,
};
const config = {
maxAttempts: options.maxAttempts || baseConfig.maxAttempts,
windowSeconds: options.windowSeconds || baseConfig.windowSeconds,
};
const rateLimitKey = `ratelimit:${userId}:${action}`;
try {
// ════════════════════════════════════════════════════════════
// 🔒 SECURITY FIX K-03: Redis-basiertes Distributed Rate Limit
// ════════════════════════════════════════════════════════════
// Problem: In-Memory-Cache wird bei Cold Start resetted
// → Attacker kann durch parallele Requests neue
// Instances forcieren und Rate Limit umgehen
//
// Lösung: Redis (Memorystore) als Single Source of Truth
// → Alle Function-Instances teilen denselben Counter
// → Cold Start hat keinen Einfluss mehr
// ════════════════════════════════════════════════════════════
const { incrementRateLimitCounter } = require('./redis_client');
// Atomar inkrementieren (Lua Script verhindert Race Conditions)
const currentAttempts = await incrementRateLimitCounter(
rateLimitKey,
config.windowSeconds
);
if (currentAttempts > config.maxAttempts) {
// Rate Limit überschritten → Reject
const remainingSeconds = config.windowSeconds;
secureLogger.warn('Rate Limit überschritten', {
userId,
action,
attempts: currentAttempts,
maxAttempts: config.maxAttempts,
});
throw new HttpsError(
'resource-exhausted',
`Zu viele Anfragen. Bitte versuchen Sie es in ${remainingSeconds} Sekunden erneut.`
);
}
// Rate Limit OK
if (currentAttempts === 1) {
secureLogger.info('Rate Limit Check (neue Window)', {
userId,
action,
attempts: currentAttempts,
maxAttempts: config.maxAttempts,
});
}
} catch (error) {
if (error instanceof HttpsError) {
throw error;
}
// Redis nicht erreichbar → Fallback auf Firestore Transaction
secureLogger.error('Redis Rate Limit fehlgeschlagen, Fallback auf Firestore', {
userId,
action,
error: error.message,
});
// FALLBACK: Original Firestore-basierte Implementierung
return checkRateLimitFirestoreFallback(userId, action, config);
}
}
/**
* Firestore-basierter Fallback wenn Redis nicht verfügbar
* (behält ursprüngliche Firestore-Transaction-Logik)
*/
async function checkRateLimitFirestoreFallback(userId, action, config) {
const rateLimitId = `${userId}_${action}`;
const now = Date.now();
const windowMs = config.windowSeconds * 1000;
const rateLimitRef = admin.firestore()
.collection('_rateLimits')
.doc(rateLimitId);
await admin.firestore().runTransaction(async (transaction) => {
const rateLimitDoc = await transaction.get(rateLimitRef);
if (!rateLimitDoc.exists) {
const newEntry = { attempts: 1, windowStart: now, lastAttempt: now };
transaction.set(rateLimitRef, newEntry);
return;
}
const data = rateLimitDoc.data();
const windowAge = now - data.windowStart;
if (windowAge > windowMs) {
const newEntry = { attempts: 1, windowStart: now, lastAttempt: now };
transaction.set(rateLimitRef, newEntry);
return;
}
if (data.attempts >= config.maxAttempts) {
const remainingSeconds = Math.ceil((windowMs - windowAge) / 1000);
throw new HttpsError(
'resource-exhausted',
`Zu viele Anfragen. Bitte versuchen Sie es in ${remainingSeconds} Sekunden erneut.`
);
}
const updated = {
attempts: data.attempts + 1,
lastAttempt: now,
};
transaction.update(rateLimitRef, updated);
});
}
Schritt 4: Environment Variables setzen¶
Datei: core/functions/.env (lokal)
REDIS_HOST=10.x.x.x # Redis Memorystore IP
REDIS_PORT=6379
# REDIS_PASSWORD=xxx # nur wenn AUTH enabled
Firebase Functions Config (Production):
cd core/functions
firebase functions:config:set \
redis.host="10.x.x.x" \
redis.port="6379" \
--project ts-easy-sale-core
# Im Code dann abrufen mit:
# process.env.REDIS_HOST || functions.config().redis.host
Schritt 5: VPC Connector in Functions Config¶
Datei: core/functions/src/functions/auth.callable.js (und alle anderen Callables)
JEDE Callable Function MUSS VPC Connector nutzen:
const createAuthUser = onCall({
region: "europe-west3",
invoker: "public",
enforceAppCheck: true,
vpcConnector: "projects/PROJECT_ID/locations/europe-west3/connectors/functions-connector",
vpcConnectorEgressSettings: "PRIVATE_RANGES_ONLY", // Nur private IPs über VPC
}, async (request) => {
// ... existing logic
});
Alternative: Global Config in firebase.json:
{
"functions": [
{
"source": "core/functions",
"codebase": "core",
"runtime": "nodejs20",
"vpcConnector": "projects/ts-easy-sale-core/locations/europe-west3/connectors/functions-connector",
"vpcConnectorEgressSettings": "PRIVATE_RANGES_ONLY"
}
]
}
✅ LÖSUNG 2: Firestore-Only mit Transaction Everywhere (NO Redis)¶
Wenn Redis zu komplex / teuer → Firestore Transactions konsequent nutzen
Änderung: ALLE Rate Limits mit Firestore Transaction¶
Datei: core/functions/src/utils/rate_limiter.js
ERSETZE Zeile 68-87 (Cache-basierte Fast-Path):
async function checkRateLimit(userId, action, options = {}) {
// ... config setup wie vorher ...
const rateLimitId = `${userId}_${action}`;
const now = Date.now();
const windowMs = config.windowSeconds * 1000;
// ════════════════════════════════════════════════════════════
// 🔒 SECURITY FIX K-03 (Alternative): NO CACHE, ONLY FIRESTORE
// ════════════════════════════════════════════════════════════
// Entfernt In-Memory-Cache komplett → jeder Check ist eine
// Firestore Transaction. Langsamer, aber Cold-Start-sicher.
//
// Trade-Off:
// + Kein Cold-Start-Bypass möglich
// + Keine Redis-Infrastruktur nötig
// - +1 Firestore Read + 1 Write pro checkRateLimit Call
// - Kosten: ~$0.10/100k Calls (statt $0 mit Redis)
// ════════════════════════════════════════════════════════════
const rateLimitRef = admin.firestore()
.collection('_rateLimits')
.doc(rateLimitId);
try {
await admin.firestore().runTransaction(async (transaction) => {
const rateLimitDoc = await transaction.get(rateLimitRef);
if (!rateLimitDoc.exists) {
const newEntry = { attempts: 1, windowStart: now, lastAttempt: now };
transaction.set(rateLimitRef, newEntry);
return;
}
const data = rateLimitDoc.data();
const windowAge = now - data.windowStart;
if (windowAge > windowMs) {
// Window abgelaufen → Reset
const newEntry = { attempts: 1, windowStart: now, lastAttempt: now };
transaction.set(rateLimitRef, newEntry);
return;
}
if (data.attempts >= config.maxAttempts) {
const remainingSeconds = Math.ceil((windowMs - windowAge) / 1000);
throw new HttpsError(
'resource-exhausted',
`Zu viele Anfragen. Bitte versuchen Sie es in ${remainingSeconds} Sekunden erneut.`
);
}
// Inkrementiere Counter
const updated = {
attempts: data.attempts + 1,
lastAttempt: now,
};
transaction.update(rateLimitRef, updated);
});
} catch (error) {
if (error instanceof HttpsError) {
throw error;
}
console.error('⚠️ Rate Limit Check fehlgeschlagen:', error);
// Bei Transaction-Fehler → Rate Limit NICHT durchlassen
throw new HttpsError('internal', 'Rate Limit Check fehlgeschlagen');
}
}
Kosten-Vergleich:
| Implementierung | Setup | Laufende Kosten (100k Calls/Tag) | Cold-Start-Sicher? |
|---|---|---|---|
| Original (Cache) | $0 | $0 | ❌ Nein |
| Redis | $45/Monat | $45/Monat | ✅ Ja |
| Firestore-Only | $0 | $6/Monat (200k R/W) | ✅ Ja |
Empfehlung: Firestore-Only für MVP, Redis ab 1M Calls/Tag
🔴 K-04: DoS via Exponential Backoff in Triggers¶
Problem-Analyse¶
Aktueller Code (auth-claims.triggers.js, Zeile 54-95):
async function retryWithExponentialBackoff(fn, maxRetries = 5, baseDelayMs = 100) {
let lastError;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
if (attempt === maxRetries - 1) {
break;
}
const errorMessage = err.message?.toLowerCase() || '';
const isRetryable =
err.code === 'auth/quota-exceeded' ||
err.code === 'auth/internal-error' ||
errorMessage.includes('quota') ||
errorMessage.includes('rate') ||
errorMessage.includes('resource_exhausted');
if (!isRetryable) {
throw err;
}
// Exponential Backoff mit Equal Jitter
const maxDelay = baseDelayMs * Math.pow(2, attempt);
const delayMs = Math.ceil(maxDelay / 2 + Math.random() * (maxDelay / 2));
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
throw lastError;
}
Verwendet in:
- onErpUserDocumentCreated (Zeile 114)
- onShopUserPermissionChanged (Zeile 224)
DoS-Szenario:
Annahmen:
- Firebase Function Concurrency Limit: 1000 (Standard für Blaze Plan)
- Retries: 5× pro Trigger
- Delay: 50ms + 100ms + 200ms + 400ms + 800ms = 1.550ms total pro Trigger
Attacker Scenario:
T0: Attacker erstellt 1000 customerUsers-Dokumente parallel
→ 1000 onShopUserPermissionChanged Trigger starten
→ Jeder Trigger versucht setCustomUserClaims()
T1: Firebase Auth Rate Limit: 10 Writes/Sekunde (hypothetisch)
→ 990 Trigger bekommen "quota-exceeded"
→ 990 Trigger starten Retry 1 (nach 50-100ms)
T2: 990 Trigger retrying gleichzeitig
→ Nur 10 schaffen es, 980 scheitern wieder
→ 980 Trigger starten Retry 2 (nach 100-200ms)
T3: Function Concurrency jetzt bei:
- 1000 ursprüngliche Trigger (noch am retrying)
- 0 neue Trigger können starten
→ CONCURRENCY LIMIT ERREICHT
T4: Legitime Requests (createOrder, createAuthUser) werden abgelehnt:
→ "Function invocation exceeded rate limits"
→ SERVICE UNAVAILABLE
T5: Nach 1.550ms sind alle Retries durch
→ Service erholt sich
ABER:
Attacker kann wiederholen → DoS für 1-2 Sekunden alle 10 Sekunden
Real-World Impact: - Batch-Import von 5000 Kunden durch Admin → Trigger-Storm - Connector schreibt 1000 Articles gleichzeitig → license-quota.triggers DoS - Legitime User können keine Orders erstellen (Service Down)
Kosten: - 1000 Trigger × 5 Retries = 5000 Function Invocations - 5000 × $0.40 per 1M = $0.002 (minimal) - Echter Schaden: Reputation Loss durch Service Outages
✅ LÖSUNG: Circuit Breaker + Max Concurrency Limits¶
Schritt 1: Circuit Breaker Implementation¶
Neue Datei: core/functions/src/utils/circuit_breaker.js
/**
* Circuit Breaker Pattern für Cloud Functions
*
* Verhindert DoS durch zu viele Retries bei systemweiten Ausfällen.
*
* Zustände:
* - CLOSED: Alles OK, alle Requests durchlassen
* - OPEN: Zu viele Fehler, alle Requests sofort abbrechen (Fail Fast)
* - HALF_OPEN: Test-Phase, ein Request durchlassen um Recovery zu prüfen
*
* SECURITY FIX K-04: Bei Mass-Updates können tausende parallel laufende
* Trigger mit Exponential Backoff die Function Concurrency erschöpfen.
* Circuit Breaker erkennt systemweite Probleme und schaltet auf Fail-Fast.
*/
const admin = require('firebase-admin');
const secureLogger = require('./secure_logger');
// Circuit Breaker State (pro Function Instance)
const circuitBreakers = new Map();
// Circuit Breaker Config
const DEFAULT_CONFIG = {
failureThreshold: 5, // Nach 5 Fehlern → OPEN
successThreshold: 2, // Nach 2 Erfolgen in HALF_OPEN → CLOSED
timeout: 10000, // 10 Sekunden bis HALF_OPEN-Versuch
};
class CircuitBreaker {
constructor(name, config = {}) {
this.name = name;
this.state = 'CLOSED';
this.failureCount = 0;
this.successCount = 0;
this.nextAttempt = Date.now();
this.config = { ...DEFAULT_CONFIG, ...config };
}
async execute(fn) {
const now = Date.now();
if (this.state === 'OPEN') {
if (now < this.nextAttempt) {
// Circuit noch OPEN → Fail Fast
secureLogger.warn('Circuit Breaker OPEN - Fail Fast', {
name: this.name,
nextAttemptIn: this.nextAttempt - now,
});
throw new Error(`Circuit Breaker OPEN for ${this.name}`);
}
// Timeout abgelaufen → Wechsel zu HALF_OPEN
this.state = 'HALF_OPEN';
this.successCount = 0;
secureLogger.info('Circuit Breaker → HALF_OPEN', { name: this.name });
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (err) {
this.onFailure(err);
throw err;
}
}
onSuccess() {
this.failureCount = 0;
if (this.state === 'HALF_OPEN') {
this.successCount++;
if (this.successCount >= this.config.successThreshold) {
this.state = 'CLOSED';
secureLogger.info('Circuit Breaker → CLOSED (recovered)', {
name: this.name,
});
}
}
}
onFailure(err) {
this.failureCount++;
if (this.state === 'HALF_OPEN') {
// Fehlschlag in HALF_OPEN → zurück zu OPEN
this.state = 'OPEN';
this.nextAttempt = Date.now() + this.config.timeout;
secureLogger.warn('Circuit Breaker → OPEN (HALF_OPEN failed)', {
name: this.name,
error: err.message,
});
} else if (this.failureCount >= this.config.failureThreshold) {
this.state = 'OPEN';
this.nextAttempt = Date.now() + this.config.timeout;
secureLogger.error('Circuit Breaker → OPEN', {
name: this.name,
failureCount: this.failureCount,
error: err.message,
});
}
}
getState() {
return {
name: this.name,
state: this.state,
failureCount: this.failureCount,
successCount: this.successCount,
nextAttempt: this.nextAttempt,
};
}
}
/**
* Holt oder erstellt Circuit Breaker für einen Service
*/
function getCircuitBreaker(name, config) {
if (!circuitBreakers.has(name)) {
circuitBreakers.set(name, new CircuitBreaker(name, config));
}
return circuitBreakers.get(name);
}
/**
* Wrapper für retryWithExponentialBackoff mit Circuit Breaker
*/
async function retryWithCircuitBreaker(name, fn, options = {}) {
const circuitBreaker = getCircuitBreaker(name, options.circuitBreakerConfig);
return circuitBreaker.execute(async () => {
// Exponential Backoff INNERHALB des Circuit Breakers
return retryWithExponentialBackoff(fn, options.maxRetries, options.baseDelayMs);
});
}
/**
* Original Exponential Backoff (unverändert)
*/
async function retryWithExponentialBackoff(fn, maxRetries = 3, baseDelayMs = 100) {
let lastError;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
if (attempt === maxRetries - 1) {
break;
}
const errorMessage = err.message?.toLowerCase() || '';
const isRetryable =
err.code === 'auth/quota-exceeded' ||
err.code === 'auth/internal-error' ||
errorMessage.includes('quota') ||
errorMessage.includes('rate') ||
errorMessage.includes('resource_exhausted');
if (!isRetryable) {
throw err;
}
const maxDelay = baseDelayMs * Math.pow(2, attempt);
const delayMs = Math.ceil(maxDelay / 2 + Math.random() * (maxDelay / 2));
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
throw lastError;
}
/**
* Status aller Circuit Breakers (für Monitoring)
*/
function getCircuitBreakerStats() {
const stats = [];
circuitBreakers.forEach(cb => {
stats.push(cb.getState());
});
return stats;
}
module.exports = {
CircuitBreaker,
getCircuitBreaker,
retryWithCircuitBreaker,
retryWithExponentialBackoff,
getCircuitBreakerStats,
};
Schritt 2: Trigger auf Circuit Breaker umstellen¶
Datei: core/functions/src/triggers/auth-claims.triggers.js
ERSETZE Import und retryWithExponentialBackoff (Zeile 1-95):
const { onDocumentCreated, onDocumentWritten } = require("firebase-functions/v2/firestore");
const admin = require("firebase-admin");
const secureLogger = require("../utils/secure_logger");
const { logTriggerExecution } = require("../shared/trigger_logger");
// ═══════════════════════════════════════════════════════════════
// 🔒 SECURITY FIX K-04: Circuit Breaker statt direktem Retry
// ═══════════════════════════════════════════════════════════════
const { retryWithCircuitBreaker } = require("../utils/circuit_breaker");
/**
* Circuit-Breaker-geschützter Wrapper für setCustomUserClaims
*
* Verhindert Function Concurrency Exhaustion bei Mass-Updates:
* - Wenn Firebase Auth überlastet ist (quota-exceeded), schaltet
* der Circuit Breaker nach 5 Fehlern auf OPEN
* - Weitere Trigger scheitern sofort (Fail Fast) statt 5× zu retrying
* - Nach 10 Sekunden wird HALF_OPEN-Versuch gemacht
*/
async function setCustomUserClaimsWithCircuitBreaker(uid, claims, circuitName = 'firebase-auth') {
return retryWithCircuitBreaker(
circuitName,
async () => {
await admin.auth().setCustomUserClaims(uid, claims);
},
{
maxRetries: 3, // Reduziert von 5 auf 3
baseDelayMs: 100,
circuitBreakerConfig: {
failureThreshold: 5, // Nach 5 Fehlern → OPEN
successThreshold: 2, // Nach 2 Erfolgen → CLOSED
timeout: 10000, // 10s bis HALF_OPEN-Versuch
},
}
);
}
ERSETZE alle retryWithExponentialBackoff Calls (Zeile 114, 224):
// ALT:
await retryWithExponentialBackoff(async () => {
await admin.auth().setCustomUserClaims(uid, { appType: "erp" });
});
// NEU:
await setCustomUserClaimsWithCircuitBreaker(uid, { appType: "erp" }, 'erp-user-claims');
Schritt 3: Max Concurrency Limits setzen¶
Alle Trigger MÜSSEN Concurrency Limits haben:
Datei: core/functions/src/triggers/auth-claims.triggers.js
exports.onErpUserDocumentCreated = onDocumentCreated({
document: "users/{uid}",
region: "europe-west3",
// ═══════════════════════════════════════════════════════════════
// 🔒 SECURITY FIX K-04: Max Concurrency Limit
// ═══════════════════════════════════════════════════════════════
// Begrenzt parallele Ausführungen dieses Triggers.
// Verhindert dass bei Batch-Import 5000 Trigger gleichzeitig laufen.
//
// Trade-Off:
// + Schützt vor Function Concurrency Exhaustion
// + Verhindert Firebase Auth Rate-Limit-Überschreitungen
// - Batch-Operationen werden langsamer (Queue-Bildung)
//
// 50 = Sweet Spot zwischen Performance und Stabilität
// ═══════════════════════════════════════════════════════════════
concurrency: 50,
// Rate Limiting auf Trigger-Ebene
maxInstances: 100, // Max 100 Instances dieses Triggers
}, async (event) => {
// ... existing logic
});
exports.onShopUserPermissionChanged = onDocumentWritten({
document: "customerUsers/{docId}",
region: "europe-west3",
concurrency: 50, // Max 50 parallele Claims-Updates
maxInstances: 100,
}, async (event) => {
// ... existing logic
});
ALLE anderen Triggers ebenfalls:
// license-quota.triggers.js
exports.onCustomerCreatedCheckQuota = onDocumentCreated({
document: "customers/{customerId}",
region: "europe-west3",
concurrency: 100, // Quota-Checks sind schnell, können mehr parallel laufen
maxInstances: 200,
}, async (event) => {
// ...
});
// customer-feed.triggers.js
exports.onCustomerFeedCreated = onDocumentCreated({
document: "customerFeeds/{feedId}",
region: "europe-west3",
concurrency: 10, // Feed-Processing ist teuer (viele Firestore Writes)
maxInstances: 50,
}, async (event) => {
// ...
});
Schritt 4: Monitoring & Alerting¶
Neue Datei: core/functions/src/functions/monitoring.callable.js
/**
* Admin-Funktion: Circuit Breaker Status abrufen
*/
const { onCall } = require('firebase-functions/v2/https');
const { requireAdmin } = require('../utils/security');
const { getCircuitBreakerStats } = require('../utils/circuit_breaker');
exports.getCircuitBreakerStatus = onCall({
region: 'europe-west3',
invoker: 'public',
enforceAppCheck: true,
}, async (request) => {
await requireAdmin(request);
const stats = getCircuitBreakerStats();
return {
timestamp: Date.now(),
circuitBreakers: stats,
};
});
Cloud Monitoring Alert:
# alert_policies.yaml (Google Cloud Monitoring)
displayName: "Function Concurrency > 80%"
conditions:
- displayName: "High Function Concurrency"
conditionThreshold:
filter: 'resource.type="cloud_function" AND metric.type="cloudfunctions.googleapis.com/function/active_instances"'
comparison: COMPARISON_GT
thresholdValue: 800 # 80% von 1000
duration: 60s
notificationChannels:
- projects/PROJECT_ID/notificationChannels/CHANNEL_ID
🚀 Rollout-Strategie¶
Phase 1: K-03 (Rate Limiter) — 2 Wochen¶
Option A: Redis (Recommended)¶
- [ ] Woche 1: Redis Memorystore provisionieren
- [ ] Terraform/Bicep für Redis + VPC Connector
- [ ] Redis Client implementieren (
redis_client.js) - [ ] Rate Limiter auf Redis umstellen
-
[ ] Lokale Tests mit Redis Emulator
-
[ ] Woche 2: Deployment & Validation
- [ ] Deploy auf Dev-Environment
- [ ] Load Test: 1000 parallele Requests → Rate Limit hält
- [ ] Cold-Start-Test: neue Instances bekommen Redis-Counter
- [ ] Deploy auf Production
- [ ] Monitoring: Redis Latency < 5ms
Option B: Firestore-Only (Schneller)¶
- [ ] Tag 1-2: Code-Änderungen
- [ ] Cache entfernen aus
checkRateLimit - [ ] Firestore Transaction everywhere
-
[ ] Tests schreiben
-
[ ] Tag 3-4: Deployment
- [ ] Deploy auf Dev
- [ ] Load Test: Rate Limit funktioniert bei Cold Start
- [ ] Deploy auf Production
Kosten: Redis: $45/Monat | Firestore-Only: $6/Monat
Phase 2: K-04 (Circuit Breaker) — 1 Woche¶
- [ ] Tag 1-2: Implementation
- [ ] Circuit Breaker implementieren (
circuit_breaker.js) - [ ]
retryWithCircuitBreakerWrapper erstellen -
[ ] Auth-Claims-Trigger umstellen
-
[ ] Tag 3-4: Concurrency Limits setzen
- [ ] ALLE Trigger mit
concurrency&maxInstancesversehen -
[ ] Concurrency-Werte optimieren (Profiling)
-
[ ] Tag 5: Deployment & Monitoring
- [ ] Deploy auf Dev
- [ ] Batch-Test: 5000 customerUsers anlegen → kein DoS
- [ ] Circuit Breaker Status API testen
- [ ] Deploy auf Production
- [ ] Cloud Monitoring Alerts aktivieren
✅ Erfolgskriterien¶
K-03: Rate Limiter Cold-Start-Bypass behoben¶
- [ ] Load Test: 1000 parallele createOrder-Calls → max 30 durchgekommen
- [ ] Cold-Start-Test: 100 neue Function Instances → Rate Limit funktioniert
- [ ] Attack Simulation: Parallele Requests können Limit nicht umgehen
- [ ] Performance: Rate-Limit-Check < 10ms (p95)
K-04: DoS via Exponential Backoff verhindert¶
- [ ] Batch-Test: 5000 users anlegen → Function Concurrency < 80%
- [ ] Circuit Breaker reagiert: Nach 5 Auth-Fehlern → OPEN
- [ ] HALF_OPEN-Recovery: Nach 10s wird Recovery-Versuch gemacht
- [ ] Legitime Requests: createOrder funktioniert auch bei Trigger-Storm
⚠️ Rollback-Plan¶
K-03 (Rate Limiter)¶
Redis-Version Rollback:
# Functions auf vorherige Version zurücksetzen
firebase deploy --only functions:checkRateLimit --project PROD
# Redis löschen (falls fehlerhaft)
gcloud redis instances delete easysale-rate-limiter --region=europe-west3
Firestore-Only Rollback:
K-04 (Circuit Breaker)¶
Function Rollback:
# Auf vorherige Version (ohne Circuit Breaker)
firebase deploy --only functions --project PROD --force
Concurrency Limits entfernen:
📊 Testing Checklist¶
K-03 Tests¶
Unit Tests: - [ ] Redis Client: connect, increment, get, reset - [ ] Rate Limiter mit Redis: erste Request OK, 11. Request rejected - [ ] Firestore Fallback: funktioniert wenn Redis down
Integration Tests: - [ ] Cold Start: neue Function Instance hat Zugriff auf Redis-Counter - [ ] Parallel Load: 100 Requests gleichzeitig → Rate Limit hält - [ ] Redis Down: Fallback auf Firestore funktioniert
Attack Simulation: - [ ] 1000 parallele createAuthUser-Calls → max 10 durchgekommen - [ ] Cold-Start-Forcing: Requests an verschiedene Regions → Rate Limit global
K-04 Tests¶
Unit Tests: - [ ] Circuit Breaker: CLOSED → OPEN nach 5 Fehlern - [ ] Circuit Breaker: OPEN → HALF_OPEN nach 10 Sekunden - [ ] Circuit Breaker: HALF_OPEN → CLOSED nach 2 Erfolgen - [ ] retryWithCircuitBreaker: Retry + Circuit Breaker kombiniert
Integration Tests: - [ ] Batch User Creation: 5000 users → Function Concurrency < 80% - [ ] Circuit Breaker Status API: /getCircuitBreakerStatus funktioniert - [ ] Trigger mit maxInstances: nur N Instances laufen parallel
Load Tests: - [ ] 10.000 customerUsers gleichzeitig ändern → kein DoS - [ ] Circuit Breaker öffnet bei Firebase Auth Overload - [ ] Legitime createOrder-Calls funktionieren trotz Trigger-Storm
💰 ROI-Berechnung¶
K-03: Rate Limiter Bypass Fix¶
Kosten ohne Fix (bei Angriff): - DDoS Attack: $1.320/Stunde - Reputation Loss: unbezifferbar
Kosten mit Fix: - Redis: $45/Monat - Firestore-Only: $6/Monat
Break-Even: Nach 2 Minuten Angriff (Redis) oder 10 Minuten (Firestore-Only)
K-04: DoS via Exponential Backoff Fix¶
Kosten ohne Fix: - Service Outages: 5-10 Min bei jedem Batch-Import - Support-Tickets: 10-20 pro Outage × $50 = $500-$1.000 - Customer Churn: schwer zu quantifizieren
Kosten mit Fix: - Implementation: 1 Woche × $500/Tag = $3.500 - Laufende Kosten: $0 (nur Config-Änderung)
Break-Even: Nach 4-7 Outages (= 1-2 Monate bei aktivem Betrieb)
🧭 4-ROLLEN ANALYSE¶
Challenger: Was wird unterschätzt?¶
K-03: - Redis Memorystore braucht VPC Connector → zusätzliche 10-15ms Latency auf JEDEM Function Call - Firestore-Only-Lösung ist teurer bei hoher Last (1M Calls/Tag = $60/Monat statt $45 Redis) - Firestore Transaction Conflicts bei extrem hoher Load (10k+ Requests/Sekunde) → Retry-Storm
K-04: - Concurrency Limits können legitime Batch-Operationen verlangsamen (Admin importiert 10k Kunden → dauert 10× länger) - Circuit Breaker funktioniert nur pro Function Instance → bei 100 Instances haben wir 100 separate Circuit Breakers (nicht shared) - Cloud Monitoring Alerts für Function Concurrency sind nicht real-time (2-5 Min Delay) → Outage kann schon vorbei sein
Builder: Quick Wins¶
K-03:
- Firestore-Only-Lösung ist in 4 Stunden implementiert (kein Redis Setup nötig)
- Rate Limiter kann per Environment Variable umgeschaltet werden: USE_REDIS=false → Firestore Fallback
K-04:
- Concurrency Limits sind YAML-Config → kein Code-Change nötig, nur Function-Redeployment
- Circuit Breaker kann optional gemacht werden: Env Var CIRCUIT_BREAKER_ENABLED=false → deaktiviert
CTO: Business Risiko¶
K-03: - Ohne Fix: 1 erfolgreiche DDoS-Attacke = $1.320/Stunde + Reputation Loss → Deal-Breaker für Enterprise-Kunden - Mit Fix (Redis): $45/Monat Extra-Kosten, aber 99.9% DDoS-Protection
K-04: - Ohne Fix: Jeder größere Batch-Import = 5-10 Min Service Outage → Kunden beschweren sich - Mit Fix: Batch-Operationen werden langsamer (Queue-Bildung), aber Service bleibt stabil
Architekt: Langfristige Probleme¶
K-03: - Redis Memorystore ist Single Point of Failure → wenn Redis down, müssen alle Functions auf Firestore Fallback - Firestore Transaction-basierter Rate Limiter hat Contention-Problem bei >10k Requests/Sekunde auf denselben User
K-04: - Circuit Breaker State ist In-Memory → bei Function Scale-Down gehen Informationen verloren (Circuit öffnet sich neu) - Concurrency Limits sind global per Trigger → ein böswilliger User kann Trigger für alle User blockieren (Denial of Service durch Monopolization)
Langfristige Lösung: - K-03: Upgrade auf Distributed Rate Limiter mit Firestore + Memcache (Hybrid) - K-04: Shared Circuit Breaker State in Redis statt In-Memory (Function-übergreifend)
✅ Würdest du das so in einem echten Produkt shippen?¶
JA, mit Einschränkungen.
K-03: Rate Limiter Fix¶
Empfehlung: Firestore-Only für MVP, Redis ab Scale
Begründung: - Firestore-Only ist in 4 Stunden implementiert und kostet nur $6/Monat - Redis braucht VPC Connector (Komplexität ++) und kostet $45/Monat - Für <100k Requests/Tag ist Firestore-Only ausreichend - Ab 1M Requests/Tag wird Redis günstiger und performanter
Bedingung: Firestore Fallback MUSS existieren (wenn Redis down ist)
K-04: Circuit Breaker + Concurrency Limits¶
Empfehlung: Concurrency Limits SOFORT, Circuit Breaker OPTIONAL
Begründung: - Concurrency Limits sind Risk-Free (nur Config-Änderung, kein Code-Risk) - Circuit Breaker ist Nice-to-Have (schützt vor Edge Cases, aber nicht business-critical) - Bei realistischer Last (< 1000 Trigger/Min) reichen Concurrency Limits
Bedingung: Circuit Breaker MUSS deaktivierbar sein (Feature Flag), falls Bugs auftreten
🚀 Konkreter nächster Schritt¶
Empfohlene Reihenfolge:¶
- K-03 Firestore-Only (4 Stunden)
- Cache aus
checkRateLimitentfernen - Firestore Transaction everywhere
- Deploy auf Dev + Test
-
Deploy auf Production
-
K-04 Concurrency Limits (2 Stunden)
- Alle Trigger mit
concurrency: 50, maxInstances: 100versehen - Deploy auf Dev + Batch-Test
-
Deploy auf Production
-
K-03 Redis (optional, später)
- Wenn Firestore-Kosten > $50/Monat
- Redis Memorystore + VPC Connector
-
2 Wochen Implementierung
-
K-04 Circuit Breaker (optional, später)
- Wenn Trigger-Storm-Probleme auftreten
- Circuit Breaker Implementation
- 1 Woche Implementierung
Total Quick Wins: 6 Stunden für K-03 (Firestore) + K-04 (Concurrency Limits)
Total Full Solution: 3 Wochen für K-03 (Redis) + K-04 (Circuit Breaker)