diff --git a/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.test.ts b/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.test.ts index 320550251e..8052522e6c 100644 --- a/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.test.ts +++ b/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.test.ts @@ -41,4 +41,58 @@ describe('SecureTemplater', () => { '(unknown path) [Line 1, Column 13]\n expected name as lookup value, got .', ); }); + + it('should make jsonify available when requested', async () => { + const templaterWith = new SecureTemplater({ jsonify: true }); + const templaterWithout = new SecureTemplater(); + + await expect(templaterWith.render('${{ 1 | jsonify }}', {})).resolves.toBe( + '1', + ); + await expect( + templaterWithout.render('${{ 1 | jsonify }}', {}), + ).rejects.toThrow('(unknown path)\n Error: filter not found: jsonify'); + }); + + it('should make parseRepoUrl available when requested', async () => { + const parseRepoUrl = jest.fn(() => ({ + repo: 'my-repo', + owner: 'my-owner', + host: 'my-host.com', + })); + const templaterWith = new SecureTemplater({ + parseRepoUrl, + }); + const templaterWithout = new SecureTemplater(); + + const ctx = { + repoUrl: 'https://my-host.com/my-owner/my-repo', + }; + + await expect( + templaterWith.render('${{ repoUrl | parseRepoUrl | dump }}', ctx), + ).resolves.toBe( + JSON.stringify({ + repo: 'my-repo', + owner: 'my-owner', + host: 'my-host.com', + }), + ); + await expect( + templaterWith.render('${{ repoUrl | projectSlug }}', ctx), + ).resolves.toBe('my-owner/my-repo'); + await expect( + templaterWithout.render('${{ repoUrl | parseRepoUrl | dump }}', ctx), + ).rejects.toThrow( + '(unknown path)\n Error: filter not found: parseRepoUrl', + ); + await expect( + templaterWithout.render('${{ repoUrl | projectSlug }}', ctx), + ).rejects.toThrow('(unknown path)\n Error: filter not found: projectSlug'); + + expect(parseRepoUrl.mock.calls).toEqual([ + ['https://my-host.com/my-owner/my-repo'], + ['https://my-host.com/my-owner/my-repo'], + ]); + }); }); diff --git a/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.ts b/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.ts index c6fa98de4e..4b4a7f6f75 100644 --- a/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.ts +++ b/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.ts @@ -17,6 +17,7 @@ import { VM } from 'vm2'; import { resolvePackagePath } from '@backstage/backend-common'; import fs from 'fs-extra'; +import { RepoSpec } from '../../scaffolder/actions/builtin/publish/util'; const mkScript = (nunjucksSource: string) => ` const render = (() => { @@ -33,15 +34,47 @@ const render = (() => { }, }); + if (typeof jsonify !== 'undefined' && jsonify) { + env.addFilter('jsonify', env.getFilter('dump')); + } + + if (typeof parseRepoUrl !== 'undefined') { + env.addFilter('parseRepoUrl', repoUrl => { + return JSON.parse(parseRepoUrl(repoUrl)) + }); + env.addFilter('projectSlug', repoUrl => { + const { owner, repo } = JSON.parse(parseRepoUrl(repoUrl)); + return owner + '/' + repo; + }); + } + return function render(str, values) { - return env.renderString(str, JSON.parse(values)); + try { + return env.renderString(str, JSON.parse(values)); + } catch (error) { + // Make sure errors don't leak anything + throw new Error(String(error.message)); + } } })(); `; +export interface SecureTemplaterOptions { + jsonify?: true; + parseRepoUrl?(repoUrl: string): RepoSpec; +} + export class SecureTemplater { #vm?: VM; + #jsonify?: true; + #parseRepoUrl?: (repoUrl: string) => RepoSpec; + + constructor(options?: SecureTemplaterOptions) { + this.#jsonify = options?.jsonify; + this.#parseRepoUrl = options?.parseRepoUrl; + } + async render(template: string, values: unknown) { const vm = await this.getVm(); vm.setGlobal('templateStr', template); @@ -52,9 +85,19 @@ export class SecureTemplater { private async getVm() { if (!this.#vm) { - this.#vm = new VM({ - timeout: 1000, - }); + let sandbox = undefined; + if (this.#jsonify) { + sandbox = { jsonify: true }; + } + if (this.#parseRepoUrl) { + const parseRepoUrl = this.#parseRepoUrl; + sandbox = { + ...sandbox, + parseRepoUrl: (url: string) => JSON.stringify(parseRepoUrl(url)), + }; + } + + this.#vm = new VM({ timeout: 1000, sandbox }); const nunjucksSource = await fs.readFile( resolvePackagePath(