CVE-2022-35954 (published 2022-08-15T11:21Z)

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`.

CVSS v3 base score: 5.0, MEDIUM (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:N/I:L/A:N)

Weakness information:
cwe_id: CWE-74
cwe_name: Improper Neutralization of Special Elements in Output Used by a Downstream Component ('Injection')
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.
extended_description: Software or other automated logic has certain assumptions about what constitutes data and control respectively. It is the lack of verification of these assumptions for user-controlled input that leads to injection problems. Injection problems encompass a wide variety of issues -- all mitigated in very different ways and usually attempted in order to alter the control flow of the process. For this reason, the most effective way to discuss these weaknesses is to note the distinct features that classify them as injection weaknesses. The most important issue to note is that all injection problems share one thing in common -- i.e., they allow for the injection of control plane data into the user-controlled data plane. This means that the execution of the process may be altered by sending code in through legitimate data channels, using no other mechanism. While buffer overflows, and many other flaws, involve the use of some further issue to gain execution, injection problems need only for the data to be parsed.
         

Fix information:
repo_url: https://github.com/actions/toolkit
commit_details: repo_url: https://github.com/actions/toolkit
author: Cory Miller
author_date: 2022-08-08 14:16:39-04:00
msg: 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>
repository: repo_url: https://github.com/actions/toolkit
repo_name: actions/toolkit
description: The GitHub ToolKit for developing GitHub Actions.
repo_language: TypeScript
owner: actions
file_changes: filename: RELEASES.md
change_type: ModificationType.MODIFY
diff: @@ -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)
 

programming_language: Markdown
filename: core.test.ts
change_type: ModificationType.MODIFY
diff: @@ -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}`])

programming_language: TypeScript
filename: package-lock.json
change_type: ModificationType.MODIFY
diff: @@ -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,6 +31,12 @@
       "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",
@@ -36,6 +44,14 @@
       "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=="
     }
   }
 }

programming_language: HTML
filename: package.json
change_type: ModificationType.MODIFY
diff: @@ -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"
   }
 }

programming_language: JavaScript
filename: core.ts
change_type: ModificationType.MODIFY
diff: @@ -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 {

programming_language: TypeScript