|
1 | | -import { SpawnOptionsWithoutStdio, execSync, spawn } from 'child_process'; |
| 1 | +import { SpawnOptionsWithoutStdio, SpawnSyncOptions, spawn } from 'child_process'; |
2 | 2 | import { existsSync, readFileSync, renameSync, writeFileSync } from 'fs'; |
3 | 3 | import { dirname, join } from 'path'; |
4 | 4 | import { BuilderContext, targetFromTargetString } from '@angular-devkit/architect'; |
5 | 5 | import { SchematicsException } from '@angular-devkit/schematics'; |
| 6 | +import crossSpawn from 'cross-spawn'; |
6 | 7 | import { copySync, removeSync } from 'fs-extra'; |
7 | 8 | import * as inquirer from 'inquirer'; |
8 | 9 | import open from 'open'; |
@@ -115,8 +116,78 @@ const defaultFsHost: FSHost = { |
115 | 116 | existsSync, |
116 | 117 | }; |
117 | 118 |
|
118 | | -const findPackageVersion = (packageManager: string, name: string) => { |
119 | | - const match = execSync(`${packageManager} list ${name}`).toString().match(`[^|s]${escapeRegExp(name)}[@| ][^s]+(s.+)?$`); |
| 119 | +// Package managers AngularFire is willing to shell out to when resolving |
| 120 | +// dependency versions. Kept in sync with the Angular CLI's own list. The value |
| 121 | +// comes from `cli.packageManager` in angular.json, which this builder reads with |
| 122 | +// a raw `JSON.parse`, i.e. it is NOT run through the Angular CLI's own schema |
| 123 | +// validation, so it must be checked here before it is ever used as an argv0. |
| 124 | +export const SUPPORTED_PACKAGE_MANAGERS = ['npm', 'yarn', 'pnpm', 'cnpm', 'bun']; |
| 125 | + |
| 126 | +export const assertSupportedPackageManager = (packageManager: string): string => { |
| 127 | + if (!SUPPORTED_PACKAGE_MANAGERS.includes(packageManager)) { |
| 128 | + throw new SchematicsException( |
| 129 | + `Unsupported package manager "${packageManager}" in angular.json (cli.packageManager). ` + |
| 130 | + `Expected one of: ${SUPPORTED_PACKAGE_MANAGERS.join(', ')}.` |
| 131 | + ); |
| 132 | + } |
| 133 | + return packageManager; |
| 134 | +}; |
| 135 | + |
| 136 | +// A dependency name comes from `architect.<project>.server.options.externalDependencies` |
| 137 | +// in angular.json. Reject anything that is not a plain package specifier so it can |
| 138 | +// neither inject shell metacharacters (defence in depth alongside execFileSync) nor |
| 139 | +// be parsed as a CLI flag by the package manager (argument injection). |
| 140 | +export const assertSafeDependencyName = (name: string): string => { |
| 141 | + // Valid npm package names / esbuild external globs never contain whitespace or |
| 142 | + // shell metacharacters, and never start with a dash. Reject anything else so the |
| 143 | + // value can neither inject a shell command (defence in depth alongside |
| 144 | + // execFileSync) nor be parsed as a package-manager flag (argument injection). |
| 145 | + if (typeof name !== 'string' || name.length === 0 || name.startsWith('-') || |
| 146 | + /[\s;&|$`(){}<>!\\'"]/.test(name)) { |
| 147 | + throw new SchematicsException( |
| 148 | + `Invalid dependency name ${JSON.stringify(name)} in angular.json (server externalDependencies).` |
| 149 | + ); |
| 150 | + } |
| 151 | + return name; |
| 152 | +}; |
| 153 | + |
| 154 | +// All shelling out from the deploy builder funnels through this single runner. |
| 155 | +// cross-spawn (v7) resolves the platform-appropriate executable and escapes each |
| 156 | +// argument, so a value taken from angular.json is passed as an argv entry and can |
| 157 | +// never be parsed as shell syntax. Unlike child_process.execFile it can launch a |
| 158 | +// Windows `.cmd`/`.bat` shim (npm, yarn, pnpm and cnpm all ship as `.cmd` shims |
| 159 | +// there), so `ng deploy` keeps working cross-platform. We deliberately avoid |
| 160 | +// `shell: true`, whose args-array form Node runtime-deprecates (DEP0190) because |
| 161 | +// it concatenates the arguments without escaping, reopening the injection. |
| 162 | +// It is exported as an object so tests can assert the deploy code shells out only |
| 163 | +// through here and never regresses to execSync or `shell: true`. |
| 164 | +export const processHost = { |
| 165 | + runPackageBin( |
| 166 | + command: string, |
| 167 | + args: string[], |
| 168 | + options: SpawnSyncOptions = {}, |
| 169 | + ): Buffer { |
| 170 | + const result = crossSpawn.sync(command, args, options); |
| 171 | + if (result.error) { throw result.error; } |
| 172 | + if (result.status !== 0) { |
| 173 | + throw new SchematicsException( |
| 174 | + `Command "${command}" exited with ${result.signal ? `signal ${result.signal}` : `code ${result.status}`}.` |
| 175 | + ); |
| 176 | + } |
| 177 | + return result.stdout; |
| 178 | + }, |
| 179 | +}; |
| 180 | + |
| 181 | +export const findPackageVersion = (packageManager: string, name: string) => { |
| 182 | + // Run the package manager without a shell (argument array, no `shell` option) |
| 183 | + // so a dependency name or package-manager value taken from angular.json cannot |
| 184 | + // be interpreted as a shell command. Both values are validated first, so an |
| 185 | + // unsupported manager or unsafe name throws before anything is ever spawned. |
| 186 | + const output = processHost.runPackageBin(assertSupportedPackageManager(packageManager), [ |
| 187 | + 'list', |
| 188 | + assertSafeDependencyName(name), |
| 189 | + ]).toString(); |
| 190 | + const match = output.match(`[^|s]${escapeRegExp(name)}[@| ][^s]+(s.+)?$`); |
120 | 191 | return match ? match[0].split(new RegExp(`${escapeRegExp(name)}[@| ]`))[1].split(/\s/)[0] : null; |
121 | 192 | }; |
122 | 193 |
|
@@ -238,7 +309,12 @@ export const deployToFunction = async ( |
238 | 309 | const siteTarget = options.target ?? context.target!.project; |
239 | 310 |
|
240 | 311 | if (fsHost.existsSync(functionsPackageJsonPath)) { |
241 | | - execSync(`npm --prefix ${functionsOut} install`); |
| 312 | + // Pass the output directory as an argv entry rather than interpolating it |
| 313 | + // into a shell string; `functionsOut` derives from the `outputPath` deploy |
| 314 | + // option (angular.json) and must not be able to inject shell commands. npm is |
| 315 | + // a `.cmd` shim on Windows, which child_process.execFile cannot launch, so |
| 316 | + // this goes through the shell-free cross-spawn runner instead. |
| 317 | + processHost.runPackageBin('npm', ['--prefix', functionsOut, 'install'], { stdio: 'inherit' }); |
242 | 318 | } else { |
243 | 319 | console.error(`No package.json exists at ${functionsOut}`); |
244 | 320 | } |
|
0 commit comments