fix(deploy): pass gcloud arguments as an array instead of a joined string - #3726
fix(deploy): pass gcloud arguments as an array instead of a joined string#3726herdiyana256 wants to merge 4 commits into
Conversation
…ring spawnAsync built the gcloud command as a single template-literal string and split it on whitespace before handing it to spawn(). Any deploy option containing a space (region, firebaseProject, functionName, cloudRunOptions.vpcConnector, none of which have a schema pattern) would be split into extra argv entries, letting a value from angular.json add unintended flags to the gcloud builds submit / run deploy / auth activate-service-account invocations. spawnAsync now takes command and args separately, matching child_process spawn's own signature, and the three call sites build their argument lists as arrays instead of interpolating into one string. This removes the join/split round-trip entirely rather than trying to validate each field.
armando-navarro
left a comment
There was a problem hiding this comment.
Thanks for this, and for the unusually clear writeup and repro. I worked through it and everything checks out on my end.
I reproduced the original problem against the compiled deploy code:
- Driving
deployToCloudRunwith aregionofus-central1 --set-env-vars=INJECTED=owned, the old whitespace split sent--set-env-vars=INJECTED=ownedtogcloudas its own argument. - With your change the same input stays a single
--regionvalue, so the split is gone. - I also confirmed all three
spawnAsynccall sites are converted and that a space in a legitimate value (a path, a project name) no longer breaks the invocation.
A few things came up, none blocking your fix. The first is the one I would most suggest folding in while you are here.
Worth folding in: spawnAsync only treats exit code 1 as failure
This one predates your PR, so it is not something you introduced, but you are editing this exact function so it is a natural place to fix it. The close handler rejects only when code === 1 and resolves for anything else:
- gcloud's scripting docs only promise a "non-zero" exit on failure, not specifically
1, and gcloud commands do exit other non-zero values, so those failures currently resolve as success. - A build killed by a signal (an out-of-memory
gcloud builds submit, for instance) arrives withcode === null, which also resolves as success.
The effect is that a failed builds submit or run deploy can be reported as a successful deploy and the schematic prints success anyway. Changing the guard to if (code !== 0) would make any non-zero or signal exit reject. Entirely your call whether to include it here or leave it for a follow-up.
Optional, defense-in-depth: the service name is a positional argument
gcloud run deploy takes the service name (functionName) as a positional argument, and the schema does not constrain that value.
- If it starts with a dash, gcloud's parser reads it as a flag rather than as the name.
- Your array change already stops space-splitting everywhere, and this is a narrower and pre-existing case, so it is not something you need to solve here.
- If you want to close it too, putting a literal
--right before the service name ('deploy', '--', serviceId) makes gcloud treat everything after it as values.
Optional: the argument form
You switched --region=${options.region} to --region, options.region. A couple of notes if you want to weigh keeping the = form:
- Both forms are valid gcloud syntax, but the docs note the
=form is required when a value can start with-. - With arguments passed as an array this is not a security concern either way, so it is purely a judgment call.
Keeping --region=, --project= would match gcloud's own recommendation, if you would rather.
Optional: a type nit on the optional values
region and firebaseProject are optional in the deploy schema, so as array elements they are technically string | undefined. Two things worth knowing before you decide whether to touch it:
- It has no runtime effect: Node coerces a missing value to the string
"undefined", the same result as the old interpolation. - It does not affect the build or any check that runs on the PR.
If you want the types exactly right, a small narrowing on those two would do it.
For a follow-up, not here
While I was in the file I noticed two older spots that build a shell command by interpolating values into a string, the same shape as what you fixed here:
actions.ts:124, anexecSyncfor the package-version lookup.actions.ts:247, anexecSyncrunningnpm installon the Cloud Functions path, where the path comes from a user option.
They predate your change and are out of scope for this PR. I wanted to flag them in case you or we want to pick them up separately.
Would you be open to adding a small test for the arg construction? The path did not have coverage before, so a test that asserts the argv shape would lock your fix in. Happy to point at the existing actions.jasmine.ts harness if useful. I can take care of any of these suggestions myself as well, if you'd prefer.
Either way, thank you again, this is a good catch.
… construction tests spawnAsync's close handler only rejected on code === 1. gcloud's own docs only promise a non-zero exit on failure, and a killed process (e.g. an out-of-memory gcloud builds submit) reports code === null, both of which previously resolved as success, so a failed deploy could be reported as successful. Now rejects on any code !== 0. Also extracts the gcloud args construction for both cloud run calls (buildCloudRunBuildsSubmitArgs, buildCloudRunDeployArgs) into pure, exported functions, and adds tests asserting a value containing a space (region, firebaseProject, a cloudRunOptions value) stays a single argv entry rather than being split into extra flags, locking in the fix from the previous commit without needing to mock child_process.spawn.
|
Thanks for the thorough review. Pushed 7c2668e addressing the two I'd call must-do:
Left the rest as follow-ups rather than guessing:
|
armando-navarro
left a comment
There was a problem hiding this comment.
Thank you, this is a great turnaround, and both changes look right to me.
I pulled 7c2668e and ran the full build with the node suite:
- It passes at 59 specs, and I checked your new argv tests are doing real work by recombining
--regionwith its value and watching the suite go red. - Extracting
buildCloudRunDeployArgsandbuildCloudRunBuildsSubmitArgsas pure functions and asserting on them is a nicer approach than mockingspawn, and it reads well.
One correction I owe you, and it cuts against my own earlier note: you were right not to land the -- change unverified.
- My original placement (a
--before the service name) would have pushed the flags behind the separator and broken the deploy, which is the failure you raised. - I then thought a trailing
--(service name last) would be the safe form, but I could not verify that against a real gcloud either. Thegcloud run deployreference documents no--separator, and the one documented use of--in gcloud is passing arguments through to an external program rather than marking the end of flags, so I am not going to assert any--form here. - I should also be straight that the underlying worry, a leading-dash service name being read as a flag, is something I reasoned about from argument-parser behavior, not something I confirmed on a real gcloud.
If you ever do want to close that edge without depending on gcloud's parser at all, the surest route is a pattern on functionName in the schema so a value starting with a dash never reaches the command. It is a pre-existing edge and entirely optional.
Thank you for holding the line on not shipping something untested, that instinct was the right one.
Leaving the rest as follow-ups sounds right to me. Thanks again for the careful work on this.
functionName had no schema constraint, so a value starting with a dash
reached gcloud run deploy in the service-name positional slot. Both
values also land in the generated Cloud Functions source: functionName
as the exports.<name> property, region inside a single-quoted string
literal in defaultFunction, so neither is argv-only.
functionName is now ^[A-Za-z][A-Za-z0-9_-]{0,62}$, which accepts both
Cloud Run service IDs and the JS identifiers the Functions template
needs, and region is ^[a-z]+-[a-z]+\\d+$, checked against every current
GCP region.
|
Thanks for going back and correcting your own note on the Picked up your schema suggestion in 530af9b, and while implementing it I found the argument for it is stronger than the leading-dash edge alone. Both values also land in the generated Cloud Functions source, not just in argv: // functions-templates.ts, defaultFunction / functionGen2
exports.${functionName || DEFAULT_FUNCTION_NAME} = functions
.region('${options.region || DEFAULT_FUNCTION_REGION}')
I deliberately left Also merged On verification, to be precise about what I did and did not run: I think that clears everything except the follow-ups we both agreed to leave. Since the remaining items are optional and the workflow runs need a maintainer to approve them, would you mind approving when you get a chance? |
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.
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.
`spawnAsync` built the gcloud command as a single template-literal string and split it on whitespace (`command.split(/\s+/)`) before handing it to `spawn()`. Any deploy option containing a space (`region`, `firebaseProject`, `functionName`, `cloudRunOptions.vpcConnector`, none of which have a schema `pattern`) would be split into extra argv entries, letting a value from `angular.json` add unintended flags to the `gcloud builds submit` / `gcloud run deploy` / `gcloud auth activate-service-account` invocations.
`spawnAsync` now takes `command` and `args` separately, matching `child_process.spawn`'s own signature, and the three call sites build their argument lists as arrays instead of interpolating into one string. This removes the join/split round-trip entirely rather than trying to validate each field individually, addressing the existing `// TODO validate serviceId, firebaseProject, and vpcConnector both to limit errors and opp for injection` comment.
Verified with a standalone repro pointing `spawnAsync` at a fake `gcloud` binary that records its argv: a `region` value of `"us-central1 --update-env-vars=..."` previously landed as two separate argv tokens (the injected flag reaching `gcloud` as its own argument); after this change it lands as a single `--region` value.
`npx tsc --noEmit` and `npx eslint src/schematics/deploy/actions.ts` both pass clean.