Zum Inhalt springen

Serverless-Funktionen absichern: Verteidigungsmuster

Verstehe die besonderen Sicherheitsrisiken serverloser Architekturen und setze praxisnahe Verteidigungsmuster für AWS Lambda und Azure Functions um.

6 Min. Lesezeit
Diagramm eines Sicherheitsschilds, das die Angriffsflächen von Serverless-Funktionen und ihre Verteidigungsebenen zeigt

Serverless bedeutet nicht sorglos. Cloud-Anbieter kümmern sich zwar um die Sicherheit der Infrastruktur, aber die Anwendungsschicht liegt vollständig in deiner Verantwortung. Serverless-Funktionen bringen eigene Angriffsflächen mit sich, die klassische Sicherheitsmodelle nur unzureichend abdecken.

Die flüchtige Natur der Funktionen, gemeinsam genutzte Ausführungsumgebungen und ereignisgesteuerte Aufrufmuster bringen Sicherheitsherausforderungen mit sich, die ein anderes Denken erfordern. Du kannst nicht einfach dein Playbook für Container-Sicherheit übernehmen – du brauchst Strategien, die speziell für dieses Paradigma entwickelt wurden.

Die Angriffsfläche von Serverless

Serverless-Funktionen empfangen Events aus ganz unterschiedlichen Quellen: HTTP-Anfragen, Message Queues, Storage-Trigger, geplante Events. Jede Quelle ist ein potenzieller Angriffsvektor, doch viele Entwickler validieren nur HTTP-Eingaben.

tstypescript
// ❌ Only considering HTTP input validation
export const handler = async (event: APIGatewayEvent) => {
  const body = JSON.parse(event.body ?? "{}");
  // Only validates API Gateway events
  const name = body.name;
  return { statusCode: 200, body: `Hello ${name}` };
};
tstypescript
// ✅ Validating all event sources
import { z } from "zod";
 
const UserInputSchema = z.object({
  name: z.string().min(1).max(100).regex(/^[\w\s-]+$/),
  email: z.string().email().max(254),
  action: z.enum(["create", "update", "delete"]),
});
 
type ValidatedInput = z.infer<typeof UserInputSchema>;
 
function extractInput(event: unknown): Record<string, unknown> {
  if (isApiGatewayEvent(event)) {
    return JSON.parse((event as any).body ?? "{}");
  }
  if (isSqsEvent(event)) {
    const records = (event as any).Records ?? [];
    return JSON.parse(records[0]?.body ?? "{}");
  }
  if (isS3Event(event)) {
    return {
      bucket: (event as any).Records?.[0]?.s3?.bucket?.name,
      key: (event as any).Records?.[0]?.s3?.object?.key,
    };
  }
  throw new Error("Unknown event source");
}
 
export const handler = async (event: unknown) => {
  const rawInput = extractInput(event);
  const input = UserInputSchema.parse(rawInput);
  return processValidatedInput(input);
};
 
function isApiGatewayEvent(event: unknown): boolean {
  return typeof event === "object" && event !== null && "httpMethod" in event;
}
 
function isSqsEvent(event: unknown): boolean {
  return typeof event === "object" && event !== null &&
    "Records" in event &&
    Array.isArray((event as any).Records) &&
    (event as any).Records[0]?.eventSource === "aws:sqs";
}
 
function isS3Event(event: unknown): boolean {
  return typeof event === "object" && event !== null &&
    "Records" in event &&
    Array.isArray((event as any).Records) &&
    (event as any).Records[0]?.eventSource === "aws:s3";
}
 
async function processValidatedInput(input: ValidatedInput) {
  return { statusCode: 200, body: JSON.stringify({ success: true }) };
}

Jede Event-Quelle verdient die gleiche Sorgfalt bei der Validierung. Eine SQS-Nachricht, die von einem kompromittierten vorgelagerten Dienst manipuliert wurde, ist genauso gefährlich wie eine böswillige HTTP-Anfrage.

IAM-Richtlinien nach dem Prinzip der geringsten Rechte umsetzen

Der häufigste Sicherheitsfehler bei Serverless sind zu großzügige IAM-Rollen. Eine Funktion, die nur aus einer DynamoDB-Tabelle liest, sollte keinen Schreibzugriff auf alle Tabellen deines Kontos haben.

ymlyaml
# ❌ Overly permissive policy
Resources:
  ProcessOrderFunction:
    Type: AWS::Serverless::Function
    Properties:
      Policies:
        - AmazonDynamoDBFullAccess  # Access to ALL tables
        - AmazonS3FullAccess        # Access to ALL buckets
        - AmazonSQSFullAccess       # Access to ALL queues
ymlyaml
# ✅ Least privilege per function
Resources:
  ProcessOrderFunction:
    Type: AWS::Serverless::Function
    Properties:
      Policies:
        - Statement:
            - Effect: Allow
              Action:
                - dynamodb:GetItem
                - dynamodb:PutItem
                - dynamodb:UpdateItem
              Resource:
                - !GetAtt OrdersTable.Arn
            - Effect: Allow
              Action:
                - s3:GetObject
              Resource:
                - !Sub "${OrderBucketArn}/*"
            - Effect: Allow
              Action:
                - sqs:SendMessage
              Resource:
                - !GetAtt NotificationQueue.Arn

Jede Funktion erhält ihre eigene Rolle mit ausschließlich den Berechtigungen, die sie tatsächlich braucht. Das begrenzt den möglichen Schaden: Wird eine Funktion kompromittiert, kann der Angreifer nur auf die Ressourcen dieser einen Funktion zugreifen.

Schutz vor Injection in Serverless-Umgebungen

Injection-Angriffe bei Serverless beschränken sich nicht auf SQL. NoSQL-Injection, Betriebssystembefehl-Injection über exec-Aufrufe zur Laufzeit und sogar Event-Injection durch fehlerhaft geformte Payloads sind allesamt reale Bedrohungen.

tstypescript
// ❌ Vulnerable to NoSQL injection
import { DynamoDBClient, QueryCommand } from "@aws-sdk/client-dynamodb";
 
const client = new DynamoDBClient({});
 
export const handler = async (event: any) => {
  const userId = JSON.parse(event.body).userId;
 
  // If userId contains special characters or operators,
  // this could return unintended data
  const command = new QueryCommand({
    TableName: "Users",
    KeyConditionExpression: `userId = :uid`,
    ExpressionAttributeValues: {
      ":uid": { S: userId }, // Unsanitized input
    },
  });
 
  return client.send(command);
};
tstypescript
// ✅ Sanitized and validated input with parameterized queries
import { DynamoDBClient, QueryCommand } from "@aws-sdk/client-dynamodb";
import { z } from "zod";
 
const client = new DynamoDBClient({});
 
const QuerySchema = z.object({
  userId: z
    .string()
    .uuid()
    .max(36),
});
 
export const handler = async (event: { body?: string }) => {
  const parsed = QuerySchema.safeParse(
    JSON.parse(event.body ?? "{}")
  );
 
  if (!parsed.success) {
    return {
      statusCode: 400,
      body: JSON.stringify({ error: "Invalid input" }),
    };
  }
 
  const command = new QueryCommand({
    TableName: "Users",
    KeyConditionExpression: "userId = :uid",
    ExpressionAttributeValues: {
      ":uid": { S: parsed.data.userId },
    },
  });
 
  const result = await client.send(command);
  return {
    statusCode: 200,
    body: JSON.stringify(result.Items),
  };
};

Das Muster bleibt immer gleich: erst parsen und validieren, dann erst irgendeine Operation ausführen. Zod-Schemas an den Grenzen der Funktion stellen sicher, dass nur Daten in der erwarteten Form deine Geschäftslogik erreichen.

Secrets in Serverless-Umgebungen verwalten

Umgebungsvariablen sind bei Serverless der Standardweg für Secrets, sind aber in der Konsole sichtbar, werden in Deployment-Ausgaben protokolliert und über alle Aufrufe hinweg geteilt. Verwende stattdessen einen Secrets-Manager.

tstypescript
// ❌ Secrets in environment variables
const API_KEY = process.env.THIRD_PARTY_API_KEY;
// Visible in CloudWatch logs if accidentally logged
// Exposed in CloudFormation outputs
// Rotated by redeploying every function
 
// ✅ Secrets from AWS Secrets Manager with caching
import {
  SecretsManagerClient,
  GetSecretValueCommand,
} from "@aws-sdk/client-secrets-manager";
 
const secretsClient = new SecretsManagerClient({});
 
let cachedSecrets: Record<string, string> | null = null;
let cacheExpiry = 0;
 
async function getSecret(secretName: string): Promise<string> {
  const now = Date.now();
 
  if (cachedSecrets && now < cacheExpiry) {
    const value = cachedSecrets[secretName];
    if (value !== undefined) return value;
  }
 
  const command = new GetSecretValueCommand({
    SecretId: secretName,
  });
 
  const response = await secretsClient.send(command);
  const secretValue = response.SecretString;
 
  if (!secretValue) {
    throw new Error(`Secret ${secretName} not found`);
  }
 
  cachedSecrets = JSON.parse(secretValue);
  cacheExpiry = now + 5 * 60 * 1000; // Cache for 5 minutes
 
  const result = cachedSecrets?.[secretName];
  if (!result) {
    throw new Error(`Key ${secretName} not in secret`);
  }
  return result;
}

Secrets über warme Aufrufe hinweg zu cachen ist für die Performance unerlässlich. Ohne Caching bringt jeder Aufruf zusätzliche Latenz durch den API-Aufruf für die Secrets mit sich. Das 5-minütige Cache-Fenster schafft eine Balance zwischen Aktualität und Performance.

Rate Limiting und Missbrauchsprävention

Automatische Skalierung bei Serverless ist ein zweischneidiges Schwert. Ein Angreifer kann Tausende Aufrufe auslösen, die Kosten in die Höhe treiben und dabei möglicherweise nachgelagerte Ressourcen erschöpfen.

tstypescript
import { DynamoDBClient, UpdateItemCommand } from "@aws-sdk/client-dynamodb";
 
const dynamoClient = new DynamoDBClient({});
 
interface RateLimitResult {
  allowed: boolean;
  remaining: number;
  resetAt: number;
}
 
async function checkRateLimit(
  clientId: string,
  maxRequests: number = 100,
  windowSeconds: number = 60
): Promise<RateLimitResult> {
  const windowStart = Math.floor(Date.now() / 1000 / windowSeconds);
  const key = `${clientId}:${windowStart}`;
  const ttl = windowStart * windowSeconds + windowSeconds * 2;
 
  const command = new UpdateItemCommand({
    TableName: "RateLimits",
    Key: { pk: { S: key } },
    UpdateExpression:
      "SET requestCount = if_not_exists(requestCount, :zero) + :inc, " +
      "expiresAt = :ttl",
    ExpressionAttributeValues: {
      ":zero": { N: "0" },
      ":inc": { N: "1" },
      ":ttl": { N: String(ttl) },
    },
    ReturnValues: "ALL_NEW",
  });
 
  const result = await dynamoClient.send(command);
  const count = parseInt(
    result.Attributes?.requestCount?.N ?? "0", 10
  );
 
  return {
    allowed: count <= maxRequests,
    remaining: Math.max(0, maxRequests - count),
    resetAt: (windowStart + 1) * windowSeconds,
  };
}
 
export const handler = async (event: {
  requestContext?: { identity?: { sourceIp?: string } };
  body?: string;
}) => {
  const clientIp = event.requestContext?.identity?.sourceIp ?? "unknown";
  const rateLimit = await checkRateLimit(clientIp);
 
  if (!rateLimit.allowed) {
    return {
      statusCode: 429,
      headers: {
        "Retry-After": String(
          rateLimit.resetAt - Math.floor(Date.now() / 1000)
        ),
      },
      body: JSON.stringify({ error: "Rate limit exceeded" }),
    };
  }
 
  // Process request normally
  return { statusCode: 200, body: JSON.stringify({ success: true }) };
};

Rate Limiting auf Basis von DynamoDB eignet sich gut für Serverless, weil es selbst serverless ist – es fällt keine zusätzliche Infrastruktur an, die verwaltet werden müsste. Das TTL-Attribut räumt abgelaufene Zeitfenster automatisch auf.

Abhängigkeiten von Funktionen absichern

Die Sicherheit deiner Funktion ist immer nur so stark wie ihre schwächste Abhängigkeit. Supply-Chain-Angriffe zielen gezielt auf npm-Pakete ab, die Serverless-Funktionen häufig verwenden.

jsonjson
{
  "scripts": {
    "audit": "npm audit --production",
    "audit:fix": "npm audit fix --production",
    "preinstall": "npx npm-force-resolutions",
    "deps:check": "npx depcheck --ignores='@types/*'"
  }
}
tstypescript
// Automated dependency scanning in CI
// .github/workflows/security.yml equivalent logic
 
interface DependencyCheckResult {
  package: string;
  version: string;
  severity: "low" | "moderate" | "high" | "critical";
  advisory: string;
  fixAvailable: boolean;
}
 
function shouldBlockDeploy(
  results: DependencyCheckResult[]
): boolean {
  const criticalOrHigh = results.filter(
    r => r.severity === "critical" || r.severity === "high"
  );
 
  if (criticalOrHigh.length > 0) {
    console.error("Blocking deploy due to vulnerabilities:");
    for (const vuln of criticalOrHigh) {
      console.error(
        `  ${vuln.package}@${vuln.version}: ${vuln.severity} - ${vuln.advisory}`
      );
    }
    return true;
  }
 
  return false;
}

Lockfiles sind bei Serverless noch wichtiger als bei traditionellen Anwendungen. Eine kompromittierte Abhängigkeit in einer Serverless-Funktion hat direkten Zugriff auf die Berechtigungen deiner IAM-Rolle – das macht Supply-Chain-Angriffe in diesem Kontext besonders gefährlich.

Die wichtigsten Erkenntnisse

Sicherheit bei Serverless erfordert ein Umdenken: weg von der Perimeterverteidigung, hin zur Verteidigung auf Ebene jeder einzelnen Funktion. Jede Funktion ist eine eigenständige Sicherheitsgrenze mit eigener IAM-Rolle, eigener Eingabevalidierung und eigener Angriffsfläche. Das Modell der geteilten Verantwortung bedeutet: Der Cloud-Anbieter sichert die Infrastruktur ab, du aber sicherst alles ab, was ab dem Funktionscode aufwärts liegt.

Beginne mit IAM-Richtlinien nach dem Prinzip der geringsten Rechte und einer strikten Eingabevalidierung für jede Event-Quelle – nicht nur für HTTP. Ergänze das Ganze um Secrets-Management über dedizierte Dienste statt über Umgebungsvariablen. Setze Rate Limiting ein, um kostenbasierte Denial-of-Service-Angriffe zu verhindern. Und behandle deine Dependency-Supply-Chain als Angriffsvektor, der kontinuierliche Überwachung verdient. Das Serverless-Modell verschafft dir enorme Skalierbarkeit, aber nur diszipliniert angewandte Sicherheitspraktiken verhindern, dass diese Stärke zur Belastung wird.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX