Skip to content

Commit e3ac97b

Browse files
fix: validate custom templates for dangerous code execution patterns @W-23595931@
Scan ALL EJS tag types (<% %>, <%= %>, <%- %>) except comments for dangerous patterns before rendering custom templates. Built-in templates are identified by path prefix and skip validation. Fixes: - Validates output/expression tags too (not just scriptlet blocks) - Reads file once then renders from memory (no TOCTOU race) - Checks source against builtInTemplatesRootPath (covers all custom template paths including FlexipageGenerator's separate resolution) - Blocks constructor chain traversal and Reflect API bypasses Tested against the exact exploit from the bug bounty report (which uses process.getBuiltinModule + spawn to launch Calculator).
1 parent 7bfcc16 commit e3ac97b

4 files changed

Lines changed: 212 additions & 0 deletions

File tree

src/generators/baseGenerator.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,46 @@ 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+
];
192+
193+
export function validateCustomTemplate(
194+
templateContent: string,
195+
templatePath: string
196+
): 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)) {
204+
throw new Error(
205+
`Custom template "${templatePath}" contains disallowed code execution pattern: ${pattern.source}`
206+
);
207+
}
208+
}
209+
}
210+
}
211+
172212
export abstract class BaseGenerator<
173213
TOptions extends TemplateOptions
174214
> extends NotYeoman {
@@ -237,6 +277,41 @@ export abstract class BaseGenerator<
237277
}
238278
}
239279

280+
public async render(
281+
source: string,
282+
destination: string,
283+
data?: Record<string, unknown>
284+
): Promise<void> {
285+
const isBuiltIn = this.builtInTemplatesRootPath && source.startsWith(this.builtInTemplatesRootPath);
286+
if (!isBuiltIn) {
287+
const template = await this._fs.promises.readFile(source, 'utf8');
288+
validateCustomTemplate(template, source);
289+
const rendered = render(template, data ?? {});
290+
if (rendered) {
291+
const relativePath = path.relative(this._cwd, destination);
292+
const existing = await this._fs.promises
293+
.readFile(destination, 'utf8')
294+
.catch(() => null);
295+
if (existing) {
296+
if (rendered.trim() === existing.trim()) {
297+
this.changes.identical.push(relativePath);
298+
return;
299+
} else {
300+
this.changes.conflicted.push(relativePath);
301+
this.changes.forced.push(relativePath);
302+
}
303+
} else {
304+
this.changes.created.push(relativePath);
305+
}
306+
const dir = path.dirname(destination);
307+
await this._fs.promises.mkdir(dir, { recursive: true });
308+
await this._fs.promises.writeFile(destination, rendered);
309+
}
310+
return;
311+
}
312+
return super.render(source, destination, data);
313+
}
314+
240315
public async run(opts?: {
241316
cwd?: string;
242317
customTemplatesRootPathOrGitRepo?: string;

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import { TemplateService } from './service/templateService';
99
export { TemplateService };
10+
export { validateCustomTemplate } from './generators/baseGenerator';
1011
export * from './utils/types';
1112
export * from './utils/createUtil';
1213
export * from './utils/uiEmbedding';

test/generators/baseGenerator.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { TemplateOptions } from '../../src';
1111
import {
1212
BaseGenerator,
1313
getDefaultApiVersion,
14+
validateCustomTemplate,
1415
} from '../../src/generators/baseGenerator';
1516

1617
describe('BaseGenerator', () => {
@@ -60,3 +61,100 @@ describe('BaseGenerator', () => {
6061
expect(validateOptionsStub.calledOnce).to.be.true;
6162
});
6263
});
64+
65+
describe('validateCustomTemplate', () => {
66+
it('should allow safe interpolation-only templates', () => {
67+
const template = 'public class <%= apiName %> {\n}';
68+
expect(() => validateCustomTemplate(template, 'test.cls')).to.not.throw();
69+
});
70+
71+
it('should allow safe control flow in scriptlet tags', () => {
72+
const template = '<% if (primaryField) { %>\n<%= primaryField %>\n<% } %>';
73+
expect(() => validateCustomTemplate(template, 'test.xml')).to.not.throw();
74+
});
75+
76+
it('should allow forEach loops', () => {
77+
const template = '<% fields.forEach((field) => { %>\n<%= field %>\n<% }); %>';
78+
expect(() => validateCustomTemplate(template, 'test.xml')).to.not.throw();
79+
});
80+
81+
it('should block require() calls', () => {
82+
const template = '<% require("child_process").execSync("calc") %>';
83+
expect(() => validateCustomTemplate(template, 'malicious.cls')).to.throw(
84+
'disallowed code execution pattern'
85+
);
86+
});
87+
88+
it('should block dynamic import()', () => {
89+
const template = '<% const m = await import("fs") %>';
90+
expect(() => validateCustomTemplate(template, 'malicious.cls')).to.throw(
91+
'disallowed code execution pattern'
92+
);
93+
});
94+
95+
it('should block process.env access', () => {
96+
const template = '<% process.env.SECRET %>';
97+
expect(() => validateCustomTemplate(template, 'malicious.cls')).to.throw(
98+
'disallowed code execution pattern'
99+
);
100+
});
101+
102+
it('should block eval()', () => {
103+
const template = '<% eval("malicious code") %>';
104+
expect(() => validateCustomTemplate(template, 'malicious.cls')).to.throw(
105+
'disallowed code execution pattern'
106+
);
107+
});
108+
109+
it('should block Function constructor', () => {
110+
const template = '<% Function("return process")() %>';
111+
expect(() => validateCustomTemplate(template, 'malicious.cls')).to.throw(
112+
'disallowed code execution pattern'
113+
);
114+
});
115+
116+
it('should block execSync', () => {
117+
const template = '<% execSync("whoami") %>';
118+
expect(() => validateCustomTemplate(template, 'malicious.cls')).to.throw(
119+
'disallowed code execution pattern'
120+
);
121+
});
122+
123+
it('should block globalThis access', () => {
124+
const template = '<% globalThis.process %>';
125+
expect(() => validateCustomTemplate(template, 'malicious.cls')).to.throw(
126+
'disallowed code execution pattern'
127+
);
128+
});
129+
130+
it('should block require() in output tags (<%= %>)', () => {
131+
const template = '<%= require("child_process").execSync("whoami").toString() %>';
132+
expect(() => validateCustomTemplate(template, 'malicious.cls')).to.throw(
133+
'disallowed code execution pattern'
134+
);
135+
});
136+
137+
it('should block require() in unescaped output tags (<%- %>)', () => {
138+
const template = '<%- require("fs").readFileSync("/etc/passwd","utf8") %>';
139+
expect(() => validateCustomTemplate(template, 'malicious.cls')).to.throw(
140+
'disallowed code execution pattern'
141+
);
142+
});
143+
144+
it('should block constructor chain prototype traversal', () => {
145+
const template = '<% this.constructor.constructor("return process")() %>';
146+
expect(() => validateCustomTemplate(template, 'malicious.cls')).to.throw(
147+
'disallowed code execution pattern'
148+
);
149+
});
150+
151+
it('should not flag safe output tags', () => {
152+
const template = '<%= apiName %>';
153+
expect(() => validateCustomTemplate(template, 'test.cls')).to.not.throw();
154+
});
155+
156+
it('should not flag comment tags', () => {
157+
const template = '<%# require("fs") %>';
158+
expect(() => validateCustomTemplate(template, 'test.cls')).to.not.throw();
159+
});
160+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { expect } from 'chai';
2+
import { validateCustomTemplate } from '../../src/generators/baseGenerator';
3+
4+
describe('Exploit Reproduction (W-23595931)', () => {
5+
const exploitTemplate = [
6+
'<%',
7+
'const { spawn } = process.getBuiltinModule(\'node:child_process\');',
8+
'const calculator = process.platform === \'darwin\'',
9+
' ? [\'/usr/bin/open\', [\'-a\', \'Calculator\']]',
10+
' : [\'/bin/sh\', [\'-c\', \'command -v gnome-calculator\']];',
11+
'const child = spawn(calculator[0], calculator[1], {',
12+
' detached: true, stdio: \'ignore\',',
13+
'});',
14+
'child.unref();',
15+
'%>public with sharing class <%= apiName %> {',
16+
'}',
17+
].join('\n');
18+
19+
it('should block the exact exploit template from the bug bounty report', () => {
20+
expect(() => validateCustomTemplate(exploitTemplate, 'DefaultApexClass.cls')).to.throw(
21+
'disallowed code execution pattern'
22+
);
23+
});
24+
25+
it('should block process.getBuiltinModule variant without spawn keyword', () => {
26+
const variant = '<% const cp = process.getBuiltinModule("node:child_process"); cp["exe" + "cSync"]("whoami") %>';
27+
expect(() => validateCustomTemplate(variant, 'variant.cls')).to.throw(
28+
'disallowed code execution pattern'
29+
);
30+
});
31+
32+
it('should block require variant even with string concatenation obfuscation in the arg', () => {
33+
const variant = '<% require("child" + "_process") %>';
34+
expect(() => validateCustomTemplate(variant, 'variant.cls')).to.throw(
35+
'disallowed code execution pattern'
36+
);
37+
});
38+
});

0 commit comments

Comments
 (0)