fix(deploy): prevent command injection from angular.json in ng deploy - #3738
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.
There was a problem hiding this comment.
This addresses the blocker, thanks for turning it around so quickly.
One change I would like before this goes in, and it is about the dependency rather than the fix itself.
Please move cross-spawn back to a devDependency
The schematics build bundles it, so it never needs to be installed alongside the published package:
- It is not in the
externallist incompileSchematics, so esbuild inlines it. - The root
package.jsonis"private": true, so it is not the manifest consumers install. That issrc/package.json, which does not listcross-spawn. - No file in
dist/packages-distresolvescross-spawnexternally.
I checked this end to end rather than by reading the config, since it is the reason I am asking. With cross-spawn moved to devDependencies I ran a full build, packed the tarball, and installed it into a clean project.
Everything else here looks good to me, and the Windows problem is properly solved.
The schematics bundle inlines cross-spawn (esbuild bundles it; it is not in compileSchematics' external list), and the published manifest is src/package.json, which never listed it. Consumers install the bundle, not cross-spawn, so keep it a build-time dependency. The lockfile already recorded cross-spawn under devDependencies, so this only realigns the manifest with it.
|
Done in b7747c3, You were right on every point, and it turned out the manifest was the only thing out of step: the lockfile already had I verified the bundling end to end rather than by reading the config:
Lint and typecheck are clean. Thanks for checking the tarball install by hand, that is what surfaced the mismatch. |
armando-navarro
left a comment
There was a problem hiding this comment.
Thanks for making the requested change. Everything looks good now. Approving PR and merging.
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.