Delimiter injection vulnerability in @actions/core exportVariable
Description
CVE-2022-35954: Delimiter injection in @actions/core exportVariable allows attackers to overwrite environment variables via untrusted input written to GITHUB_ENV.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
CVE-2022-35954: Delimiter injection in @actions/core exportVariable allows attackers to overwrite environment variables via untrusted input written to GITHUB_ENV.
Vulnerability
The core.exportVariable function in @actions/core uses a static delimiter _GitHubActionsFileCommandDelimeter_ to separate variable names and values when writing to the GITHUB_ENV file. An attacker who can control the value passed to exportVariable can inject this delimiter to break out of the intended variable and assign arbitrary values to other environment variables, such as PATH [1][4]. This is a classic delimiter injection vulnerability.
Exploitation
The attack surface is any GitHub Action workflow that writes untrusted user input (e.g., issue titles, branch names) to environment variables via core.exportVariable. The attacker does not need special privileges beyond the ability to provide input that gets processed by a vulnerable action. The injection occurs when the untrusted value contains the delimiter string, causing the subsequent content to be interpreted as a new variable assignment [4].
Impact
By overwriting sensitive environment variables like PATH, an attacker could influence the behavior of subsequent steps in a workflow, potentially leading to arbitrary code execution or privilege escalation within the runner's context [1]. The vulnerability can compromise the integrity of the entire CI/CD pipeline if exploited in a public repository or a repository that accepts external contributions.
Mitigation
The issue is fixed in @actions/core version 1.9.1, which replaces the static delimiter with a randomly generated one using UUIDs [2][3]. Users should upgrade to this version. A workaround is to sanitize user input by removing or rejecting any occurrence of the delimiter before calling exportVariable [4].
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
@actions/corenpm | < 1.9.1 | 1.9.1 |
Affected products
2Patches
14beda9cbc00bMerge pull request from GHSA-7r3h-m5j6-3q42
5 files changed · +92 −11
packages/core/package.json+5 −3 modified@@ -1,6 +1,6 @@ { "name": "@actions/core", - "version": "1.9.0", + "version": "1.9.1", "description": "Actions core lib", "keywords": [ "github", @@ -36,9 +36,11 @@ "url": "https://github.com/actions/toolkit/issues" }, "dependencies": { - "@actions/http-client": "^2.0.1" + "@actions/http-client": "^2.0.1", + "uuid": "^8.3.2" }, "devDependencies": { - "@types/node": "^12.0.2" + "@types/node": "^12.0.2", + "@types/uuid": "^8.3.4" } }
packages/core/package-lock.json+31 −4 modified@@ -1,18 +1,20 @@ { "name": "@actions/core", - "version": "1.9.0", + "version": "1.9.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@actions/core", - "version": "1.8.1", + "version": "1.9.1", "license": "MIT", "dependencies": { - "@actions/http-client": "^2.0.1" + "@actions/http-client": "^2.0.1", + "uuid": "^8.3.2" }, "devDependencies": { - "@types/node": "^12.0.2" + "@types/node": "^12.0.2", + "@types/uuid": "^8.3.4" } }, "node_modules/@actions/http-client": { @@ -29,13 +31,27 @@ "integrity": "sha512-5tabW/i+9mhrfEOUcLDu2xBPsHJ+X5Orqy9FKpale3SjDA17j5AEpYq5vfy3oAeAHGcvANRCO3NV3d2D6q3NiA==", "dev": true }, + "node_modules/@types/uuid": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", + "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==", + "dev": true + }, "node_modules/tunnel": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", "engines": { "node": ">=0.6.11 <=0.7.0 || >=0.7.3" } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } } }, "dependencies": { @@ -53,10 +69,21 @@ "integrity": "sha512-5tabW/i+9mhrfEOUcLDu2xBPsHJ+X5Orqy9FKpale3SjDA17j5AEpYq5vfy3oAeAHGcvANRCO3NV3d2D6q3NiA==", "dev": true }, + "@types/uuid": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", + "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==", + "dev": true + }, "tunnel": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==" + }, + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" } } }
packages/core/RELEASES.md+3 −0 modified@@ -1,5 +1,8 @@ # @actions/core Releases +### 1.9.1 +- Randomize delimiter when calling `core.exportVariable` + ### 1.9.0 - Added `toPosixPath`, `toWin32Path` and `toPlatformPath` utilities [#1102](https://github.com/actions/toolkit/pull/1102)
packages/core/src/core.ts+12 −1 modified@@ -4,6 +4,7 @@ import {toCommandProperties, toCommandValue} from './utils' import * as os from 'os' import * as path from 'path' +import { v4 as uuidv4 } from 'uuid' import {OidcClient} from './oidc-utils' @@ -86,7 +87,17 @@ export function exportVariable(name: string, val: any): void { const filePath = process.env['GITHUB_ENV'] || '' if (filePath) { - const delimiter = '_GitHubActionsFileCommandDelimeter_' + const delimiter = `ghadelimiter_${uuidv4()}` + + // These should realistically never happen, but just in case someone finds a way to exploit uuid generation let's not allow keys or values that contain the delimiter. + if (name.includes(delimiter)) { + throw new Error(`Unexpected input: name should not contain the delimiter "${delimiter}"`) + } + + if (convertedVal.includes(delimiter)) { + throw new Error(`Unexpected input: value should not contain the delimiter "${delimiter}"`) + } + const commandValue = `${name}<<${delimiter}${os.EOL}${convertedVal}${os.EOL}${delimiter}` issueFileCommand('ENV', commandValue) } else {
packages/core/__tests__/core.test.ts+41 −3 modified@@ -4,6 +4,9 @@ import * as path from 'path' import * as core from '../src/core' import {HttpClient} from '@actions/http-client' import {toCommandProperties} from '../src/utils' +import * as uuid from 'uuid' + +jest.mock('uuid') /* eslint-disable @typescript-eslint/unbound-method */ @@ -41,6 +44,9 @@ const testEnvVars = { GITHUB_ENV: '' } +const UUID = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' +const DELIMITER = `ghadelimiter_${UUID}` + describe('@actions/core', () => { beforeAll(() => { const filePath = path.join(__dirname, `test`) @@ -54,6 +60,14 @@ describe('@actions/core', () => { process.env[key] = testEnvVars[key as keyof typeof testEnvVars] } process.stdout.write = jest.fn() + + jest.spyOn(uuid, 'v4').mockImplementation(() => { + return UUID + }) + }) + + afterEach(() => { + jest.restoreAllMocks() }) it('legacy exportVariable produces the correct command and sets the env', () => { @@ -91,7 +105,7 @@ describe('@actions/core', () => { core.exportVariable('my var', 'var val') verifyFileCommand( command, - `my var<<_GitHubActionsFileCommandDelimeter_${os.EOL}var val${os.EOL}_GitHubActionsFileCommandDelimeter_${os.EOL}` + `my var<<${DELIMITER}${os.EOL}var val${os.EOL}${DELIMITER}${os.EOL}` ) }) @@ -101,7 +115,7 @@ describe('@actions/core', () => { core.exportVariable('my var', true) verifyFileCommand( command, - `my var<<_GitHubActionsFileCommandDelimeter_${os.EOL}true${os.EOL}_GitHubActionsFileCommandDelimeter_${os.EOL}` + `my var<<${DELIMITER}${os.EOL}true${os.EOL}${DELIMITER}${os.EOL}` ) }) @@ -111,10 +125,34 @@ describe('@actions/core', () => { core.exportVariable('my var', 5) verifyFileCommand( command, - `my var<<_GitHubActionsFileCommandDelimeter_${os.EOL}5${os.EOL}_GitHubActionsFileCommandDelimeter_${os.EOL}` + `my var<<${DELIMITER}${os.EOL}5${os.EOL}${DELIMITER}${os.EOL}` ) }) + it('exportVariable does not allow delimiter as value', () => { + const command = 'ENV' + createFileCommandFile(command) + + expect(() => { + core.exportVariable('my var', `good stuff ${DELIMITER} bad stuff`) + }).toThrow(`Unexpected input: value should not contain the delimiter "${DELIMITER}"`) + + const filePath = path.join(__dirname, `test/${command}`) + fs.unlinkSync(filePath) + }) + + it('exportVariable does not allow delimiter as name', () => { + const command = 'ENV' + createFileCommandFile(command) + + expect(() => { + core.exportVariable(`good stuff ${DELIMITER} bad stuff`, 'test') + }).toThrow(`Unexpected input: name should not contain the delimiter "${DELIMITER}"`) + + const filePath = path.join(__dirname, `test/${command}`) + fs.unlinkSync(filePath) + }) + it('setSecret produces the correct command', () => { core.setSecret('secret val') assertWriteCalls([`::add-mask::secret val${os.EOL}`])
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-7r3h-m5j6-3q42ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2022-35954ghsaADVISORY
- github.com/actions/toolkit/commit/4beda9cbc00ba6eefe387a937c21087ccb8ee9dfghsax_refsource_MISCWEB
- github.com/actions/toolkit/security/advisories/GHSA-7r3h-m5j6-3q42ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.