fix(deploy): prevent command injection from angular.json in ng deploy - #3738
fix(deploy): prevent command injection from angular.json in ng deploy#3738herdiyana256 wants to merge 2 commits into
Conversation
The SSR->Cloud Functions deploy builder shelled out with values read verbatim
from the workspace angular.json. findPackageVersion built `${packageManager} list
${name}` for execSync, where packageManager comes from cli.packageManager and name
from each server.options.externalDependencies entry (both parsed with a raw
JSON.parse, so the Angular CLI's own validation never runs). deployToFunction
likewise ran `npm --prefix ${functionsOut} install`, with functionsOut derived
from the outputPath deploy option. A malicious or cloned Angular workspace could
therefore run arbitrary commands the moment a developer runs ng deploy.
Run the package manager and npm without a shell (execFileSync with argument
arrays), validate cli.packageManager against the supported set, reject
externalDependencies entries that are not plain package specifiers, and constrain
outputPath in the deploy schema. Adds unit tests for both validators.
There was a problem hiding this comment.
Thanks for this, and for continuing to work the ng deploy injection surface. I reproduced the hole you describe: with an externalDependencies entry like "x; touch /tmp/pwned #", the old execSync shape runs the injected command, and your execFileSync conversion stops it.
There is one thing I think blocks merging as-is, and a few smaller notes that do not.
Blocking: this breaks ng deploy on Windows
On Windows, package managers are normally launched through .cmd shims, and Node's child_process docs state that .cmd/.bat files "cannot be launched using child_process.execFile()". The old execSync ran through the shell, which resolves those shims, so it worked. execFileSync does not, so as written I believe two calls would throw on Windows:
execFileSync('npm', ['--prefix', functionsOut, 'install'])indeployToFunction, on the default SSR-to-Cloud-Functions deploy path.npmis hardcoded here and is always a.cmdshim on Windows, so this one breaks regardless of the configured package manager.execFileSync(packageManager, ['list', ...])infindPackageVersion, whenever the configured manager is a.cmdshim, which is the standard form fornpm,yarn,pnpm, andcnpm(pnpm's standalone.exebuild being the exception).
The fix needs to launch the Windows .cmd shim in a cross-platform way while still escaping the arguments, so the injection this PR closes stays closed. cross-spawn (v7) does this: on Windows it runs the shim through cmd.exe with each argument escaped, so a metacharacter is not interpreted as shell syntax.
I would not reach for a plain shell: true on these execFileSync calls as the fix, for two reasons:
- With an args array,
shell: trueconcatenates the arguments into one command line without that escaping, which is the combination Node now runtime-deprecates (DEP0190). - Not every value interpolated here is covered by the new validators. When the deploy
outputPathoption is unset,functionsOutfalls back to the server build target'soutputPath, which the schema pattern does not guard.
I have not run this on Windows, so if your setup launches these differently I would like to hear it.
Non-blocking notes
-
Spec count in the description. Locally
npm run test:nodereports 138 specs, not the 150 in the PR body. Worth correcting. -
The tests cover the validators but not their call sites. The new specs exercise
assertSupportedPackageManagerandassertSafeDependencyNamedirectly, but I could not find a test that fails if the deploy code stops calling them:- Undoing the switch to
execFileSync, or dropping a validator call fromfindPackageVersion, still passes the whole suite. - A test that spies on the command call, confirming it uses no shell and goes through the validator, would guard the fix itself so a later regression fails.
- Undoing the switch to
-
Scope of the description. "prevent command injection from angular.json" is broader than what this PR changes:
-
deployToCloudRunstill interpolatesangular.jsonvalues (outputPath,functionName,firebaseProject,region) into thegcloudcommands, whichspawnAsyncsplits on whitespace. That is argument injection rather than shell execution, and it overlaps the surface from #3726. - I would narrow the description to the calls this PR changes, or note the Cloud Run path as follow-up.
-
-
outputPathschema pattern.^[^;&|$<>\n\r()]*$rejects</code>(<code>, </code>)<code>, and $ `, which can appear in legitimate output paths, and it does not exclude whitespace. Two options:- Drop it, since the Functions path now goes through
execFileSyncand no longer needs it. - Or keep it and exclude whitespace too, so it also constrains
cloudRunOutwhenever the deployoutputPathis set.
- Drop it, since the Functions path now goes through
I am happy to take another look once the Windows path is sorted.
execFileSync cannot launch the .cmd/.bat shims that npm, yarn, pnpm and cnpm ship as on Windows, so the previous conversion broke `ng deploy` there on the default SSR-to-Cloud-Functions path. Route the npm install and the `<pm> list` calls through cross-spawn instead: it escapes each argument and, on Windows, invokes the shim through cmd.exe, keeping the injection closed without falling back to `shell: true` (whose args-array form Node runtime-deprecates under DEP0190). Funnel both calls through a single exported processHost.runPackageBin so tests can assert the deploy code shells out only through the shell-free runner. Add specs that fail if a call site regresses to a shell or drops a validator. Drop the outputPath schema pattern, which rejected legit path characters while never guarding the whitespace that the Cloud Run path actually splits on; that argument-injection surface is tracked separately in angular#3726.
|
Thanks for the careful review, and for confirming the repro. Pushed 2ce37b4 addressing everything. Blocking: Windows
|
deployToFunction / deployToCloudRun interpolate several angular.json-derived
values straight into generated, later-executed artifacts. A server build
target's outputPath is written raw into the generated Cloud Function index.js
(`require('./<outputPath>/main')`) and into the generated package.json start
script (`node <outputPath>/main.js`); functionName is written raw as the
`exports.<name>` target in index.js; region is written into a quoted string in
the default index.js template; and functionsNodeVersion is written raw into the
generated Cloud Run Dockerfile (`FROM node:<version>-slim`). A malicious or
cloned workspace could therefore run arbitrary code in the deployed
function/container (and locally during `firebase serve` preview) via
`ng deploy`. These sinks are distinct from the gcloud argv path (PR angular#3726) and
the execSync sinks (PR angular#3738).
Validate outputPath (assertSafeOutputPath, now also rejecting a leading dash
that node would read as a flag in the start script), functionName
(assertSafeFunctionName, a plain-identifier allowlist), and functionsNodeVersion
(assertSafeNodeVersion) before they reach code generation, and emit region
through JSON.stringify in the default template so it is structurally escaped
rather than screened. Add schema patterns for functionName, region and
functionsNodeVersion. Tests cover the validators directly and drive
deployToFunction / deployToCloudRun with hostile inputs so the checks cannot be
dropped without a failing spec.
The SSR to Cloud Functions deploy builder shells out with values taken verbatim from the workspace
angular.json.findPackageVersionbuilds`${packageManager} list ${name}`and passes it toexecSync.packageManagercomes fromcli.packageManagerandnamefrom eacharchitect.<project>.server.options.externalDependenciesentry. Both are read with a rawJSON.parse(readFileSync('angular.json')), so the Angular CLI's ownpackageManagerenum validation never applies.deployToFunctionsimilarly runs`npm --prefix ${functionsOut} install`, wherefunctionsOutderives from theoutputPathdeploy option. Any of these lets a malicious or cloned Angular workspace execute arbitrary commands the moment a developer runsng deploy, for exampleexternalDependencies: ["x; <command> #"]. This is the same threat model as the recently addressed deploy argv-injection, on distinct sinks that fix did not cover.The fix stops running these values through a shell.
cli.packageManageris validated against the supported set and eachexternalDependenciesentry is rejected unless it is a plain package specifier, then the package manager and npm are launched with argument arrays throughcross-spawn(v7). cross-spawn escapes each argument and, on Windows, launches the package manager's.cmd/.batshim throughcmd.exe, whichchild_process.execFilecannot do, so the fix is cross-platform and never falls back toshell: true(whose args-array form Node runtime-deprecates under DEP0190). Both calls funnel through a singleprocessHost.runPackageBin, and tests assert the deploy code shells out only through that shell-free runner and validates its inputs first, so a later regression to a shell or a dropped validator call fails the suite.Scope: this closes the shell-execution sinks on the Cloud Functions path (
findPackageVersionand the functionsnpm install). The Cloud Run path still interpolatesangular.jsonvalues into thegcloudcommands thatspawnAsyncsplits on whitespace; that is argument injection rather than shell execution and overlaps #3726, so it is left as follow-up. TheoutputPathschema pattern was dropped: it rejected legitimate path characters ((,),$) yet never excluded the whitespace the Cloud Run path actually splits on, so it added false negatives on the one path it touched and false positives everywhere.npm run test:nodepasses (154 specs, 0 failures); lint and typecheck clean.