CVE-2022-35954: Improper Neutralization of Special Elements in Output Used by a Downstream Component ('Injection') (CWE-74)
value: The GitHub Actions ToolKit provides a set of packages to make creating actions easier. The `core.exportVariable` function uses a well known delimiter that attackers can use to break out of that specific variable and assign values to other arbitrary variables. Workflows that write untrusted values to the `GITHUB_ENV` file may cause the path or other environment variables to be modified without the intention of the workflow or action author. Users should upgrade to `@actions/core v1.9.1`. If you are unable to upgrade the `@actions/core` package, you can modify your action to ensure that any user input does not contain the delimiter `_GitHubActionsFileCommandDelimeter_` before calling `core.exportVariable`.
CWE description: The product constructs all or part of a command, data structure, or record using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify how it is parsed or interpreted when it is sent to a downstream component.
CVSS v3 base score: 5
Published: 2022-08-15T11:21Z
Repository: https://github.com/actions/toolkit
Language: JSON
Files changed: packages/core/RELEASES.md, packages/core/__tests__/core.test.ts, packages/core/package-lock.json, packages/core/package.json, packages/core/src/core.ts
Commit date: 2022-08-08 14:16:39 -0400
Commit message: Merge pull request from GHSA-7r3h-m5j6-3q42

* use uuid as our multiline env delimiter

* remove extra fn

* Fix version

* also throw error if delimiter is found in name or value

* move delimiter and uuid to global var in test

* upgrade uuid to newest version

* remove spy variable

* Update packages/core/src/core.ts

Co-authored-by: Thomas Boop <52323235+thboop@users.noreply.github.com>

* Update packages/core/src/core.ts

Co-authored-by: Thomas Boop <52323235+thboop@users.noreply.github.com>
Diff: diff --git a/packages/core/RELEASES.md b/packages/core/RELEASES.md
index 4e0c3a7..c8def6f 100644
--- a/packages/core/RELEASES.md
+++ b/packages/core/RELEASES.md
@@ -1,7 +1,10 @@
 # @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)
 
 ### 1.8.2
 - Update to v2.0.1 of `@actions/http-client` [#1087](https://github.com/actions/toolkit/pull/1087)
diff --git a/packages/core/__tests__/core.test.ts b/packages/core/__tests__/core.test.ts
index 7ffcc6c..fd7fd57 100644
--- a/packages/core/__tests__/core.test.ts
+++ b/packages/core/__tests__/core.test.ts
@@ -2,10 +2,13 @@ import * as fs from 'fs'
 import * as os from 'os'
 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 */
 
 const testEnvVars = {
   'my var': '',
@@ -39,10 +42,13 @@ const testEnvVars = {
   // File Commands
   GITHUB_PATH: '',
   GITHUB_ENV: ''
 }
 
+const UUID = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d'
+const DELIMITER = `ghadelimiter_${UUID}`
+
 describe('@actions/core', () => {
   beforeAll(() => {
     const filePath = path.join(__dirname, `test`)
     if (!fs.existsSync(filePath)) {
       fs.mkdirSync(filePath)
@@ -52,10 +58,18 @@ describe('@actions/core', () => {
   beforeEach(() => {
     for (const key in testEnvVars) {
       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', () => {
     core.exportVariable('my var', 'var val')
     assertWriteCalls([`::set-env name=my var::var val${os.EOL}`])
@@ -89,34 +103,58 @@ describe('@actions/core', () => {
     const command = 'ENV'
     createFileCommandFile(command)
     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}`
     )
   })
 
   it('exportVariable handles boolean inputs', () => {
     const command = 'ENV'
     createFileCommandFile(command)
     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}`
     )
   })
 
   it('exportVariable handles number inputs', () => {
     const command = 'ENV'
     createFileCommandFile(command)
     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}`])
   })
 
diff --git a/packages/core/package-lock.json b/packages/core/package-lock.json
index 2453168..119f418 100644
--- a/packages/core/package-lock.json
+++ b/packages/core/package-lock.json
@@ -1,20 +1,22 @@
 {
   "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": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.0.1.tgz",
@@ -27,17 +29,31 @@
       "version": "12.0.2",
       "resolved": "https://registry.npmjs.org/@types/node/-/node-12.0.2.tgz",
       "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": {
     "@actions/http-client": {
       "version": "2.0.1",
@@ -51,12 +67,23 @@
       "version": "12.0.2",
       "resolved": "https://registry.npmjs.org/@types/node/-/node-12.0.2.tgz",
       "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=="
     }
   }
 }
diff --git a/packages/core/package.json b/packages/core/package.json
index 4816c6a..45341c3 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,8 +1,8 @@
 {
   "name": "@actions/core",
-  "version": "1.9.0",
+  "version": "1.9.1",
   "description": "Actions core lib",
   "keywords": [
     "github",
     "actions",
     "core"
@@ -34,11 +34,13 @@
   },
   "bugs": {
     "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"
   }
 }
diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts
index e4f8bf9..85bdcb5 100644
--- a/packages/core/src/core.ts
+++ b/packages/core/src/core.ts
@@ -2,10 +2,11 @@ import {issue, issueCommand} from './command'
 import {issueCommand as issueFileCommand} from './file-command'
 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'
 
 /**
  * Interface for getInput options
@@ -84,11 +85,21 @@ export function exportVariable(name: string, val: any): void {
   const convertedVal = toCommandValue(val)
   process.env[name] = convertedVal
 
   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 {
     issueCommand('set-env', {name}, convertedVal)
   }

Vulnerable code:       `my var<<_GitHubActionsFileCommandDelimeter_${os.EOL}var val${os.EOL}_GitHubActionsFileCommandDelimeter_${os.EOL}`
      `my var<<_GitHubActionsFileCommandDelimeter_${os.EOL}true${os.EOL}_GitHubActionsFileCommandDelimeter_${os.EOL}`
      `my var<<_GitHubActionsFileCommandDelimeter_${os.EOL}5${os.EOL}_GitHubActionsFileCommandDelimeter_${os.EOL}`
  "version": "1.9.0",
      "version": "1.8.1",
        "@actions/http-client": "^2.0.1"
        "@types/node": "^12.0.2"
  "version": "1.9.0",
    "@actions/http-client": "^2.0.1"
    "@types/node": "^12.0.2"
    const delimiter = '_GitHubActionsFileCommandDelimeter_'
Fixed code: ### 1.9.1
- Randomize delimiter when calling `core.exportVariable`

import * as uuid from 'uuid'

jest.mock('uuid')
const UUID = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d'
const DELIMITER = `ghadelimiter_${UUID}`


    jest.spyOn(uuid, 'v4').mockImplementation(() => {
      return UUID
    })
  })

  afterEach(() => {
    jest.restoreAllMocks()
      `my var<<${DELIMITER}${os.EOL}var val${os.EOL}${DELIMITER}${os.EOL}`
      `my var<<${DELIMITER}${os.EOL}true${os.EOL}${DELIMITER}${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)
  })

  "version": "1.9.1",
      "version": "1.9.1",
        "@actions/http-client": "^2.0.1",
        "uuid": "^8.3.2"
        "@types/node": "^12.0.2",
        "@types/uuid": "^8.3.4"
    "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/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"
      }
    "@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
    },
    },
    "uuid": {
      "version": "8.3.2",
      "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
      "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
  "version": "1.9.1",
    "@actions/http-client": "^2.0.1",
    "uuid": "^8.3.2"
    "@types/node": "^12.0.2",
    "@types/uuid": "^8.3.4"
import { v4 as uuidv4 } from 'uuid'
    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}"`)
    }