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/); + }); +});