From 37bb860dec311799fa95957b6d7a932b9926a7c5 Mon Sep 17 00:00:00 2001 From: Herdiyan Adam Putra Date: Sun, 23 Aug 2026 01:10:18 +0700 Subject: [PATCH] fix(@angular/build): escape providersFile specifier in generated Vitest test setup The Vitest unit-test runner builds the TestBed initialization virtual file by interpolating the `providersFile` path from the project configuration directly into an import statement. A value containing a quote or newline terminated the import specifier early, so the trailing text was emitted as executable code in the generated file and ran when the tests were executed. The specifier is now constructed with `JSON.stringify`, so any quotes, backslashes, or newlines in the path are escaped and the value can only ever be a single string literal. --- .../unit-test/runners/vitest/build-options.ts | 9 ++- .../runners/vitest/build-options_spec.ts | 61 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 packages/angular/build/src/builders/unit-test/runners/vitest/build-options_spec.ts diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts index 3936f44b09fd..cff3040a0276 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts @@ -29,7 +29,7 @@ import { RunnerOptions } from '../api'; * @param zoneTestingStrategy How zone.js should be loaded during initialization. * @returns The string content of the virtual initialization file. */ -function createTestBedInitVirtualFile( +export function createTestBedInitVirtualFile( providersFile: string | undefined, projectSourceRoot: string, teardown: boolean, @@ -41,7 +41,12 @@ function createTestBedInitVirtualFile( const relativePath = path.relative(projectSourceRoot, providersFile); const { dir, name } = path.parse(relativePath); const importPath = toPosixPath(path.join(dir, name)); - providersImport = `import providers from './${importPath}';`; + // The import path is derived from the `providersFile` value in the project's + // configuration and must be embedded as a single JavaScript string literal. + // Building the specifier with `JSON.stringify` escapes any quotes, backslashes, + // or newlines it contains, so a crafted value cannot terminate the string early + // and inject executable code into this generated file. + providersImport = `import providers from ${JSON.stringify('./' + importPath)};`; } let zoneTestingSnippet = ''; diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/build-options_spec.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/build-options_spec.ts new file mode 100644 index 000000000000..6a42beee2a2a --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/build-options_spec.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { createTestBedInitVirtualFile } from './build-options'; + +describe('createTestBedInitVirtualFile', () => { + const projectSourceRoot = '/project/src'; + + it('generates a providers import for a normal providersFile', () => { + const content = createTestBedInitVirtualFile( + '/project/src/my.providers.ts', + projectSourceRoot, + true, + 'none', + false, + ); + + expect(content).toContain('import providers from "./my.providers";'); + }); + + it('embeds the providersFile specifier as a single escaped string literal', () => { + // A providersFile whose value carries a quote followed by extra source. With raw + // string interpolation this would terminate the import specifier early and inject + // the trailing text as executable code into the generated file. + const malicious = `/project/src/x';globalThis['__pwned']=true;'`; + + const content = createTestBedInitVirtualFile( + malicious, + projectSourceRoot, + true, + 'none', + false, + ); + + // The entire value stays inside one string literal, so no statement escapes. + expect(content).toContain(`import providers from "./x';globalThis['__pwned']=true;'";`); + // The payload must never appear as standalone code. + expect(content).not.toContain(`import providers from './x';globalThis`); + }); + + it('escapes newlines in the providersFile specifier', () => { + const withNewline = '/project/src/x\';\nglobalThis["__pwned"]=true;//'; + + const content = createTestBedInitVirtualFile( + withNewline, + projectSourceRoot, + true, + 'none', + false, + ); + + // A raw newline cannot appear inside the generated string literal; it is escaped. + expect(content).toContain('\\n'); + expect(content).not.toMatch(/import providers from '\.\/x';\n/); + }); +});