Seguridad: cuando el portal tiene las llaves de la cocina
Tiempo de lectura: 24 min · Itinerarios: cocinero jefe, CTO. Lee :cap-17 antes si no conoces el modelo de plugins.
|
Objetivos del capítulo
Al terminar este capítulo vas a poder:
|
La pregunta incómoda
Cuando despliegas un Backstage en producción, le estás dando a una web app:
-
Acceso de lectura a tu GitHub/GitLab/Bitbucket (incluyendo repos privados con código fuente).
-
Tokens de larga duración para tu cluster de Kubernetes.
-
Secrets de CI (Jenkins, GitHub Actions, CircleCI).
-
Credenciales para bases de datos que almacenan la metadata del catalog.
-
Acceso al Scaffolder, que ejecuta código arbitrario en nombre del usuario.
Eso convierte a Backstage en un objetivo de altísimo valor para cualquier atacante con acceso a tu red corporativa. Si lo compromete, no solo se lleva datos: se lleva credenciales para llegar a todos tus servicios.
Backstage es como darle las llaves del restaurante a un aprendiz el primer día. Si la cocina está bien diseñada (permisos, miffs, llaves etiquetadas), el aprendiz puede cocinar sin peligro. Si la puerta está abierta, una hora después no queda nada.
El threat model oficial de Backstage
El equipo de Backstage mantiene un threat model público que describe qué defiende y qué NO defiende. Léelo antes de desplegar.
Lo que SÍ defiende
-
Aislamiento de datos por usuario: cada
user.entitysolo ve lo que el Permission Framework le permite. -
Aislamiento de templates: Scaffolder ejecuta en un Node VM sandbox con vm.Script (no
evalniFunction). -
Auditoría de plugins: el
plugin-auditverifica compatibilidad de versiones antes de instalar. -
Defense in depth: HTTPS obligatorio, CORS restrictivo por defecto, sesiones con cookies HttpOnly + SameSite.
-
No ejecución implícita: el catalog no ejecuta código de los
catalog-info.yaml(a diferencia de Helm/Kustomize, donde un manifest puede ser código).
Lo que NO defiende (y debes asegurar tú)
-
Resource exhaustion: el catalog no limita el tamaño de los
catalog-info.yamlni la profundidad del árbol. Un atacante interno puede crear entidades gigantes que rompan el catalog. -
Cifrado en reposo: la base de datos Postgres que uses debe estar cifrada en disco (responsabilidad tuya, no de Backstage).
-
Secretos en repos de código: si un repo tiene un secret committed, Backstage no lo puede borrar retroactivamente.
-
Plugins de terceros: cualquier plugin que instales es tu responsabilidad auditarlo.
-
Plantillas de Scaffolder: las plantillas son código que se ejecuta. Si permites que developers escriban plantillas sin revisión, estás dando RCE.
|
El threat model está vivo y se actualiza con cada release mayor. Antes de actualizar de 1.x a 1.y, relee backstage.io/docs/overview/threat-model. Los cambios entre versiones suelen afectar a la superficie de ataque. |
Los CVEs que ya han afectado a Backstage
El equipo de Backstage mantiene una lista pública de advisories. Estos son los más importantes en los últimos 12 meses:
CVE-2025-32791: symlink path traversal en Scaffolder (severidad: Media 4.3)
|
Qué pasó: Las acciones Versiones afectadas: Fix: Validación de symlinks antes de cualquier operación de filesystem. Lección: Nunca confíes en paths de usuario. Cualquier acción que toca el filesystem debe resolver symlinks y verificar que el destino esté dentro de un directorio autorizado. |
CVE-2025-55285: secretos en logs de Scaffolder (severidad: Baja 2.6)
|
Qué pasó: La acción Versiones afectadas: Fix: Redacción reforzada de secrets en todos los sinks de log. Lección: El sistema de redacción de logs es tu última línea de defensa, no la primera. Nunca pases |
CVE-2025-XXXXX: dry-run API exfiltraba secretos de entorno (severidad: Media 4.3)
|
Qué pasó: La API Versiones afectadas: Fix: Redacción de secrets en la respuesta de dry-run, no solo en logs. Lección: Cada endpoint HTTP de Backstage debe ser considerado como un sink potencial de secretos. Audita todas las respuestas, no solo los logs. |
Auditoría externa de X41 (2024)
X41 D-Sec, una firma de seguridad alemana, realizó una auditoría completa del código de Backstage en 2024. La auditoría encontró:
-
11 vulnerabilidades de severidad media o menor
-
0 vulnerabilidades críticas
-
0 vulnerabilidades altas
-
Áreas auditadas: XSS, template injection, SQLi, DoS, SSRF, click-jacking, file inclusion, directory traversal
El informe es público y debería ser lectura obligatoria antes de cualquier despliegue en producción.
Hardening por superficie
Vamos capa por capa. Para cada vector de ataque, qué defensa aplicar.
Frontend (XSS, CSRF)
Backstage usa React, que escapa por defecto. El riesgo de XSS es bajo — pero existe en:
-
Custom cards/plugins que renderizan HTML crudo con
dangerouslySetInnerHTML. -
TechDocs que renderiza Markdown — si un atacante controla un repo, puede inyectar HTML.
-
Readme fields en
catalog-info.yamlque se renderizan como Markdown.
Defensas:
// 1. CSP estricto en app-config.yaml
app:
csp:
default-src: ["'self'"]
script-src: ["'self'", "'unsafe-eval'"] # unsafe-eval solo si usas Monaco
style-src: ["'self'", "'unsafe-inline'"] # Backstage lo necesita
img-src: ["'self'", "data:", "https:"]
connect-src: ["'self'"]
frame-ancestors: ["'none'"] # anti click-jacking
// 2. Habilitar SameSite=Strict y HttpOnly en cookies de sesión
backend:
auth:
providers:
github:
session:
cookie:
sameSite: 'strict'
httpOnly: true
secure: true
|
Si despliegas Backstage detrás de un CDN (CloudFront, Cloudflare, Akamai), asegúrate de que el CDN pase el header |
Scaffolder (RCE, secretos)
El Scaffolder es la superficie más sensible. Tres controles no negociables:
1. Limitar quién puede crear templates
# app-config.yaml
permission:
enabled: true
policies:
- name: 'scaffolder-template-admin'
policy:
permission:
name: 'scaffolder-template-action'
action: 'create'
policy: 'only-admins'
2. Auditar las templates antes de promoverlas
# Script de auditoría
#!/usr/bin/env bash
set -e
TEMPLATE_DIR=$1
echo "Auditando templates en $TEMPLATE_DIR"
# Buscar ${{ secrets.* }} pasados a fetch:template
grep -rn 'fetch:template' "$TEMPLATE_DIR" | while read line; do
if echo "$line" | grep -q 'secrets'; then
echo "⚠️ PELIGRO: $line"
echo " fetch:template no debe recibir secrets"
fi
done
# Buscar uso de fs:delete, debug:log, o ejecutables
for action in fs:delete debug:log run:command run:shell; do
if grep -qr "$action" "$TEMPLATE_DIR"; then
echo "⚠️ Acción peligrosa encontrada: $action"
fi
done
echo "✓ Auditoría completa"
3. Prohibir ejecutar templates en producción sin aprobación
# GitOps para templates
# main → soft templates (auto-aprobadas)
# protected/ → solo con PR aprobada por un humano
catalog:
locations:
- type: url
target: https://github.com/mi-empresa/templates/blob/main/approved/*.yaml
- type: url
target: https://github.com/mi-empresa/templates/blob/protected/dangerous/*.yaml
rules:
- allow: [User, Group]
role: admin-only
|
Regla de oro: una template que llama a |
Catalog (resource exhaustion)
El catalog no limita el tamaño de los catalog-info.yaml. Un atacante con permisos de registro puede:
-
Crear una entidad con
metadata.descriptionde 100MB. -
Crear un árbol de 10.000 entidades anidadas.
-
Hacer que el catalog tarde 30 minutos en procesar.
Defensas:
// 1. Limitar el tamaño máximo de los catalog-info.yaml
// En tu processor de catalog, añadir:
processor.providers.push({
supports: () => true,
read: async (path) => {
const stat = await fs.stat(path);
if (stat.size > 1024 * 1024) { // 1MB
throw new Error('catalog-info.yaml demasiado grande');
}
// ...
},
});
# 2. Limitar quién puede registrar entidades
# Por defecto, todos los usuarios internos pueden
permission:
enabled: true
policies:
- policy:
permission: catalog-entity
action: create
policy: 'only-catalog-admins'
// 3. Limitar la frecuencia de refresh
catalog:
providers:
github:
schedule:
frequency: { minutes: 30 } # No más frecuente
timeout: { minutes: 5 }
Backend API (SSRF, IDOR)
El backend de Backstage puede hacer requests HTTP salientes (para catalog providers, Scaffolder actions, etc.). Esto abre un vector de SSRF.
Defensas:
# 1. Configurar el proxy de Backstage para bloquear redes internas
proxy:
endpoints:
'/gitlab/api':
target: 'https://gitlab.internal.com'
changeOrigin: true
secure: true
# El proxy SOLO se usa para esto
# Cualquier otro path pasa al backend
// 2. Validar URLs de catalog providers
// No permitir file://, gopher://, etc.
const ALLOWED_PROTOCOLS = ['https:', 'http:'];
function validateProviderUrl(url: string) {
const parsed = new URL(url);
if (!ALLOWED_PROTOCOLS.includes(parsed.protocol)) {
throw new Error(`Protocolo no permitido: ${parsed.protocol}`);
}
// Bloquear rangos privados si es un proxy
if (parsed.hostname === 'localhost' || parsed.hostname.startsWith('127.')) {
throw new Error('No se permite localhost en producción');
}
}
Network layer (defense in depth)
Aún si la app es segura, el cluster no debería serlo menos:
# NetworkPolicy de Kubernetes para el pod de Backstage
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: backstage-egress
namespace: idp
spec:
podSelector:
matchLabels:
app: backstage
policyTypes:
- Egress
egress:
# Permitir solo HTTPS saliente (catalog providers, Scaffolder actions)
- to:
- namespaceSelector: {}
ports:
- port: 443
protocol: TCP
# DNS obligatorio
- to:
- namespaceSelector: {}
ports:
- port: 53
protocol: UDP
Manejo de secretos en Scaffolder
El Scaffolder tiene tres niveles de secretos, cada uno más sensible:
Nivel 1: inputs del usuario (visibles en UI)
El usuario teclea su valor en un form. Es el menos sensible porque el usuario lo ve, pero igualmente debe redactarse en logs.
parameters:
- title: API credentials
properties:
apiKey:
type: string
ui:field: Secret # <-- Campo especial que oculta el valor
ui:options:
monospace: true
|
El campo |
Nivel 2: app-config secrets (visibles para el integrator)
Para secretos que NO deben estar en el form (credenciales del cluster, API keys corporativas), usa scaffolder.defaultEnvironment en app-config.yaml:
# app-config.yaml
scaffolder:
defaultEnvironment:
parameters:
region: 'eu-west-1'
secrets:
AWS_ACCESS_KEY: ${AWS_ACCESS_KEY} # se resuelve de process.env
GITHUB_TOKEN: ${GITHUB_TOKEN}
En el template:
steps:
- id: deploy
action: deploy:aws
name: Deploy to AWS
input:
accessKey: ${{ secrets.AWS_ACCESS_KEY }}
region: ${{ parameters.region }}
|
Los secrets en |
Nivel 3: short-lived tokens (lo más seguro)
El patrón de seguridad ideal: emitir tokens de corta duración justo antes de la acción que los necesita.
// Backend module que emite tokens de GitHub temporales
// @backstage/plugin-scaffolder-backend-module-github-token-exchange
import { createBackendModule } from '@backstage/backend-plugin-api';
import { githubAuthApiRef } from '@backstage/plugin-github-backend';
export const githubTokenModule = createBackendModule({
pluginId: 'scaffolder',
moduleId: 'github-token-exchange',
register(env) {
env.registerInit({
deps: { github: githubAuthApiRef },
async init({ github }) {
scaffolder.addAction({
name: 'github:token-exchange',
handler: async (ctx) => {
// El usuario está autenticado, intercambiamos su sesión
// por un token de GitHub de 1 hora
const { token } = await github.getAccessToken();
return { output('githubToken', token) };
},
});
},
});
},
});
Los tokens de larga duración son como dejar las llaves del restaurante debajo del felpudo. Los tokens de corta duración son como un código que cambia cada hora. Sí, es más incómodo, pero el día que alguien encuentre las llaves, no tendrá tiempo de copiar la cerradura.
Auditoría: quién hizo qué
Sin auditoría, no puedes detectar incidentes ni demostrar compliance. Backstage emite logs estructurados que puedes enviar a un SIEM (Splunk, Elastic, Datadog Logs):
// app-config.yaml
backend:
listening:
port: 7007
events:
http:
topicPrefix: backstage
subscribers:
- rabbitmq # envía eventos a RabbitMQ
- audit-log # escribe a un append-only log
Eventos que siempre debes capturar:
-
scaffolder.task.created: usuario X ejecutó template Y para crear entidad Z -
scaffolder.task.completed: terminó con éxito o error -
catalog.entity.created/updated/deleted: quién modificó qué -
auth.session.created: cada login (con IP, user agent) -
permission.evaluated: decisiones de permiso denegadas (intentó algo que no podía)
|
Una métrica de seguridad útil: el ratio de denegaciones por usuario. Un usuario con 10 denegaciones por hora es probable atacante interno o un plugin mal configurado. Alerta cuando un usuario supere 20 denegaciones/día. |
Lista de verificación de seguridad pre-producción
|
Security checklist
Antes de abrir Backstage a todos los employees, verifica:
|
Resumen
-
Backstage es un objetivo de altísimo valor: da acceso a GitHub, Kube, secrets.
-
El threat model oficial está en backstage.io. Lée antes de producción.
-
Los CVEs reales (symlink traversal, secret leakage, dry-run) enseñan que ningún endpoint es innocuo.
-
Hardening por superficie: frontend (CSP, CSRF), Scaffolder (auditoría de templates, no
run:shell), catalog (límite de tamaño), backend (no SSRF), network (NetworkPolicy). -
Secretos: nunca de larga duración, idealmente short-lived exchange on-demand.
-
Auditoría: cada evento crítico debe ir a un SIEM, no a stdout.
Glosario del capítulo
- Threat model
-
Documento que describe qué defiende un sistema y qué no.
- CVE
-
Common Vulnerabilities and Exposures, identificador público de una vulnerabilidad.
- CSP
-
Content Security Policy, header HTTP que limita qué recursos puede cargar una página.
- CSRF
-
Cross-Site Request Forgery, ataque que fuerza acciones no autorizadas.
- SSRF
-
Server-Side Request Forgery, ataque que hace al servidor acceder a recursos internos.
- IDOR
-
Insecure Direct Object Reference, autorización rota que permite acceso a objetos de otros usuarios.
- Short-lived token
-
Token con expiración corta (minutos a horas) que reduce el blast radius de una filtración.
- Defense in depth
-
Estrategia de aplicar múltiples capas de defensa para que si una falla, las demás contengan el ataque.
Próximo capítulo
Capítulo 19 — Permisos: la matriz que decide quién cocina qué — Permission Framework en profundidad, conditional policies, integración con OPA/Casbin, errores comunes como "permission denied silencioso" y migración desde el RBAC legacy.