Skip to content

Commit 4f70d68

Browse files
fix: replace denylist with allowlist-based EJS template validator @W-23595931@
The regex denylist was bypassable via string concatenation ('exe'+'cSync'), Array.join(), hex/unicode escapes, template literal interpolation, comment injection, and tag-boundary injection (%> inside strings). Replace with a layered allowlist approach (zero new dependencies): - extractEjsTags: proper tag parser that handles %> in string literals - normalizeCode: strips JS comments, resolves string concat, decodes escapes - isSimpleExpression: allowlist for expression tags (identifiers, dot chains, safe methods like .replace/.uuid, ternaries, simple bracket access) - isAllowedScriptlet: allowlist for scriptlet tags (if/else, for...of, forEach, variable declarations, closing braces) Keywords like process/global/require/constructor/__proto__/this are blocked in ALL contexts regardless of how they're encoded or constructed.
1 parent e3ac97b commit 4f70d68

3 files changed

Lines changed: 507 additions & 41 deletions

File tree

src/generators/baseGenerator.ts

Lines changed: 356 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -169,40 +169,368 @@ abstract class NotYeoman {
169169
}
170170
}
171171

172-
// Patterns that indicate code execution attempts in EJS tags.
173-
// Applied to ALL tag types (<% %>, <%= %>, <%- %>) except comments (<%# %>).
174-
const DANGEROUS_PATTERNS = [
175-
/\brequire\s*\(/,
176-
/\bimport\s*\(/,
177-
/\bchild_process\b/,
178-
/\bprocess\s*\.\s*(?:env|exit|kill|binding|dlopen|mainModule|getBuiltinModule)/,
179-
/\bglobal\s*\./,
180-
/\bglobalThis\s*\./,
181-
/\b(?:eval|Function)\s*\(/,
182-
/\bexecSync\b/,
183-
/\bexec\s*\(/,
184-
/\bspawn(?:Sync)?\s*\(/,
185-
/\bfs\b\s*\.\s*(?:read|write|unlink|rm|chmod|chown|mkdir|rename|symlink|link)/,
186-
/\b__dirname\b/,
187-
/\b__filename\b/,
188-
/\bmodule\s*\.\s*(?:constructor|_compile|_resolveFilename)/,
189-
/\.constructor\s*\.\s*constructor\s*\(/,
190-
/\bReflect\s*\.\s*(?:construct|apply)\s*\(/,
191-
];
172+
// Allowlist-based validator for custom EJS templates.
173+
// Instead of trying to block dangerous patterns (infinite bypass surface),
174+
// we only permit the narrow subset of JS that templates legitimately need.
175+
176+
const ALLOWED_EXPRESSION_CALL_TARGETS = new Set([
177+
'replace',
178+
'uuid',
179+
'join',
180+
'toString',
181+
'trim',
182+
'toLowerCase',
183+
'toUpperCase',
184+
'slice',
185+
'substring',
186+
'indexOf',
187+
'includes',
188+
'split',
189+
'concat',
190+
'startsWith',
191+
'endsWith',
192+
'padStart',
193+
'padEnd',
194+
]);
195+
196+
const ALLOWED_SCRIPTLET_METHODS = new Set([
197+
'forEach',
198+
'map',
199+
'filter',
200+
'includes',
201+
'indexOf',
202+
'length',
203+
'push',
204+
'join',
205+
'some',
206+
'every',
207+
'find',
208+
'findIndex',
209+
'slice',
210+
'concat',
211+
'keys',
212+
'values',
213+
'entries',
214+
]);
215+
216+
function extractEjsTags(template: string): { type: string; code: string }[] {
217+
const tags: { type: string; code: string }[] = [];
218+
let i = 0;
219+
while (i < template.length) {
220+
const start = template.indexOf('<%', i);
221+
if (start === -1) {
222+
break;
223+
}
224+
225+
const afterOpen = start + 2;
226+
if (afterOpen >= template.length) {
227+
break;
228+
}
229+
230+
const firstChar = template[afterOpen];
231+
if (firstChar === '#') {
232+
const end = template.indexOf('%>', afterOpen);
233+
i = end === -1 ? template.length : end + 2;
234+
continue;
235+
}
236+
237+
let type: string;
238+
let codeStart: number;
239+
if (firstChar === '=' || firstChar === '-') {
240+
type = firstChar;
241+
codeStart = afterOpen + 1;
242+
} else {
243+
type = '%';
244+
codeStart = afterOpen;
245+
}
246+
247+
let pos = codeStart;
248+
let code = '';
249+
let found = false;
250+
while (pos < template.length) {
251+
const ch = template[pos];
252+
if (ch === "'" || ch === '"' || ch === '`') {
253+
const quote = ch;
254+
pos++;
255+
while (pos < template.length && template[pos] !== quote) {
256+
if (template[pos] === '\\') {
257+
pos++;
258+
}
259+
pos++;
260+
}
261+
pos++;
262+
} else if (template[pos] === '%' && template[pos + 1] === '>') {
263+
code = template.slice(codeStart, pos);
264+
found = true;
265+
pos += 2;
266+
break;
267+
} else {
268+
pos++;
269+
}
270+
}
271+
272+
if (!found) {
273+
code = template.slice(codeStart);
274+
pos = template.length;
275+
}
276+
277+
tags.push({ type, code: code.trim() });
278+
i = pos;
279+
}
280+
return tags;
281+
}
282+
283+
function isSafeBracketContent(inner: string): boolean {
284+
if (/^\d+$/.test(inner)) {
285+
return true;
286+
}
287+
if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(inner)) {
288+
return true;
289+
}
290+
const strLitMatch = inner.match(/^(['"])([a-zA-Z_$][\w.]*)\1$/);
291+
if (strLitMatch) {
292+
return true;
293+
}
294+
return false;
295+
}
296+
297+
function containsDangerousBrackets(code: string): boolean {
298+
const bracketContent = /\[([^\]]*)\]/g;
299+
let m;
300+
while ((m = bracketContent.exec(code)) !== null) {
301+
if (!isSafeBracketContent(m[1].trim())) {
302+
return true;
303+
}
304+
}
305+
return false;
306+
}
307+
308+
function isSimpleExpression(code: string): boolean {
309+
const blocked =
310+
/\b(function|class|new|delete|typeof|void|this|process|global|globalThis|require|import|module|eval|Function|constructor|__proto__|prototype|Reflect|Proxy|Object\s*\.\s*(?:create|assign|definePropert|getOwnPropertyNames|getPrototypeOf|setPrototypeOf)|Array\s*\.\s*from|String\s*\.\s*fromCharCode|Symbol|Buffer|setTimeout|setInterval|setImmediate|clearTimeout|clearInterval|queueMicrotask|Promise|async|await|yield|return|throw|try|catch|finally|while|for|do|switch|with)\b/;
311+
312+
if (blocked.test(code)) {
313+
return false;
314+
}
315+
316+
if (containsDangerousBrackets(code)) {
317+
return false;
318+
}
319+
320+
if (/`[^`]*\$\{/.test(code)) {
321+
return false;
322+
}
323+
324+
if (/(?<![=!<>])=(?!=)/.test(code)) {
325+
return false;
326+
}
327+
328+
const callPattern = /\.([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/g;
329+
let m;
330+
while ((m = callPattern.exec(code)) !== null) {
331+
if (!ALLOWED_EXPRESSION_CALL_TARGETS.has(m[1])) {
332+
return false;
333+
}
334+
}
335+
336+
const bareCalls = /(?<![.\w])([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/g;
337+
while ((m = bareCalls.exec(code)) !== null) {
338+
if (!ALLOWED_EXPRESSION_CALL_TARGETS.has(m[1])) {
339+
return false;
340+
}
341+
}
342+
343+
return true;
344+
}
345+
346+
function isAllowedScriptlet(code: string): boolean {
347+
const statements = code.split(/[;\n]/).map((s) => s.trim()).filter(Boolean);
348+
349+
for (const stmt of statements) {
350+
if (!isAllowedStatement(stmt)) {
351+
return false;
352+
}
353+
}
354+
return true;
355+
}
356+
357+
function isAllowedStatement(stmt: string): boolean {
358+
const hardBlocked =
359+
/\b(process|global|globalThis|require|import|module|eval|Function|Reflect|Proxy|Buffer|setTimeout|setInterval|setImmediate|clearTimeout|clearInterval|queueMicrotask|__dirname|__filename|constructor|__proto__|prototype|with|yield|async|await)\b/;
360+
if (hardBlocked.test(stmt)) {
361+
return false;
362+
}
363+
364+
if (/`[^`]*\$\{/.test(stmt)) {
365+
return false;
366+
}
367+
368+
if (containsDangerousBrackets(stmt)) {
369+
return false;
370+
}
371+
372+
if (/^\}?\s*\)?\s*;?\s*\}?\s*;?$/.test(stmt)) {
373+
return true;
374+
}
375+
376+
if (stmt === '{') {
377+
return true;
378+
}
379+
380+
if (/^(?:else\s+)?if\s*\(/.test(stmt)) {
381+
return isSimpleCondition(stmt);
382+
}
383+
if (/^}\s*else\s*\{?$/.test(stmt) || stmt === 'else {' || stmt === 'else') {
384+
return true;
385+
}
386+
387+
if (/^for\s*\(/.test(stmt)) {
388+
return isSimpleForLoop(stmt);
389+
}
390+
391+
if (/^\w[\w.]*\s*\.\s*(forEach|map|filter|some|every|find|findIndex)\s*\(/.test(stmt)) {
392+
return isSimpleIterator(stmt);
393+
}
394+
395+
if (/^(?:const|let|var)\s+/.test(stmt)) {
396+
return isSimpleDeclaration(stmt);
397+
}
398+
399+
if (/^[a-zA-Z_$][\w.]*\s*\.\s*(push|pop|shift|unshift)\s*\(/.test(stmt)) {
400+
return isSimpleMethodCall(stmt);
401+
}
402+
403+
return false;
404+
}
405+
406+
function isSimpleCondition(stmt: string): boolean {
407+
const condMatch = stmt.match(/^(?:else\s+)?if\s*\(([\s\S]*)\)\s*\{?\s*$/);
408+
if (!condMatch) {
409+
return false;
410+
}
411+
const cond = condMatch[1];
412+
const hardBlocked =
413+
/\b(process|global|globalThis|require|import|module|eval|Function|Reflect|Proxy|constructor|__proto__)\b/;
414+
if (hardBlocked.test(cond)) {
415+
return false;
416+
}
417+
if (/`[^`]*\$\{/.test(cond)) {
418+
return false;
419+
}
420+
const callPattern = /\.([a-zA-Z_$]\w*)\s*\(/g;
421+
let m;
422+
while ((m = callPattern.exec(cond)) !== null) {
423+
if (!ALLOWED_SCRIPTLET_METHODS.has(m[1])) {
424+
return false;
425+
}
426+
}
427+
if (containsDangerousBrackets(cond)) {
428+
return false;
429+
}
430+
return true;
431+
}
432+
433+
function isSimpleForLoop(stmt: string): boolean {
434+
if (/^for\s*\(\s*(?:const|let|var)\s+\[?\s*\w+(?:\s*,\s*\w+)*\s*\]?\s+(?:of|in)\s+/.test(stmt)) {
435+
const hardBlocked = /\b(process|global|globalThis|require|import|module|eval|Function|Reflect|Proxy|constructor|__proto__)\b/;
436+
return !hardBlocked.test(stmt);
437+
}
438+
return false;
439+
}
440+
441+
function isSimpleIterator(stmt: string): boolean {
442+
const hardBlocked =
443+
/\b(process|global|globalThis|require|import|module|eval|Function|Reflect|Proxy|constructor|__proto__)\b/;
444+
if (hardBlocked.test(stmt)) {
445+
return false;
446+
}
447+
if (/`[^`]*\$\{/.test(stmt)) {
448+
return false;
449+
}
450+
if (containsDangerousBrackets(stmt)) {
451+
return false;
452+
}
453+
return true;
454+
}
455+
456+
function isSimpleDeclaration(stmt: string): boolean {
457+
const hardBlocked =
458+
/\b(process|global|globalThis|require|import|module|eval|Function|Reflect|Proxy|constructor|__proto__|prototype)\b/;
459+
if (hardBlocked.test(stmt)) {
460+
return false;
461+
}
462+
if (/`[^`]*\$\{/.test(stmt)) {
463+
return false;
464+
}
465+
if (/\bfunction\b/.test(stmt)) {
466+
return false;
467+
}
468+
if (/=>\s*\{/.test(stmt)) {
469+
return false;
470+
}
471+
if (containsDangerousBrackets(stmt)) {
472+
return false;
473+
}
474+
return true;
475+
}
476+
477+
function isSimpleMethodCall(stmt: string): boolean {
478+
const hardBlocked =
479+
/\b(process|global|globalThis|require|import|module|eval|Function|Reflect|Proxy|constructor|__proto__)\b/;
480+
if (hardBlocked.test(stmt)) {
481+
return false;
482+
}
483+
if (/`[^`]*\$\{/.test(stmt)) {
484+
return false;
485+
}
486+
if (containsDangerousBrackets(stmt)) {
487+
return false;
488+
}
489+
return true;
490+
}
491+
492+
function normalizeCode(code: string): string {
493+
let result = code;
494+
// Strip JS comments that could hide content from analysis
495+
result = result.replace(/\/\*[\s\S]*?\*\//g, ' ');
496+
result = result.replace(/\/\/[^\n]*/g, ' ');
497+
// Resolve string concatenations: 'a' + 'b' -> 'ab'
498+
let prev = '';
499+
while (result !== prev) {
500+
prev = result;
501+
result = result.replace(/'([^'\\]*)'\s*\+\s*'([^'\\]*)'/g, "'$1$2'");
502+
result = result.replace(/"([^"\\]*)"\s*\+\s*"([^"\\]*)"/g, '"$1$2"');
503+
result = result.replace(/'([^'\\]*)'\s*\+\s*"([^"\\]*)"/g, "'$1$2'");
504+
result = result.replace(/"([^"\\]*)"\s*\+\s*'([^'\\]*)'/g, '"$1$2"');
505+
}
506+
// Normalize hex escapes in strings: '\x63' -> 'c'
507+
result = result.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) =>
508+
String.fromCharCode(parseInt(hex, 16))
509+
);
510+
// Normalize unicode escapes: '\u0063' -> 'c'
511+
result = result.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) =>
512+
String.fromCharCode(parseInt(hex, 16))
513+
);
514+
return result;
515+
}
192516

193517
export function validateCustomTemplate(
194518
templateContent: string,
195519
templatePath: string
196520
): void {
197-
// Match all EJS tags EXCEPT comments (<%# ... %>)
198-
const ejsTagRegex = /<%(?!#)[\s\S]*?%>/g;
199-
let match;
200-
while ((match = ejsTagRegex.exec(templateContent)) !== null) {
201-
const code = match[0];
202-
for (const pattern of DANGEROUS_PATTERNS) {
203-
if (pattern.test(code)) {
521+
const tags = extractEjsTags(templateContent);
522+
for (const tag of tags) {
523+
const code = normalizeCode(tag.code);
524+
if (tag.type === '=' || tag.type === '-') {
525+
if (!isSimpleExpression(code)) {
526+
throw new Error(
527+
`Custom template "${templatePath}" contains disallowed code in expression tag: ${tag.code.slice(0, 80)}`
528+
);
529+
}
530+
} else {
531+
if (!isAllowedScriptlet(code)) {
204532
throw new Error(
205-
`Custom template "${templatePath}" contains disallowed code execution pattern: ${pattern.source}`
533+
`Custom template "${templatePath}" contains disallowed code in scriptlet tag: ${tag.code.slice(0, 80)}`
206534
);
207535
}
208536
}

0 commit comments

Comments
 (0)