Seguridad serverless: superficies de ataque y mitigaciones
Los desafíos de seguridad propios de serverless: inyección por fuentes de eventos, funciones con exceso de permisos y dependencias vulnerables.

El malentendido sobre la seguridad serverless
Serverless no es sinónimo de seguro. El modelo de responsabilidad compartida traslada la seguridad de la infraestructura al proveedor de la nube, pero la seguridad de la aplicación —validación de entradas, autorización, gestión de secretos, higiene de dependencias— sigue siendo enteramente tu responsabilidad. En muchos sentidos, serverless introduce nuevas superficies de ataque que las arquitecturas tradicionales no tienen.
Las funciones que se activan mediante fuentes de eventos diversas (API Gateway, eventos de S3, mensajes de SQS, eventos de CloudWatch) crean oportunidades de inyección que los desarrolladores acostumbrados a validar solo entradas HTTP pasan completamente por alto. Cada fuente de eventos es una superficie de ataque.
Ataques de inyección a través de fuentes de eventos
En las aplicaciones tradicionales, la entrada llega a través de solicitudes HTTP. En serverless, la entrada llega mediante objetos de evento provenientes de muchas fuentes distintas. Cada fuente tiene una forma de datos diferente y vectores de inyección diferentes.
// ❌ 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`,
]);
}La versión segura valida la estructura del evento, sanitiza la clave de S3 contra path traversal, valida las extensiones de archivo y usa execFile en lugar de exec para evitar la inyección de comandos de shell.
Políticas de IAM con privilegio mínimo
Las funciones Lambda con permisos excesivos son uno de los problemas de seguridad más comunes en serverless. Una función que procesa imágenes no debería tener acceso a DynamoDB, SES ni a ningún otro servicio que no utilice.
// ❌ 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:*",
],
},
],
};Cada función recibe únicamente los permisos que necesita, limitados a los recursos específicos con los que opera. Las condiciones restringen aún más el acceso: el procesador de imágenes solo puede leer objetos etiquetados como validados y debe usar cifrado al escribir.
Gestión de secretos en serverless
Las variables de entorno son la forma predeterminada de pasar configuración a las funciones Lambda, pero son visibles en la consola de AWS y en las plantillas de CloudFormation. Los secretos deben obtenerse en tiempo de ejecución desde un gestor de secretos.
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...
}La función solo tiene el ARN del secreto en sus variables de entorno: los valores reales se obtienen en tiempo de ejecución y se almacenan en caché durante la vida del entorno de ejecución de Lambda.
Autorización a nivel de función
Cada función serverless debe verificar la autorización de forma independiente. No confíes únicamente en los autorizadores de API Gateway: la defensa en profundidad implica que cada capa verifique los permisos.
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: "" };
}Gestión de vulnerabilidades en dependencias
Las funciones serverless a menudo tienen poco código propio, pero incorporan decenas de dependencias. Cada dependencia es una superficie de ataque.
// 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);// 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;
}Registrar sin filtrar información
Los registros de las funciones serverless suelen contener datos sensibles porque los desarrolladores añaden registros detallados durante el desarrollo y luego se olvidan de eliminarlos.
// ❌ 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() }));
}Conclusiones clave
La seguridad en serverless exige un cambio de mentalidad: pasar de la defensa perimetral a la defensa a nivel de función. Cada fuente de eventos es una superficie de ataque que necesita validación de entradas, no solo las solicitudes HTTP. Aplica políticas de IAM de privilegio mínimo, limitadas a los recursos y acciones específicos de cada función.
Obtén los secretos en tiempo de ejecución desde un gestor de secretos en lugar de almacenarlos en variables de entorno. Implementa comprobaciones de autorización dentro de cada función, no solo a nivel de API Gateway. Audita las dependencias de forma continua y bloquea los despliegues cuando se detecten vulnerabilidades críticas. Sanitiza toda la salida de los logs para evitar la filtración de datos sensibles.
El modelo serverless elimina muchas preocupaciones de seguridad de infraestructura, pero concentra las decisiones de seguridad de la aplicación en cada invocación de función. Trata cada función como un límite de seguridad.


