Zum Inhalt springen

Serverless-Sicherheit: Angriffsflächen und Gegenmaßnahmen

Die besonderen Sicherheitsrisiken serverloser Architekturen: Injection über Event-Quellen, überberechtigte Funktionen und verwundbare Abhängigkeiten.

5 Min. Lesezeit
Diagramm einer Serverless-Architektur mit Markierungen der Angriffsflächen und Sicherheitskontrollpunkten

Das Missverständnis über Serverless-Sicherheit

Serverless bedeutet nicht automatisch sicher. Das Modell der geteilten Verantwortung verlagert die Sicherheit der Infrastruktur zum Cloud-Anbieter, doch die Sicherheit der Anwendung – Eingabevalidierung, Autorisierung, Secrets-Management, Pflege der Abhängigkeiten – bleibt vollständig in der eigenen Verantwortung. In vielerlei Hinsicht bringt Serverless neue Angriffsflächen mit sich, die klassische Architekturen so nicht kennen.

Funktionen, die durch unterschiedlichste Event-Quellen ausgelöst werden (API Gateway, S3-Events, SQS-Nachrichten, CloudWatch-Events), eröffnen Angriffsmöglichkeiten durch Injection, die Entwickler übersehen, wenn sie nur an die Validierung von HTTP-Eingaben gewöhnt sind. Jede Event-Quelle ist eine Angriffsfläche.

Injection-Angriffe über Event-Quellen

In klassischen Anwendungen gelangen Eingaben über HTTP-Requests ins System. Bei Serverless kommen sie über Event-Objekte aus vielen unterschiedlichen Quellen. Jede Quelle hat eine eigene Datenstruktur und eigene Angriffsvektoren für Injection.

tstypescript
// ❌ Trusting event data without validation
export async function handler(event: S3Event): Promise<void> {
  const bucket = event.Records[0].s3.bucket.name;
  const key = event.Records[0].s3.object.key;
 
  // Dangerous: key could contain path traversal characters
  const localPath = `/tmp/${key}`;
  await downloadFile(bucket, key, localPath);
 
  // Dangerous: passing unvalidated key to shell command
  await exec(`convert ${localPath} -resize 200x200 ${localPath}.thumb.jpg`);
}
 
// ✅ Validating and sanitizing event source data
import { z } from "zod";
import path from "path";
 
const s3EventSchema = z.object({
  Records: z.array(
    z.object({
      s3: z.object({
        bucket: z.object({
          name: z.string().regex(/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/),
        }),
        object: z.object({
          key: z.string().max(1024),
        }),
      }),
    })
  ),
});
 
export async function secureHandler(event: unknown): Promise<void> {
  const parsed = s3EventSchema.parse(event);
  const record = parsed.Records[0];
 
  const key = record.s3.object.key;
  const bucket = record.s3.bucket.name;
 
  // Validate key doesn't contain path traversal
  const sanitizedKey = path.basename(key);
  if (sanitizedKey !== key || key.includes("..")) {
    throw new Error(`Invalid S3 key: ${key}`);
  }
 
  // Validate file extension
  const allowedExtensions = [".jpg", ".jpeg", ".png", ".webp"];
  const ext = path.extname(sanitizedKey).toLowerCase();
  if (!allowedExtensions.includes(ext)) {
    throw new Error(`Unsupported file type: ${ext}`);
  }
 
  const localPath = path.join("/tmp", sanitizedKey);
  await downloadFile(bucket, sanitizedKey, localPath);
 
  // Use array form to avoid shell injection
  await execFile("convert", [
    localPath,
    "-resize",
    "200x200",
    `${localPath}.thumb.jpg`,
  ]);
}

Die sichere Variante validiert die Struktur des Events, bereinigt den S3-Key gegen Path Traversal, prüft die Dateiendung und verwendet execFile statt exec, um Shell-Injection zu verhindern.

IAM-Richtlinien nach dem Least-Privilege-Prinzip

Zu großzügig berechtigte Lambda-Funktionen zählen zu den häufigsten Sicherheitsproblemen bei Serverless. Eine Funktion, die Bilder verarbeitet, sollte keinen Zugriff auf DynamoDB, SES oder andere Dienste haben, die sie gar nicht nutzt.

tstypescript
// ❌ Over-permissioned: function can do anything
const overPermissioned = {
  Effect: "Allow",
  Action: "*",
  Resource: "*",
};
 
// ✅ Least-privilege: only the permissions the function needs
interface LambdaPermission {
  functionName: string;
  permissions: {
    service: string;
    actions: string[];
    resources: string[];
    conditions?: Record<string, Record<string, string>>;
  }[];
}
 
const imageProcessorPermissions: LambdaPermission = {
  functionName: "image-processor",
  permissions: [
    {
      service: "s3",
      actions: ["s3:GetObject"],
      resources: ["arn:aws:s3:::upload-bucket/*"],
      conditions: {
        StringEquals: {
          "s3:ExistingObjectTag/validated": "true",
        },
      },
    },
    {
      service: "s3",
      actions: ["s3:PutObject"],
      resources: ["arn:aws:s3:::processed-bucket/*"],
      conditions: {
        StringEquals: {
          "s3:x-amz-server-side-encryption": "aws:kms",
        },
      },
    },
    {
      service: "logs",
      actions: [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents",
      ],
      resources: [
        "arn:aws:logs:us-east-1:123456789:log-group:/aws/lambda/image-processor:*",
      ],
    },
  ],
};

Jede Funktion erhält nur die Berechtigungen, die sie tatsächlich braucht, beschränkt auf die konkreten Ressourcen, mit denen sie arbeitet. Bedingungen schränken den Zugriff zusätzlich ein: Der Bildprozessor darf nur Objekte lesen, die als validiert markiert sind, und muss beim Schreiben Verschlüsselung verwenden.

Secrets-Management bei Serverless

Umgebungsvariablen sind der Standardweg, um Konfiguration an Lambda-Funktionen zu übergeben, sind aber in der AWS-Konsole und in CloudFormation-Templates sichtbar. Secrets sollten zur Laufzeit aus einem Secrets-Manager geladen werden.

tstypescript
import {
  SecretsManagerClient,
  GetSecretValueCommand,
} from "@aws-sdk/client-secrets-manager";
 
// Cache secrets to avoid calling Secrets Manager on every invocation
let cachedSecrets: Record<string, string> | null = null;
let cacheTimestamp = 0;
const CACHE_TTL = 300000; // 5 minutes
 
async function getSecrets(): Promise<Record<string, string>> {
  if (cachedSecrets && Date.now() - cacheTimestamp < CACHE_TTL) {
    return cachedSecrets;
  }
 
  const client = new SecretsManagerClient({});
  const command = new GetSecretValueCommand({
    SecretId: process.env.SECRET_ARN,
  });
 
  const response = await client.send(command);
 
  if (!response.SecretString) {
    throw new Error("Secret value is empty");
  }
 
  cachedSecrets = JSON.parse(response.SecretString);
  cacheTimestamp = Date.now();
  return cachedSecrets!;
}
 
// Usage in handler
export async function handler(event: APIGatewayEvent): Promise<APIGatewayResult> {
  const secrets = await getSecrets();
  const dbConnection = await connectToDatabase(secrets.DATABASE_URL);
  // Process request...
}

Die Funktion kennt in ihren Umgebungsvariablen nur die ARN des Secrets – die eigentlichen Werte werden zur Laufzeit abgerufen und für die Lebensdauer der Lambda-Ausführungsumgebung zwischengespeichert.

Autorisierung auf Funktionsebene

Jede Serverless-Funktion sollte die Autorisierung eigenständig prüfen. Verlass dich nicht ausschließlich auf die Authorizer des API Gateways – Defense in Depth bedeutet, dass jede Schicht die Berechtigungen selbst kontrolliert.

tstypescript
interface AuthContext {
  userId: string;
  roles: string[];
  permissions: string[];
  tenantId: string;
}
 
function extractAuthContext(event: APIGatewayEvent): AuthContext {
  const claims = event.requestContext.authorizer?.claims;
  if (!claims) {
    throw new UnauthorizedError("No authorization context");
  }
 
  return {
    userId: claims.sub,
    roles: (claims["custom:roles"] || "").split(","),
    permissions: (claims["custom:permissions"] || "").split(","),
    tenantId: claims["custom:tenant_id"],
  };
}
 
function requirePermission(
  auth: AuthContext,
  permission: string
): void {
  if (!auth.permissions.includes(permission)) {
    throw new ForbiddenError(
      `Missing required permission: ${permission}`
    );
  }
}
 
function requireTenantAccess(
  auth: AuthContext,
  resourceTenantId: string
): void {
  if (auth.tenantId !== resourceTenantId) {
    throw new ForbiddenError("Cross-tenant access denied");
  }
}
 
// Usage in handler
export async function deleteUserHandler(
  event: APIGatewayEvent
): Promise<APIGatewayResult> {
  const auth = extractAuthContext(event);
  requirePermission(auth, "users:delete");
 
  const userId = event.pathParameters?.userId;
  if (!userId) {
    return { statusCode: 400, body: "Missing userId" };
  }
 
  const user = await getUserById(userId);
  requireTenantAccess(auth, user.tenantId);
 
  await deleteUser(userId);
  return { statusCode: 204, body: "" };
}

Umgang mit Schwachstellen in Abhängigkeiten

Serverless-Funktionen bestehen oft nur aus wenig eigenem Code, ziehen dabei aber Dutzende Abhängigkeiten nach sich. Jede Abhängigkeit ist eine Angriffsfläche.

tstypescript
// package.json auditing script
import { execSync } from "child_process";
 
interface AuditResult {
  vulnerabilities: {
    critical: number;
    high: number;
    moderate: number;
    low: number;
  };
}
 
function auditDependencies(): AuditResult {
  try {
    const output = execSync("npm audit --json", {
      encoding: "utf-8",
    });
    return JSON.parse(output);
  } catch (error: unknown) {
    // npm audit exits non-zero when vulnerabilities exist
    const err = error as { stdout?: string };
    if (err.stdout) {
      return JSON.parse(err.stdout);
    }
    throw error;
  }
}
 
function enforcePolicy(result: AuditResult): void {
  const { critical, high } = result.vulnerabilities;
 
  if (critical > 0) {
    throw new Error(
      `Deployment blocked: ${critical} critical vulnerabilities found`
    );
  }
 
  if (high > 0) {
    console.warn(
      `Warning: ${high} high-severity vulnerabilities found`
    );
  }
}
 
// Run as CI/CD gate
const audit = auditDependencies();
enforcePolicy(audit);
tstypescript
// Minimize dependencies by auditing what's actually used
import { readFileSync } from "fs";
 
function findUnusedDependencies(
  packageJsonPath: string,
  srcDir: string
): string[] {
  const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
  const deps = Object.keys(pkg.dependencies || {});
  const unused: string[] = [];
 
  for (const dep of deps) {
    const importPattern = new RegExp(
      `(?:require|from)\\s*['"(]${dep.replace("/", "\\/")}`,
    );
 
    const files = getAllSourceFiles(srcDir);
    const isUsed = files.some((file) => {
      const content = readFileSync(file, "utf-8");
      return importPattern.test(content);
    });
 
    if (!isUsed) {
      unused.push(dep);
    }
  }
 
  return unused;
}

Loggen, ohne Daten preiszugeben

Logs von Serverless-Funktionen enthalten häufig sensible Daten, weil Entwickler während der Entwicklung ausführliches Logging einbauen und es später zu entfernen vergessen.

tstypescript
// ❌ Logging sensitive data
export async function badHandler(event: APIGatewayEvent) {
  console.log("Received event:", JSON.stringify(event));
  // Logs authorization headers, cookies, user tokens
 
  const user = await authenticateUser(event);
  console.log("Authenticated user:", JSON.stringify(user));
  // Logs email, password hash, session tokens
}
 
// ✅ Structured logging with sensitive field redaction
const SENSITIVE_FIELDS = new Set([
  "password",
  "token",
  "authorization",
  "cookie",
  "secret",
  "creditCard",
  "ssn",
]);
 
function sanitizeForLogging(obj: unknown): unknown {
  if (typeof obj !== "object" || obj === null) return obj;
  if (Array.isArray(obj)) return obj.map(sanitizeForLogging);
 
  const sanitized: Record<string, unknown> = {};
  for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
    if (SENSITIVE_FIELDS.has(key.toLowerCase())) {
      sanitized[key] = "[REDACTED]";
    } else {
      sanitized[key] = sanitizeForLogging(value);
    }
  }
  return sanitized;
}
 
function secureLog(message: string, data?: unknown): void {
  const sanitized = data ? sanitizeForLogging(data) : undefined;
  console.log(JSON.stringify({ message, data: sanitized, timestamp: new Date().toISOString() }));
}

Die wichtigsten Erkenntnisse

Sicherheit bei Serverless erfordert ein Umdenken: weg von der Perimeterverteidigung, hin zur Verteidigung auf Funktionsebene. Jede Event-Quelle ist eine Angriffsfläche, die Eingabevalidierung braucht – nicht nur HTTP-Requests. Wende Least-Privilege-IAM-Richtlinien an, die für jede Funktion auf die konkreten Ressourcen und Aktionen beschränkt sind.

Lade Secrets zur Laufzeit aus einem Secrets-Manager, statt sie in Umgebungsvariablen zu speichern. Implementiere Autorisierungsprüfungen innerhalb jeder Funktion, nicht nur auf Ebene des API Gateways. Prüfe Abhängigkeiten fortlaufend auf Schwachstellen und blockiere Deployments, wenn kritische Schwachstellen gefunden werden. Bereinige jede Log-Ausgabe, um das Preisgeben sensibler Daten zu verhindern.

Das Serverless-Modell nimmt viele Sicherheitsfragen der Infrastruktur ab, bündelt aber die sicherheitsrelevanten Entscheidungen der Anwendung in jedem einzelnen Funktionsaufruf. Behandle jede Funktion als eigene Sicherheitsgrenze.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX