scaffolder-backend: jsonify + repoUrl helpers for SecureTemplater + error wrapping

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2021-11-19 18:23:04 +01:00
parent 0bb4be63d6
commit 6facaab24c
2 changed files with 101 additions and 4 deletions
@@ -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'],
]);
});
});
@@ -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(