Skip to content

Commit 27ecc43

Browse files
Merge branch 'main' into mgoho/chore/update-angular
2 parents ef54172 + 778625a commit 27ecc43

5 files changed

Lines changed: 258 additions & 40 deletions

File tree

src/schematics/deploy/actions.jasmine.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
import { join } from 'path';
33
import { BuilderContext, BuilderRun, ScheduleOptions, Target } from '@angular-devkit/architect';
44
import { JsonObject, logging } from '@angular-devkit/core';
5-
import { BuildTarget, FSHost, FirebaseDeployConfig, FirebaseTools } from '../interfaces';
6-
import deploy, { assertSafeDependencyName, assertSupportedPackageManager, deployToFunction, findPackageVersion, processHost } from './actions.js'
5+
import { BuildTarget, DeployBuilderSchema, FSHost, FirebaseDeployConfig, FirebaseTools } from '../interfaces';
6+
import deploy, { assertSafeDependencyName, assertSupportedPackageManager, buildCloudRunBuildsSubmitArgs, buildCloudRunDeployArgs, deployToFunction, findPackageVersion, processHost } from './actions.js'
77
import 'jasmine';
88

99
let context: BuilderContext;
@@ -301,6 +301,47 @@ describe('universal deployment', () => {
301301
});*/
302302
});
303303

304+
describe('Cloud Run gcloud argv construction', () => {
305+
// Regression coverage for the argv-injection fix: these options used to be interpolated
306+
// into a single command string and split on whitespace, so a value containing a space
307+
// would land as extra, unintended argv entries. They're now passed straight through as
308+
// individual array elements.
309+
const INJECTED_REGION = 'us-central1 --set-env-vars=INJECTED=owned';
310+
const INJECTED_PROJECT = `${FIREBASE_PROJECT} --format=json`;
311+
312+
it('keeps a region value containing a space as a single --region argument', () => {
313+
const options: DeployBuilderSchema = { firebaseProject: FIREBASE_PROJECT, region: INJECTED_REGION };
314+
const args = buildCloudRunDeployArgs('my-service', options, []);
315+
316+
expect(args[args.indexOf('--region') + 1]).toBe(INJECTED_REGION);
317+
expect(args).not.toContain('--set-env-vars=INJECTED=owned');
318+
});
319+
320+
it('keeps a firebaseProject value containing a space as a single --project argument (deploy)', () => {
321+
const options: DeployBuilderSchema = { firebaseProject: INJECTED_PROJECT, region: 'us-central1' };
322+
const args = buildCloudRunDeployArgs('my-service', options, []);
323+
324+
expect(args[args.indexOf('--project') + 1]).toBe(INJECTED_PROJECT);
325+
expect(args).not.toContain('--format=json');
326+
});
327+
328+
it('keeps a firebaseProject value containing a space as a single --project argument (builds submit)', () => {
329+
const options: DeployBuilderSchema = { firebaseProject: INJECTED_PROJECT };
330+
const args = buildCloudRunBuildsSubmitArgs('cloudRunOut', 'my-service', options);
331+
332+
expect(args[args.indexOf('--project') + 1]).toBe(INJECTED_PROJECT);
333+
expect(args).not.toContain('--format=json');
334+
});
335+
336+
it('passes cloudRunOptions through as their own argv entries', () => {
337+
const options: DeployBuilderSchema = { firebaseProject: FIREBASE_PROJECT, region: 'us-central1' };
338+
const args = buildCloudRunDeployArgs('my-service', options, ['--vpc-connector', 'my-connector --unset-env-vars=OWNED']);
339+
340+
expect(args[args.indexOf('--vpc-connector') + 1]).toBe('my-connector --unset-env-vars=OWNED');
341+
expect(args).not.toContain('--unset-env-vars=OWNED');
342+
});
343+
});
344+
304345
describe('deploy input validation (command-injection hardening)', () => {
305346
describe('assertSupportedPackageManager', () => {
306347
['npm', 'yarn', 'pnpm', 'cnpm', 'bun'].forEach((pm) => {

src/schematics/deploy/actions.ts

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,11 @@ const DEFAULT_CLOUD_RUN_OPTIONS: Partial<CloudRunOptions> = {
3434

3535
const spawnAsync = async (
3636
command: string,
37+
args: string[],
3738
options?: SpawnOptionsWithoutStdio
3839
) =>
3940
new Promise<Buffer>((resolve, reject) => {
40-
const [spawnCommand, ...args] = command.split(/\s+/);
41-
const spawnProcess = spawn(spawnCommand, args, options);
41+
const spawnProcess = spawn(command, args, options);
4242
const chunks: Buffer[] = [];
4343
const errorChunks: Buffer[] = [];
4444
spawnProcess.stdout.on('data', (data) => {
@@ -53,7 +53,7 @@ const spawnAsync = async (
5353
reject(error);
5454
});
5555
spawnProcess.on('close', (code) => {
56-
if (code === 1) {
56+
if (code !== 0) {
5757
reject(Buffer.concat(errorChunks).toString());
5858
return;
5959
}
@@ -356,6 +356,34 @@ export const deployToFunction = async (
356356
};
357357

358358

359+
// Exported (rather than kept private) so the argv shape can be asserted directly in tests,
360+
// without having to mock child_process.spawn.
361+
export const buildCloudRunBuildsSubmitArgs = (
362+
cloudRunOut: string,
363+
serviceId: string,
364+
options: DeployBuilderOptions
365+
): string[] => [
366+
'builds', 'submit', cloudRunOut,
367+
'--tag', `gcr.io/${options.firebaseProject}/${serviceId}`,
368+
'--project', options.firebaseProject,
369+
'--quiet',
370+
];
371+
372+
export const buildCloudRunDeployArgs = (
373+
serviceId: string,
374+
options: DeployBuilderOptions,
375+
deployArguments: string[]
376+
): string[] => [
377+
'run', 'deploy', serviceId,
378+
'--image', `gcr.io/${options.firebaseProject}/${serviceId}`,
379+
'--project', options.firebaseProject,
380+
...deployArguments,
381+
'--platform', 'managed',
382+
'--allow-unauthenticated',
383+
'--region', options.region,
384+
'--quiet',
385+
];
386+
359387
export const deployToCloudRun = async (
360388
firebaseTools: FirebaseTools,
361389
context: BuilderContext,
@@ -430,25 +458,23 @@ export const deployToCloudRun = async (
430458
throw new SchematicsException('Cloud Run preview not supported.');
431459
}
432460

433-
const deployArguments: any[] = [];
461+
const deployArguments: string[] = [];
434462
const cloudRunOptions = options.cloudRunOptions || {};
435463
Object.entries(DEFAULT_CLOUD_RUN_OPTIONS).forEach(([k, v]) => {
436464
cloudRunOptions[k] ||= v;
437465
});
438466
// lean on the schema for validation (rather than sanitize)
439-
if (cloudRunOptions.cpus) { deployArguments.push('--cpu', cloudRunOptions.cpus); }
440-
if (cloudRunOptions.maxConcurrency) { deployArguments.push('--concurrency', cloudRunOptions.maxConcurrency); }
441-
if (cloudRunOptions.maxInstances) { deployArguments.push('--max-instances', cloudRunOptions.maxInstances); }
442-
if (cloudRunOptions.memory) { deployArguments.push('--memory', cloudRunOptions.memory); }
443-
if (cloudRunOptions.minInstances) { deployArguments.push('--min-instances', cloudRunOptions.minInstances); }
444-
if (cloudRunOptions.timeout) { deployArguments.push('--timeout', cloudRunOptions.timeout); }
467+
if (cloudRunOptions.cpus) { deployArguments.push('--cpu', cloudRunOptions.cpus.toString()); }
468+
if (cloudRunOptions.maxConcurrency) { deployArguments.push('--concurrency', cloudRunOptions.maxConcurrency.toString()); }
469+
if (cloudRunOptions.maxInstances) { deployArguments.push('--max-instances', cloudRunOptions.maxInstances.toString()); }
470+
if (cloudRunOptions.memory) { deployArguments.push('--memory', cloudRunOptions.memory.toString()); }
471+
if (cloudRunOptions.minInstances) { deployArguments.push('--min-instances', cloudRunOptions.minInstances.toString()); }
472+
if (cloudRunOptions.timeout) { deployArguments.push('--timeout', cloudRunOptions.timeout.toString()); }
445473
if (cloudRunOptions.vpcConnector) { deployArguments.push('--vpc-connector', cloudRunOptions.vpcConnector); }
446474

447-
// TODO validate serviceId, firebaseProject, and vpcConnector both to limit errors and opp for injection
448-
449475
context.logger.info(`📦 Deploying to Cloud Run`);
450-
await spawnAsync(`gcloud builds submit ${cloudRunOut} --tag gcr.io/${options.firebaseProject}/${serviceId} --project ${options.firebaseProject} --quiet`);
451-
await spawnAsync(`gcloud run deploy ${serviceId} --image gcr.io/${options.firebaseProject}/${serviceId} --project ${options.firebaseProject} ${deployArguments.join(' ')} --platform managed --allow-unauthenticated --region=${options.region} --quiet`);
476+
await spawnAsync('gcloud', buildCloudRunBuildsSubmitArgs(cloudRunOut, serviceId, options));
477+
await spawnAsync('gcloud', buildCloudRunDeployArgs(serviceId, options, deployArguments));
452478

453479
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
454480
const siteTarget = options.target ?? context.target!.project;
@@ -482,7 +508,7 @@ export default async function deploy(
482508
}
483509

484510
if (!firebaseToken && process.env.GOOGLE_APPLICATION_CREDENTIALS) {
485-
await spawnAsync(`gcloud auth activate-service-account --key-file ${process.env.GOOGLE_APPLICATION_CREDENTIALS}`);
511+
await spawnAsync('gcloud', ['auth', 'activate-service-account', '--key-file', process.env.GOOGLE_APPLICATION_CREDENTIALS]);
486512
console.log(`Using Google Application Credentials.`);
487513
}
488514

src/schematics/deploy/schema.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@
5151
},
5252
"functionName": {
5353
"type": "string",
54-
"description": "The name of the Cloud Function or Cloud Run serviceId to deploy SSR to"
54+
"pattern": "^[A-Za-z][A-Za-z0-9_-]{0,62}$",
55+
"description": "The name of the Cloud Function or Cloud Run serviceId to deploy SSR to. Must start with a letter and contain only letters, numbers, hyphens and underscores; on the Cloud Functions path this value also becomes a JavaScript identifier, so use only letters and numbers there."
5556
},
5657
"functionsNodeVersion": {
5758
"oneOf": [{ "type": "number" }, { "type": "string" }],
@@ -63,7 +64,8 @@
6364
},
6465
"region": {
6566
"type": "string",
66-
"description": "The region to deploy Cloud Functions or Cloud Run to"
67+
"pattern": "^[a-z]+-[a-z]+\\d+$",
68+
"description": "The region to deploy Cloud Functions or Cloud Run to, e.g. us-central1"
6769
},
6870
"outputPath": {
6971
"type": "string",

src/schematics/utils.jasmine.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { ConnectorConfig } from './interfaces.js';
2+
import {
3+
connectorConfigObjectLiteral,
4+
isValidPackageSpecifier,
5+
resolveDataConnectProviderConfig,
6+
} from './utils.js';
7+
import 'jasmine';
8+
9+
// eslint-disable-next-line @typescript-eslint/no-implied-eval
10+
const evalObjectLiteral = (literal: string) => new Function(`return (${literal});`)();
11+
12+
describe('connectorConfigObjectLiteral', () => {
13+
14+
it('escapes a quote-bearing value so it round-trips as inert string data', () => {
15+
const injectionAttempt = 'us-central1"; console.log("INJECTED"); const _z="x';
16+
const literal = connectorConfigObjectLiteral({
17+
location: injectionAttempt,
18+
connector: 'my-connector',
19+
service: 'my-service',
20+
} as ConnectorConfig);
21+
const evaluated = evalObjectLiteral(literal);
22+
expect(evaluated.location).toBe(injectionAttempt);
23+
});
24+
25+
it('coerces a non-string value from an unquoted yaml scalar to a string', () => {
26+
const literal = connectorConfigObjectLiteral({
27+
location: 'us-central1',
28+
connector: 'my-connector',
29+
service: 123,
30+
} as unknown as ConnectorConfig);
31+
const evaluated = evalObjectLiteral(literal);
32+
expect(evaluated.service).toBe('123');
33+
expect(typeof evaluated.service).toBe('string');
34+
});
35+
36+
it('coerces a boolean-like yaml scalar to a string', () => {
37+
const literal = connectorConfigObjectLiteral({
38+
location: 'us-central1',
39+
connector: true,
40+
service: 'my-service',
41+
} as unknown as ConnectorConfig);
42+
const evaluated = evalObjectLiteral(literal);
43+
expect(evaluated.connector).toBe('true');
44+
expect(typeof evaluated.connector).toBe('string');
45+
});
46+
47+
});
48+
49+
describe('isValidPackageSpecifier', () => {
50+
51+
it('accepts a normal scoped package specifier', () => {
52+
expect(isValidPackageSpecifier('@my-org/my-connector')).toBeTrue();
53+
});
54+
55+
it('rejects a value containing a double quote', () => {
56+
expect(isValidPackageSpecifier('foo"; console.log("INJECTED"); const _z="x')).toBeFalse();
57+
});
58+
59+
it('rejects a value containing a backslash', () => {
60+
expect(isValidPackageSpecifier('foo\\bar')).toBeFalse();
61+
});
62+
63+
it('rejects a value containing a newline', () => {
64+
expect(isValidPackageSpecifier('foo\nbar')).toBeFalse();
65+
});
66+
67+
});
68+
69+
describe('resolveDataConnectProviderConfig', () => {
70+
71+
it('resolves to the connectorConfig object literal when there is no package', () => {
72+
const resolution = resolveDataConnectProviderConfig({
73+
connectorYaml: { connectorId: 'my-connector' },
74+
connectorConfig: {
75+
location: 'us-central1',
76+
connector: 'my-connector',
77+
service: 'my-service',
78+
},
79+
});
80+
expect(resolution.kind).toBe('literal');
81+
});
82+
83+
it('resolves to an external import when package is a valid specifier', () => {
84+
const resolution = resolveDataConnectProviderConfig({
85+
connectorYaml: { connectorId: 'my-connector' },
86+
connectorConfig: {
87+
location: 'us-central1',
88+
connector: 'my-connector',
89+
service: 'my-service',
90+
},
91+
package: '@my-org/my-connector',
92+
});
93+
expect(resolution).toEqual({ kind: 'external', package: '@my-org/my-connector' });
94+
});
95+
96+
it('falls back to the connectorConfig literal when package is not a valid specifier', () => {
97+
const resolution = resolveDataConnectProviderConfig({
98+
connectorYaml: { connectorId: 'my-connector' },
99+
connectorConfig: {
100+
location: 'us-central1',
101+
connector: 'my-connector',
102+
service: 'my-service',
103+
},
104+
package: 'foo"; console.log("INJECTED")',
105+
});
106+
expect(resolution.kind).toBe('literal');
107+
});
108+
109+
it('does not throw and falls back to an empty literal when there is no javascriptSdk config', () => {
110+
expect(() => resolveDataConnectProviderConfig({
111+
connectorYaml: { connectorId: 'my-connector' },
112+
})).not.toThrow();
113+
expect(resolveDataConnectProviderConfig({
114+
connectorYaml: { connectorId: 'my-connector' },
115+
})).toEqual({ kind: 'literal', literal: '{}' });
116+
});
117+
118+
it('does not throw and falls back to an empty literal when config is null', () => {
119+
expect(() => resolveDataConnectProviderConfig(null)).not.toThrow();
120+
expect(resolveDataConnectProviderConfig(null)).toEqual({ kind: 'literal', literal: '{}' });
121+
});
122+
123+
});

src/schematics/utils.ts

Lines changed: 47 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ import {
77
Tree,
88
chain,
99
} from "@angular-devkit/schematics";
10-
import { NodePackageInstallTask } from "@angular-devkit/schematics/tasks";
10+
import { NodePackageInstallTask } from "@angular-devkit/schematics/tasks/index.js";
1111
import { addRootProvider } from "@schematics/angular/utility";
1212
import { parse } from "yaml";
13-
import { overwriteIfExists, safeReadJSON, stringifyFormatted } from "./common";
13+
import { overwriteIfExists, safeReadJSON, stringifyFormatted } from "./common.js";
1414
import {
1515
ConnectorConfig,
1616
ConnectorYaml,
@@ -148,6 +148,38 @@ ${addZonePatch ? "import 'zone.js/dist/zone-patch-rxjs';" : ""}`
148148
return host;
149149
}
150150

151+
// config.package and the connectorConfig values below come from the project's own
152+
// dataconnect.yaml/connector.yaml, not from a trusted schema. isValidPackageSpecifier
153+
// rejects the characters that would let a value break out of the double-quoted string
154+
// literal it's interpolated into; it does not otherwise validate that the value is a
155+
// well-formed module specifier.
156+
const PACKAGE_SPECIFIER_PATTERN = /^[^'"\\\n\r]+$/;
157+
export function isValidPackageSpecifier(pkg: string): boolean {
158+
return PACKAGE_SPECIFIER_PATTERN.test(pkg);
159+
}
160+
161+
export function connectorConfigObjectLiteral(connectorConfig: ConnectorConfig): string {
162+
return `{${(Object.keys(connectorConfig) as (keyof ConnectorConfig)[]).map(
163+
(key) => `${key}: ${JSON.stringify(String(connectorConfig[key]))}`
164+
).join(',')}}`;
165+
}
166+
167+
export type DataConnectProviderConfigResolution =
168+
| { kind: "external"; package: string }
169+
| { kind: "literal"; literal: string };
170+
171+
export function resolveDataConnectProviderConfig(
172+
config: DataConnectConnectorConfig | null | undefined
173+
): DataConnectProviderConfigResolution {
174+
if (config?.package && isValidPackageSpecifier(config.package)) {
175+
return { kind: "external", package: config.package };
176+
}
177+
if (config?.connectorConfig) {
178+
return { kind: "literal", literal: connectorConfigObjectLiteral(config.connectorConfig) };
179+
}
180+
return { kind: "literal", literal: "{}" };
181+
}
182+
151183
export function featureToRules(
152184
features: FEATURES[],
153185
projectName: string,
@@ -216,27 +248,21 @@ export function featureToRules(
216248
case FEATURES.DataConnect:
217249
return addRootProvider(projectName, ({ code, external }) => {
218250
external("getDataConnect", "@angular/fire/data-connect");
219-
let configAsStr = "{}";
220251

221-
const config = dataConnectConfig;
252+
const resolution = resolveDataConnectProviderConfig(dataConnectConfig);
253+
const configAsStr = resolution.kind === "external"
254+
? external("connectorConfig", resolution.package)
255+
: resolution.literal;
256+
222257
let angularConfig: undefined | string;
223-
if (config) {
224-
if (config.package) {
225-
configAsStr = external("connectorConfig", config.package);
226-
} else {
227-
configAsStr = `{${Object.keys(config.connectorConfig as ConnectorConfig).map(
228-
(key) => `${key}: "${(config.connectorConfig as ConnectorConfig)[key]}"`
229-
).join(',')}}`;
230-
}
231-
if (config.angular) {
232-
angularConfig = `, ${external(
233-
"provideTanStackQuery",
234-
"@tanstack/angular-query-experimental"
235-
)}(new ${external(
236-
"QueryClient",
237-
"@tanstack/angular-query-experimental"
238-
)}())`;
239-
}
258+
if (dataConnectConfig?.angular) {
259+
angularConfig = `, ${external(
260+
"provideTanStackQuery",
261+
"@tanstack/angular-query-experimental"
262+
)}(new ${external(
263+
"QueryClient",
264+
"@tanstack/angular-query-experimental"
265+
)}())`;
240266
}
241267
return code`${external(
242268
"provideDataConnect",

0 commit comments

Comments
 (0)