Fix command-injection RCE in the CI integration - #20
Closed
YuliiaKovalova wants to merge 1 commit into
Closed
Conversation
`execCommand` passed `shell: true` on Windows. Node does not escape the `args` array when a shell is used - it concatenates the arguments into a command line handed to cmd.exe (DEP0190), so any argument containing `&`, `|`, `;`, `$`, `(` or `)` runs as a separate command. Every value that reached those arguments was attacker-influenced: - owner/repo from `parseGitHubRemote(git remote get-url origin)` - org/project from `parseAzdoRemote` - branch from `git rev-parse --abbrev-ref HEAD` - artifactName returned by the CI server Git permits shell metacharacters in ref names, and `parseGitHubRemote` matched `github.com` as a substring, so `https://evil-github.com/a/b` and `https://github.com/owner/repo&calc` both parsed successfully. Cloning a repo with a crafted remote URL or branch name and running "Download Binlog from CI" was remote code execution on Windows. Fixed in three independent layers. 1. No shell (src/commandResolver.ts, new) `shell: true` is gone. `resolveExecutable` searches PATH once per command name, honouring PATHEXT, restricted to `.exe/.com/.cmd/.bat`, never searching the working directory, and caches successful resolutions. Real executables are launched directly with `shell: false`. `.cmd`/`.bat` shims (az) go through `cmd.exe /d /s /c`. Note that `spawn('cmd.exe', ['/d','/s','/c', shim, ...args])` is NOT sufficient: libuv's `quote_cmd_arg` only quotes an argument that contains a space, tab or quote, so `main&calc` would arrive unquoted and cmd.exe would still split the command. Instead the command line is built and quoted here, then passed with `windowsVerbatimArguments: true` - still no shell. Characters that cannot be neutralised inside cmd.exe double quotes (`"`, `%`, `!`, CR, LF, NUL) are rejected rather than escaped. 2. Allow-list validation at the boundary (src/ciSafety.ts) `isValid*`/`assertValid*` for repo identifiers (`^[A-Za-z0-9._-]+$`), git refs (shell metacharacters, whitespace and control characters rejected), artifact names (no path separators or metacharacters) and run ids. Applied in every function that builds `gh`/`az` arguments and to manually entered org/project and owner/repo. Rejection surfaces a user-facing error naming the offending value. 3. Real URL parsing (src/gitRemoteParse.ts, new) The scp form `git@host:owner/repo` is handled explicitly and everything else goes through `new URL()`. GitHub requires hostname `github.com` or `*.github.com`; Azure DevOps requires `dev.azure.com`, `ssh.dev.azure.com` or `*.visualstudio.com`. Path segments are percent-decoded before validation so `%26`/`%2F` payloads are caught. GitHub Enterprise Server remains unsupported, as before, but the failure is now explicit ("GitHub Enterprise remotes are not supported.") instead of a generic "no CI provider found". `github.com.attacker.io` is deliberately classified as unrelated, not as Enterprise. Two further call sites had the same defect and are reachable from the same attack chain (a CI artifact may contain `evil&calc.binlog`): - buildCheck.ts `detectSdkVersion` used `shell: true`. - extension.ts `binlog.openInStructuredLogViewer` passed an attacker-influenced file path into `cmd /c start` unquoted. Tests - src/test/gitRemoteParse.test.ts (new): spoofed hosts, metacharacter payloads, legitimate remotes (dotted repo names, `.git` suffix, trailing slash, scp form), Enterprise classification, AzDO anchoring. - src/test/ciSafety.test.ts: the validators, `quoteForCmdExe`, `buildLaunchSpec` (direct exec vs. cmd shim vs. unresolvable, and that the working directory is never searched), plus a regression test that walks src/ and fails if any file ever gives child_process a truthy `shell` option. 316 tests pass. Behaviour changes worth noting: Azure DevOps project names containing spaces and CI artifact names containing parentheses are now rejected with an explicit message. Loosen `REPO_IDENTIFIER_RE` / `ARTIFACT_NAME_RE` in src/ciSafety.ts if those turn out to matter in practice. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 64a43ea9-7f9c-4024-a1bf-cfe3c35a0ed2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
execCommandinsrc/ciIntegration.tspassedshell: trueon Windows. Node does not escape theargsarray when a shell is used — it concatenates the arguments into a command line handed tocmd.exe(this is exactly what DEP0190 warns about). An argument ofmain&echo Xcausesecho Xto run as a separate command.Every value that reached those arguments was attacker-influenced:
owner/repoparseGitHubRemote(git remote get-url origin)gh run list --repo …,gh run download --name …org/projectparseAzdoRemoteaz … --org https://dev.azure.com/… --project …branchgit rev-parse --abbrev-ref HEADgh run list --branch …artifactNamegh run download --name …Git permits
&,|,;,$,(,)in branch names, andparseGitHubRemotematchedgithub.comas a substring, so all of these parsed successfully:https://evil-github.com/attacker/payloadhttps://notgithub.com/attacker/payloadhttps://github.com/owner/repo&calcNet effect: cloning a repo with a crafted remote URL or branch name and running "Download Binlog from CI" was remote code execution on Windows.
The fix — three independent layers
1. No shell —
src/commandResolver.ts(new)shell: trueis gone.resolveExecutablesearchesPATHonce per command name, honouringPATHEXT, restricted to.exe/.com/.cmd/.bat, never searching the working directory (binary planting), and caches successful resolutions only. Real executables are launched directly withshell: false..cmd/.batshims (onlyazin practice) go throughcmd.exe /d /s /c.2. Allow-list validation at the boundary —
src/ciSafety.tsisValid*/assertValid*for:^[A-Za-z0-9._-]+$, no leading-(option injection), not./..Applied in every function that builds
gh/azarguments, and to manually enteredorg/projectandowner/repo. Rejection surfaces a user-facing error naming the offending value.3. Real URL parsing —
src/gitRemoteParse.ts(new)The scp form
git@host:owner/repois handled explicitly; everything else goes throughnew URL().hostname === 'github.com' || hostname.endsWith('.github.com')dev.azure.com,ssh.dev.azure.comor*.visualstudio.com%26/%2Fpayloads are caughtgithub.com.attacker.iois deliberately classified as unrelated, not as EnterpriseGitHub Enterprise remains unsupported (unchanged behaviour), but the failure is now explicit —
GitHub Enterprise remotes are not supported.— instead of a generic "no CI provider found".Two adjacent sites with the same defect
Both are reachable from the same attack chain, since a CI artifact may contain a file named
evil&calc.binlog:src/buildCheck.tsdetectSdkVersionusedshell: true.src/extension.tsbinlog.openInStructuredLogViewerpassed an attacker-influenced file path intocmd /c startunquoted.Tests
src/test/gitRemoteParse.test.ts(new) — spoofed hosts (evil-github.com,notgithub.com,github.com.attacker.io, userinfo tricks), metacharacter payloads (repo&calc,repo;whoami,repo|x,$(calc), backticks, percent-encoded), legitimate remotes (My.Repo,.gitsuffix, trailing slash, scp form,ssh://, deep Actions URLs), Enterprise classification, AzDO host anchoring.src/test/ciSafety.test.ts— the validators;quoteForCmdExe;buildLaunchSpec(direct exec vs. cmd shim vs. unresolvable, and that the working directory is never searched); plus a regression test that walkssrc/and fails if any file ever handschild_processa truthyshelloption.npm test→ 316 passing.Behaviour changes worth reviewing
%20).Build (Release)) are now rejected.Both follow the strict allow-list; loosen
REPO_IDENTIFIER_RE/ARTIFACT_NAME_REinsrc/ciSafety.tsif they turn out to matter in practice.