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:

  • Conocer el threat model oficial de Backstage y sus garantías.

  • Identificar los CVEs reales que ya han afectado a Backstage y aprender de ellos.

  • Aplicar la lista de verificación de seguridad a tu instancia.

  • Entender cómo Scaffolder ejecuta código sin RCE.

  • Manejar secretos en templates sin filtrarlos a logs ni al cliente.

  • Configurar CSP, CORS, CSRF y network policies correctamente.

  • Auditar quién hizo qué en producción.

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.entity solo ve lo que el Permission Framework le permite.

  • Aislamiento de templates: Scaffolder ejecuta en un Node VM sandbox con vm.Script (no eval ni Function).

  • Auditoría de plugins: el plugin-audit verifica 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.yaml ni 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:

Qué pasó: Las acciones debug:log, fs:delete y la extracción de archivos tar/zip en el Scaffolder no validaban symlinks. Un atacante con permisos para ejecutar templates podía crear un symlink a /etc/passwd o a un archivo de secrets y hacer que el Scaffolder lo leyera, lo borrara o lo escribiera fuera del workspace.

Versiones afectadas: @backstage/backend-defaults anteriores al parche publicado en 2026-04.

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 fetch:template registraba los valores de input en logs, y si usabas ${{ secrets.X }} como argumento, ese secreto acababa en el log (a pesar del sistema de redacción).

Versiones afectadas: @backstage/plugin-scaffolder-backend anteriores a 2.1.1.

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 ${{ secrets.* }} a una acción que pueda leakear el valor.

CVE-2025-XXXXX: dry-run API exfiltraba secretos de entorno (severidad: Media 4.3)

Qué pasó: La API /dry-run del Scaffolder permitía a usuarios autenticados recibir en la respuesta los scaffolder.defaultEnvironment.secrets configurados en app-config.yaml. Los logs los redactaban, pero la respuesta JSON no.

Versiones afectadas: @backstage/plugin-scaffolder-backend anteriores a 3.1.5.

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.

Diagram
Figure 20. Vectores de ataque de un Backstage 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.yaml que 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 X-Forwarded-Proto: https para que Backstage no baje a HTTP en producción. El test: si tu navegador muestra "Not Secure" en algún momento, algo está mal configurado.

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 run:shell con valores del usuario siempre es una vulnerabilidad. Si necesitas ejecutar comandos, hazlo desde CI (GitHub Actions, GitLab CI) en respuesta a un evento push, no desde un template que el usuario rellena en el navegador.

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.description de 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 ui:field: Secret está disponible en @backstage/plugin-scaffolder desde 1.18. Renderiza el input como tipo password y almacena el valor en parameters del template (visible para el usuario que lo creó, redactado en logs).

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 scaffolder.defaultEnvironment están disponibles para todas las templates y todos los usuarios que ejecuten templates. Si un usuario descubre cómo invocar una template que no debería, puede exfiltrar ${{ secrets.* }} a un destino externo. Audita regularmente qué templates usan cada secret.

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:

  • HTTPS obligatorio, HSTS enabled

  • CSP estricta definida en app-config.yaml

  • Cookies con HttpOnly, Secure, SameSite=Strict

  • CORS restrictivo: solo tu dominio permitido

  • Permission Framework habilitado con policy por defecto deny

  • Templates revisadas por un humano antes de publicarse

  • Auditar plugins de terceros con backstage-plugin-audit

  • Backups de Postgres con restore testado

  • Logs enviados a un SIEM, no solo a stdout

  • Alertas en denegaciones masivas de permiso

  • NetworkPolicy de Kubernetes: el pod solo habla HTTPS saliente

  • Secrets rotados automáticamente (Vault, AWS Secrets Manager)

  • CVEs suscritos en GitHub Security Advisories

  • Penetration testing anual (X41 o equivalente)

  • Threat model releído en cada major upgrade

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.