Zum Inhalt

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

  • [ ] 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)
  • [ ] retryWithCircuitBreaker Wrapper erstellen
  • [ ] Auth-Claims-Trigger umstellen

  • [ ] Tag 3-4: Concurrency Limits setzen

  • [ ] ALLE Trigger mit concurrency & maxInstances versehen
  • [ ] 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:

# Git Revert
git revert <commit-hash>
firebase deploy --only functions --project PROD

K-04 (Circuit Breaker)

Function Rollback:

# Auf vorherige Version (ohne Circuit Breaker)
firebase deploy --only functions --project PROD --force

Concurrency Limits entfernen:

// Entferne aus allen Trigger-Configs:
concurrency: 50,       // DELETE
maxInstances: 100,     // DELETE


📊 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:

  1. K-03 Firestore-Only (4 Stunden)
  2. Cache aus checkRateLimit entfernen
  3. Firestore Transaction everywhere
  4. Deploy auf Dev + Test
  5. Deploy auf Production

  6. K-04 Concurrency Limits (2 Stunden)

  7. Alle Trigger mit concurrency: 50, maxInstances: 100 versehen
  8. Deploy auf Dev + Batch-Test
  9. Deploy auf Production

  10. K-03 Redis (optional, später)

  11. Wenn Firestore-Kosten > $50/Monat
  12. Redis Memorystore + VPC Connector
  13. 2 Wochen Implementierung

  14. K-04 Circuit Breaker (optional, später)

  15. Wenn Trigger-Storm-Probleme auftreten
  16. Circuit Breaker Implementation
  17. 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)