feat: [8920] Define explicit type for filter; Add SecureTemplater tests

Signed-off-by: David Zemon <david@zemon.name>
This commit is contained in:
David Zemon
2022-01-21 09:37:05 -06:00
parent fde4307fc4
commit af6b692cb3
7 changed files with 87 additions and 15 deletions
@@ -99,6 +99,52 @@ describe('SecureTemplater', () => {
]);
});
it('should make additional filters available when requested', async () => {
const mockFilter1 = jest.fn(() => 'filtered text');
const mockFilter2 = jest.fn((var1, var2) => `${var1} ${var2}`);
const mockFilter3 = jest.fn((var1, var2) => ({ var1, var2 }));
const renderWith = await SecureTemplater.loadRenderer({
additionalFilters: { mockFilter1, mockFilter2, mockFilter3 },
});
const renderWithout = await SecureTemplater.loadRenderer();
const ctx = { inputValue: 'the input value' };
expect(renderWith('${{ inputValue | mockFilter1 }}', ctx)).toBe(
'filtered text',
);
expect(
renderWith('${{ inputValue | mockFilter2("extra arg") }}', ctx),
).toBe('the input value extra arg');
expect(
renderWith(
'${{ inputValue | mockFilter3("another extra arg") | dump }}',
ctx,
),
).toBe(
JSON.stringify({
var1: 'the input value',
var2: 'another extra arg',
}),
);
expect(() => renderWithout('${{ inputValue | mockFilter1 }}', ctx)).toThrow(
/Error: filter not found: mockFilter1/,
);
expect(() =>
renderWithout('${{ inputValue | mockFilter2("extra arg") }}', ctx),
).toThrow(/Error: filter not found: mockFilter2/);
expect(() =>
renderWithout('${{ inputValue | mockFilter3("extra arg") }}', ctx),
).toThrow(/Error: filter not found: mockFilter3/);
expect(mockFilter1.mock.calls).toEqual([['the input value']]);
expect(mockFilter2.mock.calls).toEqual([['the input value', 'extra arg']]);
expect(mockFilter3.mock.calls).toEqual([
['the input value', 'another extra arg'],
]);
});
it('should not allow helpers to be rewritten', async () => {
const render = await SecureTemplater.loadRenderer({
parseRepoUrl: () => ({
@@ -17,8 +17,10 @@
import { VM } from 'vm2';
import { resolvePackagePath } from '@backstage/backend-common';
import fs from 'fs-extra';
import { JsonValue } from '@backstage/types';
import { RepoSpec } from '../../scaffolder/actions/builtin/publish/util';
// language=JavaScript
const mkScript = (nunjucksSource: string) => `
const { render, renderCompat } = (() => {
const module = {};
@@ -56,6 +58,16 @@ const { render, renderCompat } = (() => {
});
}
if (typeof additionalFilters !== "undefined") {
Object.entries(additionalFilters)
.forEach(([filterName, filterFunction]) => {
env.addFilter(
filterName,
(...args) => JSON.parse(filterFunction.apply(null, args))
);
});
}
let uninstallCompat = undefined;
function render(str, values) {
@@ -87,6 +99,8 @@ const { render, renderCompat } = (() => {
})();
`;
export type NunjucksFilter = (...args: JsonValue[]) => JsonValue | undefined;
export interface SecureTemplaterOptions {
/* Optional implementation of the parseRepoUrl filter */
parseRepoUrl?(repoUrl: string): RepoSpec;
@@ -95,7 +109,7 @@ export interface SecureTemplaterOptions {
cookiecutterCompat?: boolean;
/* Extra user-provided nunjucks filters */
additionalFilters?: Record<string, (data: any) => any>;
additionalFilters?: Record<string, NunjucksFilter>;
}
export type SecureTemplateRenderer = (
@@ -106,17 +120,22 @@ export type SecureTemplateRenderer = (
export class SecureTemplater {
static async loadRenderer(options: SecureTemplaterOptions = {}) {
const { parseRepoUrl, cookiecutterCompat, additionalFilters } = options;
let sandbox = undefined;
const sandbox: Record<string, any> = {};
if (parseRepoUrl) {
sandbox = {
parseRepoUrl: (url: string) => JSON.stringify(parseRepoUrl(url)),
};
sandbox.parseRepoUrl = (url: string) => JSON.stringify(parseRepoUrl(url));
}
if (additionalFilters) {
sandbox.additionalFilters = Object.entries(additionalFilters)
.filter(([_, filterFunction]) => !!filterFunction)
.reduce((safeFilters, [filterName, filterFunction]) => {
const newSafeFilters = { ...safeFilters };
newSafeFilters[filterName] = (...args) =>
JSON.stringify(filterFunction.apply(null, args));
return newSafeFilters;
}, {} as Record<string, NunjucksFilter>);
}
sandbox = {
...sandbox,
...additionalFilters,
};
const vm = new VM({ sandbox });
@@ -46,6 +46,7 @@ import {
createGithubActionsDispatchAction,
createGithubWebhookAction,
} from './github';
import { NunjucksFilter } from '../../../lib/templating/SecureTemplater';
export const createBuiltinActions = (options: {
reader: UrlReader;
@@ -53,7 +54,7 @@ export const createBuiltinActions = (options: {
catalogClient: CatalogApi;
containerRunner?: ContainerRunner;
config: Config;
nunjucksFilters?: Record<string, (data: any) => any>;
nunjucksFilters?: Record<string, NunjucksFilter>;
}) => {
const {
reader,
@@ -23,7 +23,10 @@ import { createTemplateAction } from '../../createTemplateAction';
import globby from 'globby';
import fs from 'fs-extra';
import { isBinaryFile } from 'isbinaryfile';
import { SecureTemplater } from '../../../../lib/templating/SecureTemplater';
import {
NunjucksFilter,
SecureTemplater,
} from '../../../../lib/templating/SecureTemplater';
type CookieCompatInput = {
copyWithoutRender?: string[];
@@ -44,7 +47,7 @@ export type FetchTemplateInput = {
export function createFetchTemplateAction(options: {
reader: UrlReader;
integrations: ScmIntegrations;
nunjucksFilters?: Record<string, (data: any) => any>;
nunjucksFilters?: Record<string, NunjucksFilter>;
}) {
const { reader, integrations, nunjucksFilters } = options;
@@ -35,6 +35,7 @@ import { validate as validateJsonSchema } from 'jsonschema';
import { parseRepoUrl } from '../actions/builtin/publish/util';
import { TemplateActionRegistry } from '../actions';
import {
NunjucksFilter,
SecureTemplater,
SecureTemplateRenderer,
} from '../../lib/templating/SecureTemplater';
@@ -44,7 +45,7 @@ type NunjucksWorkflowRunnerOptions = {
actionRegistry: TemplateActionRegistry;
integrations: ScmIntegrations;
logger: winston.Logger;
additionalFilters?: Record<string, (data: any) => any>;
additionalFilters?: Record<string, NunjucksFilter>;
};
type TemplateContext = {
@@ -21,6 +21,7 @@ import { Logger } from 'winston';
import { TemplateActionRegistry } from '../actions';
import { ScmIntegrations } from '@backstage/integration';
import { assertError } from '@backstage/errors';
import { NunjucksFilter } from '../../lib/templating/SecureTemplater';
/**
* TaskWorkerOptions
@@ -46,7 +47,7 @@ export type CreateWorkerOptions = {
integrations: ScmIntegrations;
workingDirectory: string;
logger: Logger;
nunjucksFilters?: Record<string, (data: any) => any>;
nunjucksFilters?: Record<string, NunjucksFilter>;
};
/**
@@ -41,6 +41,7 @@ import {
} from '../scaffolder';
import { StorageTaskBroker } from '../scaffolder/tasks/StorageTaskBroker';
import { getEntityBaseUrl, getWorkingDirectory } from './helpers';
import { NunjucksFilter } from '../lib/templating/SecureTemplater';
/**
* RouterOptions
@@ -57,7 +58,7 @@ export interface RouterOptions {
taskWorkers?: number;
containerRunner?: ContainerRunner;
taskBroker?: TaskBroker;
nunjucksFilters?: Record<string, (data: any) => any>;
nunjucksFilters?: Record<string, NunjucksFilter>;
}
function isSupportedTemplate(