Saltar al contenido

Proteger funciones serverless: patrones de defensa

Comprende los desafíos de seguridad de las arquitecturas serverless y aplica patrones de defensa prácticos para AWS Lambda y Azure Functions.

6 min de lectura
Diagrama de un escudo de seguridad que muestra las superficies de ataque de las funciones serverless y sus capas de defensa

Serverless no significa despreocuparte de la seguridad. Si bien los proveedores de nube se encargan de la seguridad de la infraestructura, la capa de aplicación es responsabilidad tuya por completo. Las funciones serverless introducen superficies de ataque particulares que los modelos de seguridad tradicionales no cubren bien.

La naturaleza efímera de las funciones, los entornos de ejecución compartidos y los patrones de invocación basados en eventos plantean retos de seguridad que exigen un enfoque distinto. No basta con trasladar tu manual de seguridad para contenedores: necesitas estrategias pensadas específicamente para este paradigma.

La superficie de ataque serverless

Las funciones serverless reciben eventos de fuentes muy diversas: solicitudes HTTP, colas de mensajes, disparadores de almacenamiento, eventos programados. Cada fuente es un posible vector de ataque, y muchos desarrolladores solo validan las entradas HTTP.

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 }) };
}

Toda fuente de eventos merece el mismo rigor de validación. Un mensaje de SQS manipulado por un servicio previo comprometido es tan peligroso como una solicitud HTTP maliciosa.

Cómo implementar políticas de IAM con privilegio mínimo

El error de seguridad más común en serverless son los roles de IAM demasiado permisivos. Una función que solo lee de una tabla de DynamoDB no debería tener acceso de escritura a todas las tablas de tu cuenta.

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

Cada función obtiene su propio rol con únicamente los permisos que necesita. Esto limita el radio de impacto: si una función se ve comprometida, el atacante solo puede acceder a los recursos de esa función.

Cómo protegerte de la inyección en contextos serverless

Los ataques de inyección en serverless van más allá de SQL. La inyección NoSQL, la inyección de comandos del sistema operativo a través de llamadas exec en tiempo de ejecución, e incluso la inyección de eventos mediante payloads malformados son amenazas reales.

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),
  };
};

El patrón es siempre el mismo: analiza y valida antes de cualquier operación. Los esquemas de Zod en los límites de la función garantizan que solo lleguen a tu lógica de negocio los datos con la forma esperada.

Cómo gestionar secretos en entornos serverless

Las variables de entorno son la opción por defecto para los secretos en serverless, pero son visibles en la consola, quedan registradas en las salidas de despliegue y se comparten entre todas las invocaciones. Usa en su lugar un gestor de secretos.

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;
}

Cachear los secretos entre invocaciones en caliente es esencial para el rendimiento. Sin caché, cada invocación añade latencia por la llamada a la API de secretos. La ventana de caché de 5 minutos equilibra la frescura de los datos con el rendimiento.

Limitación de tasa y prevención de abusos

El autoescalado de serverless es un arma de doble filo. Un atacante puede disparar miles de invocaciones, disparando los costos y agotando potencialmente los recursos posteriores en la cadena.

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 }) };
};

La limitación de tasa basada en DynamoDB funciona bien en serverless porque es en sí misma serverless: no hay infraestructura adicional que gestionar. El atributo TTL limpia automáticamente las ventanas vencidas.

Cómo proteger las dependencias de tus funciones

La seguridad de tu función es tan fuerte como su dependencia más débil. Los ataques a la cadena de suministro apuntan a los paquetes de npm que las funciones serverless suelen usar.

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;
}

Los archivos de bloqueo (lock files) importan todavía más en serverless que en las aplicaciones tradicionales. Una dependencia comprometida en una función serverless tiene acceso directo a los permisos de tu rol de IAM, lo que hace que los ataques a la cadena de suministro sean especialmente peligrosos en este contexto.

Conclusiones clave

La seguridad en serverless exige un cambio de mentalidad: pasar de la defensa perimetral a la defensa por función. Cada función es un límite de seguridad independiente, con su propio rol de IAM, su propia validación de entradas y su propia superficie de ataque. El modelo de responsabilidad compartida implica que el proveedor de nube protege la infraestructura, pero tú proteges todo lo demás, desde el código de la función hacia arriba.

Empieza por políticas de IAM de privilegio mínimo y una validación estricta de entradas en cada fuente de eventos, no solo en HTTP. Añade gestión de secretos mediante servicios dedicados en lugar de variables de entorno. Implementa limitación de tasa para evitar la denegación de servicio basada en costos. Y trata tu cadena de suministro de dependencias como un vector de ataque que merece monitoreo continuo. El modelo serverless te da una escalabilidad enorme, pero solo unas prácticas de seguridad disciplinadas evitan que ese poder se convierta en un pasivo.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX