| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
Sorry, something went wrong.
There was a problem hiding this comment.
Thanks for this, and for keeping at the ng deploy hardening. I reproduced what you describe: rendering the generated function with a crafted server outputPath gives a standalone require('child_process').execSync(...) statement in index.js, and a crafted functionsNodeVersion adds its own RUN line to the Dockerfile.
There is one gap I think should be filled before this merges, and a few smaller notes that should not hold it up.
functionName and region come from the same deploy options block as functionsNodeVersion, and both are written straight into the same generated index.js with no validation:
Neither has a pattern in schema.json (functionName and region are both plain type: string), so nothing upstream constrains them either.
Since the description says this prevents code-generation injection from angular.json values, either of these would unblock it for me:
If I have misread any of this, point me at it and I will take another look.
Sorry, something went wrong.
|
Thanks for the careful review, and for reproducing both sinks. I took the outcome you preferred and closed the functionName / region gap in this PR rather than deferring it, and folded in the smaller notes too. Pushed as a single amended commit. Blocking: functionName and region
Non-blocking notes
Also added pattern entries for functionName and region in schema.json so the constraints hold upstream as well. npm run test:node (170 specs, 0 failures), npm run test:node-esm, ng lint, and a tsc -p tsconfig.build.json --noEmit are all clean. |
Sorry, something went wrong.
There was a problem hiding this comment.
This closes both gaps, thanks. Two things I think need changing before it merges, one of them introduced by the fix.
functionName does double duty. On the Cloud Functions path it becomes the exports.<name> target in generated JavaScript, where a plain identifier is exactly the right constraint. On the Cloud Run path the same option is the service ID (serviceId = options.functionName, and the option's own description says so), and there the constraint does not fit:
Google's Cloud Run API reference says a service ID "must begin with letter, and cannot end with hyphen", so hyphens mid-name are allowed. A service named my-ssr-service is legitimate and is rejected by the new ^[A-Za-z_$][A-Za-z0-9_$]*$.
The pattern permits _ and $, which cannot work in a service name, since it lands in the assigned hostname (https://SERVICE_NAME-PROJECT_NUMBER.REGION.run.app) and the docs describe that as a DNS segment.
This is enforced rather than advisory. @angular-devkit/architect validates builder options against the builder schema before the builder runs, so a Cloud Run user with a hyphenated service name gets refused where it worked before.
Only the schema needs to change. assertSafeFunctionName can stay exactly as it is, since deployToCloudRun generates no JavaScript from this value, so the identifier requirement only ever needs to apply on the Functions path.
Widen the functionName pattern rather than removing it. It is doing real work on the Cloud Run path.
^[A-Za-z](?:[A-Za-z0-9_$-]*[A-Za-z0-9_$])?$ is one option. It accepts ssr, my-ssr-service and my_fn, rejects whitespace, shell metacharacters and a leading dash, and enforces Cloud Run's "cannot end with hyphen" rule.
actions.ts carries // TODO validate serviceId, firebaseProject, and vpcConnector both to limit errors and opp for injection. Once the pattern lands, that comment is misleading in a way worth fixing in the same change:
If you read the Cloud Run side differently, tell me. I could not find a page where Google states the full service-name character set, so I am going off the "cannot end with hyphen" wording and the hostname format.
Sorry, something went wrong.
|
Following up on my review above, because I owe you a correction. I reviewed this PR without checking your other open ones first, and that was a mistake on my part. #3726 already adds a functionName pattern, ^[A-Za-z][A-Za-z0-9_-]{0,62}$, which does what I asked for here and does it better: it permits hyphens, requires a leading letter, and bounds the length at 63, which my suggestion did not. Your 2026-08-07 comment there also points out that functionName and region reach the generated Cloud Functions source and not just argv, which is the same thing I wrote up here as a finding five days later. So my ask above is really an ask to keep a pattern you had already written, in a PR that was waiting on me. What I think should happenThe two PRs turn out to fit together rather than compete, since each covers a different layer:
Running both rules over the same inputs: ssr and my_fn pass everywhere, my-ssr-service passes the schema and works on Cloud Run while giving a clear error on the Functions path, and a b --project evil, x;id and -rf are rejected at the schema for both paths. Concretely, if that reading matches yours: drop the functionName and region pattern changes from this PR and keep the runtime check and the template escaping, letting #3726 own the schema. The two branches currently conflict, so one of them has to give up those lines either way. You wrote both, so you are better placed than me to say whether that split is right. If you would rather this PR own the schema and #3726 drop it, that works too and I will not argue for one over the other. Sorry about that. The workflow approvals on your other two PRs are being sorted out as well. |
Sorry, something went wrong.
|
Agreed on the split, that reading matches mine. Each PR ends up owning the layer it fits, so no need to argue one over the other. Dropped the functionName and region schema patterns from this branch and left them to #3726. Its functionName pattern ^[A-Za-z][A-Za-z0-9_-]{0,62}$ is the better one: it allows the hyphens a Cloud Run service ID needs, requires a leading letter, and bounds the length at 63, none of which mine did. Its region pattern ^[a-z]+-[a-z]+\d+$ is tighter than the ^[a-z0-9-]+$ I had. That clears the schema.json overlap; whichever branch lands first, the other leaves those lines untouched. What stays here is the part #3726 does not cover:
Narrowed the TODO above the gcloud calls to firebaseProject and vpcConnector. serviceId is options.functionName, which #3726's pattern now constrains, and the other two are still unguarded, so they stay on the list. That gcloud block is the part #3726 rewrites, so actions.ts will still take a normal merge between the two branches, but only in that block, not in the schema. npm run test:node and test:node-esm are green (158 specs, 0 failures), ng lint and tsc -p tsconfig.build.json --noEmit are clean. |
Sorry, something went wrong.
There was a problem hiding this comment.
Thanks, both of the things I asked for are done, and the split is working: schema.json and the template file now merge with main cleanly, which was the whole point.
Other than the merge conflict that needs resolving, nothing here blocks it from my side. See the last paragraph for a point regarding merge conflict resolution.
One small correction, one question.
I rechecked the guards by removing them one at a time rather than all at once:
To be clear, that is not a hole in the protection. The static target's outputPath is only ever used as a filesystem path, so unlike the server one it never reaches generated source or a command, and there is nothing exploitable for a spec to assert. Guarding it anyway is sensible.
The part worth changing is the comment at actions.jasmine.ts:350, which says each spec fails if its assert is removed. I took that at face value and it cost me some time. Narrowing the sentence to the four it covers would do it.
The new pattern applies to both deploy paths, but the Dockerfile it protects is only written on the Cloud Run path.
I could not find a Firebase doc showing a range in engines.node, so this may be entirely theoretical. Do you know whether anyone deploys with one? If not, leaving it as is seems fine to me, and I would rather ask than guess.
Two small things, both fine to leave for a follow-up.
One thing for whenever you resolve the existing merge conflict: main no longer has the TODO at all, since #3726 removed it when it landed. Your narrowed version is the one that should survive, because firebaseProject and vpcConnector really are still unvalidated. It would be worth adding the outputPath deploy option to that same line, since it has no rule either.
Sorry, something went wrong.
|
Thanks. Rebased on current main, so the conflict is gone, and both of the smaller notes are in as well since they were cheap. Pushed as a single amended commit. The spec-file commentYou were right that the sentence overpromised, and your diagnosis of withServerOutputPath was exactly the cause. Rather than narrow the claim I closed the gap, since it turned out to be two specs:
I re-ran your experiment, removing each assertSafe* call one at a time and rebuilding between runs. All six call sites are now pinned:
Reverting the JSON.stringify in the region template is caught too (1 failure). The comment now says exactly that, and notes why the static target has nothing beyond the rejection itself to assert. That was your point, and worth keeping in the file. functionsNodeVersion and semver rangesChecked this rather than guessing, and the answer is that a range cannot work on either path today, so the pattern is not cutting off anything usable. On the Cloud Functions path, firebase-tools does not treat engines.node as a range at all. In deploy/functions/runtimes/node/parseRuntimeAndValidateSDK.js it concatenates: const runtime = `nodejs${engines.node}`;
if (!supported.isRuntime(runtime)) {
throw new FirebaseError(`Detected node engine ${engines.node} in package.json, which is not a supported version. ...`);
}So >=18 becomes nodejs>=18 and is rejected outright, as are 20.x and ^20. Only an exact major that maps to a supported runtime gets through. On the Cloud Run path the value is a Docker image tag (node:<version>-slim), where none of those three is valid either. The satisfies() call is only the local "your Node.js does not match the runtime" warning, and a plain version is a perfectly good range for it: satisfies('20.11.1', '20') is true. So the pattern rejects only values that would have failed later, and with a worse error. I left it as is and expanded the schema description to say a plain version is expected and why, so the next person does not have to work this out from the deploy failure. Worth noting the pattern is a security screen rather than a correctness one: it still allows 20.11.1, which is a valid Docker tag but not a valid Functions runtime. Tightening that is a separate question from injection, so I left it alone. Both optional notesTook both, since they were small. Whitespace and globs. The character class is now: if (/['"`\\\s;$&|<>(){}*?]/.test(outputPath) || outputPath.startsWith('-')) {\s covers the space, the tab and the newlines the old class listed separately, and * and ? cover the globs. So dist/x --experimental-flag, dist/*, dist/?pp and a tab-separated path all reject now, each with a spec, and a comment in the spec file records that these are breakage rather than injection. Guard ordering. assertSafeNodeVersion moved up beside the two assertSafeOutputPath calls at the top of deployToCloudRun, before removeSync(cloudRunOut) and both copySync calls. Added a spec that asserts a rejected version leaves removeSync, copySync and writeFileSync all uncalled, so the ordering does not quietly drift back. The TODORestored in narrowed form above the gcloud calls, now covering all three values that are still unvalidated: // TODO validate firebaseProject, vpcConnector, and the outputPath deploy option both to // limit errors and opp for injection The outputPath deploy option is a good catch. It has no schema pattern and no runtime check, and it is the one that becomes functionsOut / cloudRunOut. npm run test:node and test:node-esm are green (209 specs, 0 failures), ng lint passes, and tsc -p tsconfig.build.json --noEmit is clean. The branch merges into current main without conflicts. |
Sorry, something went wrong.
There was a problem hiding this comment.
Sorry for so many review rounds. I felt the extra code you added last commit warranted a deeper look at things.
The comment above it says that compiling the source without running it shows nothing broke out of the string literal. I don't think that holds:
What would pin it is running the rendered source with a stubbed require and asserting the injected call never fires, which is what the comment describes.
Characters it lets through:
Adding [, ] and ~ closes both cases and still allows ../dist/browser.
The other direction is mine. I asked for \s, * and ? with the Cloud Run start script in mind, and since all four call sites share the helper, they now apply in places where the characters I asked to add can't cause a problem:
So a folder name with a space in it is now rejected at three of the four call sites, and at none of those three could the space have caused a problem. Two ways to fix it:
The Dockerfile line is FROM node:<version>-slim, so on the Cloud Run path this value is a Docker tag, and digits-and-dots refuses most of the ones the official image publishes:
Would ^(?!latest$)[\w][\w.-]{0,127}$ work for you? That is Docker's own tag grammar with latest excluded.
I ran it both ways before suggesting it. With a bare character set your latest spec fails, and with the version above the suite is 209 passing and lint is clean.
The assertSafeFunctionName spec lists _app and $fn as valid, but the functionName pattern that #3726 put in the schema rejects both, so neither can reach the builder through a validated angular.json.
assertSafeOutputPath is typed to return the path, but all four callers discard it, and its two new siblings return nothing. It seems it doesn't need to return anything.
The region spec finds the generated index.js by position, spy.calls.argsFor(1)[1], so it depends on the package.json write staying first. Selecting the write whose path ends with index.js survives a reorder.
The region escaping has no test in functions-templates.jasmine.ts, which already has a defaultFunction group. Asserting there that a hostile region comes out JSON.stringify-ed would test the change directly, rather than only through a full builder run that can go red for unrelated reasons.
deploy codegen input validation (injection hardening) sits next to the existing deploy input validation (command-injection hardening), and the two differ only by the word "codegen". I found the suite output hard to scan with both in it.
The as unknown as on withOutputPaths goes away by annotating its return type and handing back promises, with the unrecognized target throwing rather than returning undefined:
const withOutputPaths = (staticOutputPath: string, serverOutputPath: string): BuilderContext['getTargetOptions'] => (target: Target) => {
if (target.target === 'build') { return Promise.resolve({ outputPath: staticOutputPath }); }
if (target.target === 'server') { return Promise.resolve({ outputPath: serverOutputPath }); }
throw new Error(`Invalid target: ${JSON.stringify(target)}.`);
};p, v and n in the new forEach callbacks. Try to use parameter/variable names that tell you what is being represented.
If I have any of this wrong, say so and I will take another look. Thanks again for all the hard work you've been putting in. I expect this to be the final review round.
Sorry, something went wrong.
The SSR deploy builders interpolate several angular.json values into generated artifacts that are later executed: a server build target's outputPath into the Cloud Function index.js and the Cloud Run package.json start script, functionName into the exports assignment, region into the .region() call, and functionsNodeVersion into the Cloud Run Dockerfile FROM line. region is escaped structurally with JSON.stringify in the template, and the start script now quotes its path, so a shell no longer splits or expands it. On top of that, outputPath, functionName and functionsNodeVersion are screened before code generation (assertSafeOutputPath, assertSafeFunctionName, assertSafeNodeVersion). assertSafeOutputPath rejects only what is still live once the start script is quoted: quotes, a backslash and line terminators, which break out of the require() string literal, `$` and a backtick, which are still command substitution inside double quotes, and a leading dash, which node reads as a flag. functionName is only screened on the Functions path, where it becomes a JavaScript identifier. functionsNodeVersion is screened against Docker's tag grammar, since the value is a node image tag, with latest excluded because its slim variant is published as node:slim. The functionName and region schema patterns are left to angular#3726, which already carries stricter versions of both. The functionsNodeVersion schema pattern stays here and matches the runtime check exactly. The TODO above the gcloud calls is restored in narrowed form, covering the values that are still unvalidated: firebaseProject, vpcConnector, and the outputPath deploy option.
|
No apology needed, the deeper look was worth it. Both of the things you found were real: the containment check could not fail, and the character class was wrong in both directions. Rebased on current main and pushed as a single amended commit. All three main points are in, along with every one of the smaller notes. The containment check now runs the sourceYou were right that new Script passes on the vulnerable render too, because ('').runWith(...) parses fine on its own. Replaced it with a runGeneratedFunction helper. It runs the rendered index.js in a fresh context against a stubbed require that records every module id asked for, and a stub whose execSync records every command run. The spec then asserts four things: that running it does not throw, that nothing was executed, that child_process was never required, and that exports came out with exactly the one key. Reverting the JSON.stringify now fails it four times, and three of those are the run rather than the text: Expected function not to throw, but it threw TypeError: "".runWith is not a function. Expected $.length = 1 to equal 0. Unexpected $[0] = 'id' in array. Expected [ ..., 'child_process' ] not to contain 'child_process'. The character class, fixed by quoting the start scriptTook the second of your two options, since it fixes the underlying thing rather than screening for it. defaultPackage now emits node "<path>/main.js". With the path quoted, whitespace, globs, ~ and the metacharacters that would chain a command are all inert, at every call site rather than only the Cloud Run one. So \s, * and ? came back out of the class rather than [, ] and ~ going in, and a folder name with a space in it deploys again. What is left is only what the quotes do not cover: if (/['"`\\$\n\r]/.test(outputPath) || outputPath.startsWith('-')) {
The comment above the check now says that per character, instead of making a broader claim. dist/my app, dist/*, dist/[ab], ~/x, a;b and a tab-separated path each have a spec saying they are allowed, with a note on why that is safe now. functionsNodeVersion is Docker's tag grammar nowTook your expression as written, in both schema.json and assertSafeNodeVersion, and rewrote the schema description to match. lts, current, iron, jod, lts-bookworm and 22-bookworm all pass. There are specs for the values that would otherwise regress quietly, including the 128 character bound and latest. One correction, since I ran all three before writing the description: 20.x passes this pattern. x is \w, so ^(?!latest$)[\w][\w.-]{0,127}$ accepts it. >=18 and ^20 are refused as you said. That is not a hole. node:20.x-slim matches no published image, so it fails at pull time with a clear error. It is the same class of thing as 20.11.1 being a valid tag and an invalid Functions runtime, so I described it that way in the schema rather than trying to screen for it. The smaller thingsAll five in.
The casts and the namingAll four gone. withOutputPaths is annotated BuilderContext['getTargetOptions'], hands back promises, and throws Invalid target: <target> on an unrecognised target, as you wrote it. The other three dropped with no replacement. p, v and n are now outputPath, version and functionName. Guards re-pinnedRe-ran the removal experiment, rebuilding between runs, now covering the two template changes as well:
npm run test:node and test:node-esm are green at 226 specs, 0 failures. ng lint passes, tsc -p tsconfig.build.json --noEmit is clean, and the branch is rebased on current main with no conflict. |
Sorry, something went wrong.
There was a problem hiding this comment.
Thank you so much for all your work on this @herdiyana256, as well as the other PRs you've created to fix these very important injection issues.
I am approving this PR and merging it now. I look forward to reviewing any future PRs you may create to improve AngularFire.
Sorry, something went wrong.
| Back | FazBrowse Home | New Git URL |
The SSR deploy builders interpolate several angular.json-derived values straight into generated artifacts that are later executed.
A server build target's outputPath (read via getTargetOptions) is written raw into the generated Cloud Function index.js as require('./${path}/main') and into the generated package.json start script as node ${path}/main.js. functionName is written raw as the exports.${functionName} assignment target, and region inside a single-quoted literal as .region('${options.region}'), both in the same generated index.js. functionsNodeVersion is written raw into the generated Cloud Run Dockerfile as FROM node:${version}-slim. None of these has any validation.
A crafted server outputPath such as x').app(); require('child_process').execSync('...'); (' lands as a standalone statement in index.js and runs on every Cloud Function cold start (and locally during firebase serve preview). A crafted functionName such as ssr; require('child_process').execSync('...'); var _x does the same, with the template's own = becoming that variable's initializer, so the file still parses and the injected call runs when the function loads. A ' in region breaks out of its literal the same way. A crafted functionsNodeVersion injects extra RUN instructions executed during the Cloud Run container build. All are reachable the moment a developer runs ng deploy on a malicious or cloned workspace. These are distinct sinks from the gcloud argv path and the execSync calls addressed separately.
The fix
Each value is handled in the way that fits where it lands:
The functionName and region schema patterns are owned by #3726, which carries stricter versions of both; this PR keeps only the runtime checks and the template escaping. The TODO above the gcloud calls is restored in narrowed form, covering the three values that genuinely remain unvalidated: firebaseProject, vpcConnector, and the outputPath deploy option.
Specs cover each validator directly and drive deployToFunction / deployToCloudRun end-to-end, so every assertSafe* call site turns the suite red if it is removed, and reverting the region escaping does too.
npm run test:node and test:node-esm pass (209 specs, 0 failures); ng lint and tsc -p tsconfig.build.json --noEmit are clean.