diff --git a/__tests__/lib/dev-environment/dev-environment-configuration-file.js b/__tests__/lib/dev-environment/dev-environment-configuration-file.js index 2bcbd6896..044fb9c7c 100644 --- a/__tests__/lib/dev-environment/dev-environment-configuration-file.js +++ b/__tests__/lib/dev-environment/dev-environment-configuration-file.js @@ -4,7 +4,9 @@ import { jest } from '@jest/globals'; import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import * as exit from '../../../src/lib/cli/exit'; import { getConfigurationFileOptions } from '../../../src/lib/dev-environment/dev-environment-configuration-file'; // Mock fs operations @@ -18,9 +20,12 @@ jest.mock( '../../../src/lib/cli/exit', () => ( { } ) ); const mockedReadFile = readFile; +const mockedExitWithError = exit.withError; describe( 'dev-environment-configuration-file', () => { describe( 'multisite configuration sanitization', () => { + const templateConfigDir = path.join( '/test/dir', '.wpvip' ); + const templateConfigPath = path.join( templateConfigDir, 'vip-dev-env.yml.ejs' ); const createConfigContent = multisiteValue => ` configuration-version: 1 slug: test-site @@ -32,6 +37,9 @@ app-code: demo beforeEach( () => { jest.clearAllMocks(); jest.resetAllMocks(); + mockedExitWithError.mockImplementation( message => { + throw new Error( message ); + } ); // Mock process.cwd() to return a known directory jest.spyOn( process, 'cwd' ).mockReturnValue( '/test/dir' ); // Default mock to reject all file reads @@ -126,5 +134,38 @@ app-code: demo expect( result ).toEqual( {} ); } ); + + it( 'should replace supported configDir placeholders in template configuration files', async () => { + const configContent = ` +configuration-version: 1 +slug: test-site +overrides: | + services: + php: + volumes: + - <%= configDir %>/..:/wp/wp-content/plugins/my-integration +`; + + mockedReadFile.mockResolvedValueOnce( configContent ); + + const result = await getConfigurationFileOptions(); + + expect( result.overrides ).toContain( + `${ templateConfigDir }/..:/wp/wp-content/plugins/my-integration` + ); + } ); + + it( 'should reject arbitrary EJS syntax in template configuration files', async () => { + const configContent = ` +configuration-version: 1 +slug: <%= process.env.SLUG %> +`; + + mockedReadFile.mockResolvedValueOnce( configContent ); + + await expect( getConfigurationFileOptions() ).rejects.toThrow( + `EJS JavaScript is not supported in dev-env configuration file ${ templateConfigPath }` + ); + } ); } ); } ); diff --git a/src/lib/dev-environment/dev-environment-configuration-file.ts b/src/lib/dev-environment/dev-environment-configuration-file.ts index 8667c36c1..41391f15f 100644 --- a/src/lib/dev-environment/dev-environment-configuration-file.ts +++ b/src/lib/dev-environment/dev-environment-configuration-file.ts @@ -1,6 +1,5 @@ import chalk from 'chalk'; import debugLib from 'debug'; -import ejs from 'ejs'; import yaml, { FAILSAFE_SCHEMA } from 'js-yaml'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; @@ -23,6 +22,8 @@ const debug = debugLib( '@automattic/vip:bin:dev-environment' ); export const CONFIGURATION_FILE_NAME = 'vip-dev-env.yml'; export const CONFIGURATION_TEMPLATE_FILE_NAME = 'vip-dev-env.yml.ejs'; +const ALLOWED_CONFIG_DIR_TEMPLATE = /<%=\s*configDir\s*%>/g; +const EJS_TEMPLATE_TAG = /<%[\s\S]*?%>/; export async function getConfigurationFileOptions(): Promise< ConfigurationFileOptions > { const configurationFile = await findConfigurationFile(); @@ -249,19 +250,30 @@ async function findConfigurationFile(): Promise< } for ( const { dir, file, template } of locations ) { + let contents: string; try { // eslint-disable-next-line no-await-in-loop - const contents = await readFile( file, 'utf8' ); - if ( template ) { - const rendered = ejs.render( contents, { configDir: dir } ); - return { configurationPath: file, configurationContents: rendered }; - } - - return { configurationPath: file, configurationContents: contents }; + contents = await readFile( file, 'utf8' ); } catch ( error ) { const err = error instanceof Error ? error : new Error( 'Unknown error' ); - debug( `Error reading or rendering file ${ file }: ${ err.message }` ); + debug( `Error reading file ${ file }: ${ err.message }` ); + continue; } + + if ( template ) { + const stripped = contents.replace( ALLOWED_CONFIG_DIR_TEMPLATE, '' ); + if ( EJS_TEMPLATE_TAG.test( stripped ) ) { + exit.withError( + `EJS JavaScript is not supported in dev-env configuration file ${ file }. ` + + 'Use plain YAML, or only the supported <%= configDir %> placeholder.' + ); + } + + const rendered = contents.replace( ALLOWED_CONFIG_DIR_TEMPLATE, () => dir ); + return { configurationPath: file, configurationContents: rendered }; + } + + return { configurationPath: file, configurationContents: contents }; } return false;