glsec

Rules / GL046

GL046 Cache poisoning across the protected-branch boundary

Severity: warn; error for cache: unprotect: true on deploy-like or merge request jobs
OWASP: CICD-SEC-3 – Dependency Chain Abuse
CWE: CWE-494 – Download of Code Without Integrity Check

Risk

A cache: block whose key: is derived from a user-controlled variable allows an attacker to poison the cache for other pipelines. By creating a branch whose name is crafted to match the cache key of a protected branch or release pipeline, an attacker can pre-fill the cache with malicious build artefacts (e.g. tampered node_modules/, compiled binaries). A subsequent legitimate build will restore and execute those artefacts without integrity verification.

CI_COMMIT_REF_SLUG is a URL-safe normalisation of CI_COMMIT_REF_NAME and carries the same risk — an attacker can craft a branch name whose slug matches the target key.

What is flagged

GL046 covers two ways an unprotected branch can fill a cache that a protected pipeline restores.

Cache key from a user-controlled variable

Any cache: key: value (at job level or under default:) that contains a user-controlled predefined variable, and any cache: key: prefix: field that contains such a variable.

Flagged variables:

Variable Controlled by
$CI_COMMIT_REF_NAME Pusher (branch or tag name)
$CI_COMMIT_REF_SLUG Pusher (normalised branch/tag name)
$CI_COMMIT_BRANCH Pusher (branch name)
$CI_COMMIT_TAG Pusher (tag name)
$CI_COMMIT_TITLE / $CI_COMMIT_MESSAGE Commit author
$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME MR author
$CI_MERGE_REQUEST_TITLE MR author
$CI_PIPELINE_NAME API caller
$CI_EXTERNAL_PULL_REQUEST_SOURCE_BRANCH_NAME External pull request author
$CI_CONFIG_REF_URI Pusher (ends in the branch name)
$CI_RELEASE_DESCRIPTION Release author
$AI_FLOW_INPUT / $AI_FLOW_CONTEXT Author of the comment or merge request that triggered a GitLab Duo flow

cache: unprotect: true on a job that writes the cache

GitLab keeps caches of protected and unprotected branches apart by default. cache:unprotect: true removes that separation — in GitLab's words, "users without access to protected branches can read and write to cache keys used by protected branches". Anyone who can push a branch can then plant artefacts that a protected or release job restores.

An entry is flagged when it sets unprotect: true and can write the cache: policy: pull-push (the default), policy: push, or a policy taken from a variable, which cannot be resolved statically. policy: pull only reads and is not flagged. Severity is error for deploy-like jobs (name, stage, or environment:) and for jobs that run on merge request pipelines, warn otherwise, including under default:.

build:
  cache:
    key: deps
    unprotect: true      # shared with unprotected branches, written by this job
    paths:
      - vendor/

Not flagged:

Caches defined behind a YAML anchor (cache: *deps or <<: *deps) are checked like inline ones. A cache used by several jobs is reported once, at its definition, with the highest severity any of those jobs gives it.

Fix: drop unprotect:, or set policy: pull on jobs that share the cache with unprotected branches and build it only on protected ones.

Trigger examples

build:
  cache:
    key: $CI_COMMIT_REF_NAME        # branch name controls the cache slot
    paths:
      - node_modules/
  script:
    - ./build.sh

test:
  cache:
    key:
      files:
        - package-lock.json
      prefix: $CI_COMMIT_REF_NAME   # prefix derived from branch name
    paths:
      - node_modules/
  script:
    - ./build.sh

Safe alternative

Key the cache on a file hash instead of a branch name. This makes the key deterministic and independent of attacker-controlled input:

build:
  cache:
    key:
      files:
        - package-lock.json   # cache invalidates when lockfile changes
    paths:
      - node_modules/
  script:
    - ./build.sh

Notes

Documentation for glsec 1.23.0. Source of this page · Rule implementation