Skip to content

Commit 49d6513

Browse files
committed
add template security validation
1 parent b36733b commit 49d6513

File tree

3 files changed

+234
-15
lines changed

3 files changed

+234
-15
lines changed

__tests__/lib/template-utils.test.ts

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { extractTemplateVariables, validateVariables } from '../../src/utils/template-utils';
1+
import {
2+
extractTemplateVariables,
3+
validateVariables,
4+
validateTemplate,
5+
compilePrompt,
6+
} from '../../src/utils/template-utils';
27
import { Database } from '@/app/__generated__/supabase.types';
38

49
type PromptVariable = Database['public']['Tables']['prompt_variables']['Row'];
@@ -825,3 +830,139 @@ describe('validateVariables', () => {
825830
expect(variablesWithDefaults).toEqual({ opt1: 'a', opt2: '1' });
826831
});
827832
});
833+
834+
describe('validateTemplate', () => {
835+
it('should return isValid: true for a valid template', () => {
836+
const template = 'Hello {{ name }}!';
837+
const result = validateTemplate(template);
838+
expect(result.isValid).toBe(true);
839+
expect(result.error).toBeUndefined();
840+
});
841+
842+
it("should return isValid: true for a template using 'global'", () => {
843+
const template = 'Version: {{ global.version }}';
844+
const result = validateTemplate(template);
845+
expect(result.isValid).toBe(true);
846+
expect(result.error).toBeUndefined();
847+
});
848+
849+
it('should return isValid: false for Nunjucks syntax errors', () => {
850+
const template = 'Hello {{ name !'; // Invalid Nunjucks syntax
851+
const result = validateTemplate(template);
852+
expect(result.isValid).toBe(false);
853+
expect(result.error).toMatch(/expected variable end/i);
854+
});
855+
856+
it('should return isValid: false for disallowed identifiers', () => {
857+
const template = 'Data: {{ process.env.SECRET }}';
858+
const result = validateTemplate(template);
859+
expect(result.isValid).toBe(false);
860+
expect(result.error).toBe("Security: Disallowed identifier 'process' found.");
861+
});
862+
863+
it('should return isValid: false for another disallowed identifier (eval)', () => {
864+
const template = '{{ eval("alert(1)") }}';
865+
const result = validateTemplate(template);
866+
expect(result.isValid).toBe(false);
867+
expect(result.error).toBe("Security: Disallowed identifier 'eval' found.");
868+
});
869+
870+
it('should return isValid: false for disallowed property lookups', () => {
871+
const template = 'Access: {{ myObj.__proto__ }}';
872+
const result = validateTemplate(template);
873+
expect(result.isValid).toBe(false);
874+
expect(result.error).toBe("Security: Disallowed property lookup '__proto__' found.");
875+
});
876+
877+
it('should return isValid: false for disallowed property lookups via string literal', () => {
878+
const template = "Access: {{ myObj['constructor'] }}";
879+
const result = validateTemplate(template);
880+
expect(result.isValid).toBe(false);
881+
expect(result.error).toBe("Security: Disallowed property lookup 'constructor' found.");
882+
});
883+
884+
it('should allow legitimate property lookups that are not disallowed', () => {
885+
const template = 'Value: {{ myObj.legitProperty }}';
886+
const result = validateTemplate(template);
887+
expect(result.isValid).toBe(true);
888+
expect(result.error).toBeUndefined();
889+
});
890+
891+
it('should handle complex but valid templates correctly', () => {
892+
const template = `
893+
{% if user.isAdmin %}
894+
Admin: {{ user.name }}
895+
{% for item in user.items %}
896+
{{ item.id }} - {{ item.value }}
897+
{% endfor %}
898+
{% else %}
899+
User: {{ user.name }}
900+
{% endif %}
901+
`;
902+
const result = validateTemplate(template);
903+
expect(result.isValid).toBe(true);
904+
expect(result.error).toBeUndefined();
905+
});
906+
});
907+
908+
describe('compilePrompt', () => {
909+
const baseMockVariables = {
910+
name: 'Tester',
911+
myObj: { legitProperty: 'hello' },
912+
global: { version: '1.0' },
913+
};
914+
915+
it('should compile a valid prompt', () => {
916+
const template = 'Hello {{ name }}!';
917+
expect(() => compilePrompt(template, baseMockVariables)).not.toThrow();
918+
expect(compilePrompt(template, baseMockVariables)).toBe('Hello Tester!');
919+
});
920+
921+
it("should compile a prompt using 'global' as it was removed from blacklist by user", () => {
922+
const template = 'Version: {{ global.version }}';
923+
expect(() => compilePrompt(template, baseMockVariables)).not.toThrow();
924+
// Ensure the output matches the global.version from baseMockVariables
925+
expect(compilePrompt(template, baseMockVariables)).toBe('Version: 1.0');
926+
});
927+
928+
it('should throw error for disallowed identifiers', () => {
929+
const template = 'Data: {{ process.env.SECRET }}';
930+
expect(() => compilePrompt(template, baseMockVariables)).toThrow(
931+
"Security: Disallowed identifier 'process' found.",
932+
);
933+
});
934+
935+
it('should throw error for disallowed property lookups', () => {
936+
const template = 'Access: {{ myObj.__proto__ }}';
937+
expect(() => compilePrompt(template, baseMockVariables)).toThrow(
938+
"Security: Disallowed property lookup '__proto__' found.",
939+
);
940+
});
941+
942+
it('should throw error for Nunjucks syntax errors which cause parse fail in ensureSecureAst', () => {
943+
const template = 'Hello {{ name !'; // Invalid Nunjucks syntax
944+
expect(() => compilePrompt(template, baseMockVariables)).toThrow(
945+
'Template parsing failed: expected variable end',
946+
);
947+
});
948+
949+
it("should allow mixed-case identifiers like 'Process' if not explicitly blacklisted (current check is case-sensitive)", () => {
950+
const template = 'Data: {{ Process.env.SECRET }}'; // 'Process' is not in DISALLOWED_IDENTIFIERS
951+
const result = validateTemplate(template);
952+
expect(result.isValid).toBe(true);
953+
expect(result.error).toBeUndefined();
954+
955+
const specificMockVariables = {
956+
...baseMockVariables,
957+
Process: { env: { SECRET: 'oops' } },
958+
};
959+
expect(() => compilePrompt(template, specificMockVariables)).not.toThrow();
960+
expect(compilePrompt(template, specificMockVariables)).toBe('Data: oops');
961+
});
962+
963+
it('should allow deeply nested valid lookups', () => {
964+
const template = 'Deep: {{ a.b.c.d }}';
965+
const vars = { ...baseMockVariables, a: { b: { c: { d: 'value' } } } };
966+
expect(compilePrompt(template, vars)).toBe('Deep: value');
967+
});
968+
});

src/lib/PromptsService.ts

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
import { Database, Json } from '@/app/__generated__/supabase.types';
1+
import { Database } from '@/app/__generated__/supabase.types';
22
import type { SupabaseClient } from '@supabase/supabase-js';
33
import { AgentsmithSupabaseService } from './AgentsmithSupabaseService';
44
import { AgentsmithServicesDirectory } from './AgentsmithServices';
5-
// @ts-ignore needs to be browser version so nextjs can import it
6-
import nunjucks from 'nunjucks/browser/nunjucks';
75
import {
86
OpenrouterRequestBody,
97
CompletionConfig,
@@ -16,14 +14,7 @@ import {
1614
import { routes } from '@/utils/routes';
1715
import { ORGANIZATION_KEYS, SEMVER_PATTERN } from '@/app/constants';
1816
import { compareSemanticVersions, incrementVersion } from '@/utils/versioning';
19-
import {
20-
compilePrompt,
21-
extractTemplateVariables,
22-
findMissingGlobalContext,
23-
processUniqueValues,
24-
} from '@/utils/template-utils';
25-
26-
type PromptVariable = Database['public']['Tables']['prompt_variables']['Row'];
17+
import { compilePrompt } from '@/utils/template-utils';
2718

2819
type PromptVariableBase = Omit<
2920
Database['public']['Tables']['prompt_variables']['Row'],

src/utils/template-utils.ts

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -231,21 +231,108 @@ export const extractTemplateVariables = (
231231
}
232232
};
233233

234+
const DISALLOWED_IDENTIFIERS = [
235+
'process',
236+
'require',
237+
'eval',
238+
'Function',
239+
'this',
240+
'window',
241+
'document',
242+
'self',
243+
'globalThis',
244+
'constructor',
245+
'prototype',
246+
'__proto__',
247+
];
248+
249+
const DISALLOWED_PROPERTY_LOOKUPS = ['__proto__', 'constructor', 'prototype'];
250+
251+
const ensureSecureAstRecursive = (node: any): void => {
252+
if (!node) return;
253+
254+
if (Array.isArray(node)) {
255+
node.forEach(ensureSecureAstRecursive);
256+
return;
257+
}
258+
259+
switch (node.typename) {
260+
case 'Symbol':
261+
if (DISALLOWED_IDENTIFIERS.includes(node.value)) {
262+
throw new Error(`Security: Disallowed identifier '${node.value}' found.`);
263+
}
264+
break;
265+
case 'LookupVal':
266+
if (
267+
node.val &&
268+
node.val.typename === 'Literal' &&
269+
typeof node.val.value === 'string' &&
270+
DISALLOWED_PROPERTY_LOOKUPS.includes(node.val.value)
271+
) {
272+
throw new Error(`Security: Disallowed property lookup '${node.val.value}' found.`);
273+
}
274+
ensureSecureAstRecursive(node.target);
275+
ensureSecureAstRecursive(node.val);
276+
break;
277+
default:
278+
if (typeof node === 'object') {
279+
for (const key in node) {
280+
if (
281+
Object.prototype.hasOwnProperty.call(node, key) &&
282+
!key.startsWith('_') &&
283+
key !== 'parent'
284+
) {
285+
ensureSecureAstRecursive(node[key]);
286+
}
287+
}
288+
}
289+
break;
290+
}
291+
};
292+
293+
const ensureSecureAst = (content: string): void => {
294+
try {
295+
// @ts-ignore - parser exists but is not in type definitions
296+
const ast = nunjucks.parser.parse(content);
297+
ensureSecureAstRecursive(ast);
298+
} catch (error) {
299+
if (error instanceof Error && error.message.startsWith('Security:')) {
300+
throw error; // Re-throw specific security errors
301+
}
302+
// For other parsing errors, wrap them if necessary or let them bubble up as Nunjucks errors.
303+
// For now, re-throwing to ensure `validateTemplate` can catch them as generic parsing issues if not security related.
304+
throw new Error(
305+
`Template parsing failed: ${error instanceof Error ? error.message : String(error)}`,
306+
);
307+
}
308+
};
309+
234310
/**
235311
* Validates if a Nunjucks template is syntactically correct
236312
* Note: Currently not used in the application, but kept for potential future use
237313
*/
238314
export const validateTemplate = (content: string): { isValid: boolean; error?: string } => {
239315
try {
316+
// First, check basic Nunjucks syntax
240317
transform(nunjucks.parser.parse(content));
241-
242-
return { isValid: true };
243318
} catch (error) {
244319
return {
245320
isValid: false,
246321
error: error instanceof Error ? error.message : 'Invalid template syntax',
247322
};
248323
}
324+
325+
try {
326+
// Then, perform our security AST check
327+
ensureSecureAst(content);
328+
} catch (error) {
329+
return {
330+
isValid: false,
331+
error: error instanceof Error ? error.message : 'Template violates security policy',
332+
};
333+
}
334+
335+
return { isValid: true };
249336
};
250337

251338
type FindMissingGlobalContextOptions = {
@@ -348,8 +435,8 @@ export const compilePrompt = (
348435
promptContent: string,
349436
variables: Record<string, any> & { global: Record<string, any> },
350437
): string => {
438+
ensureSecureAst(promptContent); // Perform security check first
351439
const processedVariables = processUniqueValues(variables);
352-
353440
nunjucks.configure({ autoescape: false });
354441
return nunjucks.renderString(promptContent, processedVariables);
355442
};

0 commit comments

Comments
 (0)