Server-side template injection in AI-generated Express.js apps
作者:Seaworthy · 2026年6月27日
The setup
Express.js plus a template engine is one of the most common patterns AI tools produce when you ask for "a simple web app." Ask for a profile page or an email preview renderer, and you'll get an Express route that takes a query parameter, passes it into a Pug or EJS template, and sends back HTML. It looks innocent. It usually isn't.
Server-side template injection (SSTI) is what happens when user-controlled data lands inside the template engine itself, not just inside a template variable. The engine then parses and executes that data. The result ranges from credential leakage to full remote code execution.
This is not a theoretical edge case. SSTI appears in OWASP's security testing guide and has been used in real-world breaches. AI-generated code is making it more common, not less, because LLMs learned from tutorials written for speed rather than correctness.
Where the injection surface is
Two distinct failure modes matter here.
The first is passing raw input as a template string. The engine compiles the user's input as if it were template source code. This is catastrophic.
The second is passing raw input into an unsafe template expression. This is subtler but still dangerous in engines that support unescaped output or dynamic compilation.
Here's what both failures look like in practice, representative of AI-generated Express scaffolding:
// EJS: passing user input as the template itself
app.get('/preview', (req, res) => {
const userTemplate = req.query.template;
const html = ejs.render(userTemplate, { user: req.session.user });
res.send(html);
});
// Pug: dynamic template compilation from user input
app.get('/render', (req, res) => {
const fn = pug.compile(req.query.layout);
res.send(fn({ title: 'My App' }));
});
// Handlebars: registering user-supplied partial content at runtime
app.post('/template', (req, res) => {
handlebars.registerPartial(req.body.name, req.body.content);
const template = handlebars.compile('{{> ' + req.body.name + '}}');
res.send(template({}));
});
In the EJS example, an attacker sends ?template=<%= process.env.DATABASE_URL %> and reads your database credentials in the response. With Pug, the payload - global.process.mainModule.require('child_process').execSync('id') executes shell commands on your server. Both payloads are trivial to craft. The only thing standing between an attacker and your secrets is whether you let user input reach ejs.render() as a string.
AI tools generate these patterns because the training data is full of demo code where dynamism is presented as a feature. Nobody reviewed those tutorials for security before they were indexed and learned from.
What an attacker can do with this
Remote code execution is the ceiling, and it's closer than it sounds. EJS and Pug both expose Node.js globals unless you explicitly sandbox them. Handlebars is more constrained by design but still dangerous when users can define partials or trigger template compilation.
More common in practice: data exfiltration. A template that renders process.env or iterates over a database object in the context can leak everything the server knows. The attacker does not need RCE if the template language lets them simply ask for the data.
The session object is the other target. If req.session is in the template context, SSTI gives the attacker a window into every active user's session data.
The safer alternative
The core rule: never pass user input to compile(), render(), or any function that treats a string as template source code. User data belongs in the context object, not in the template string.
// EJS: templates live on disk; user input goes only into context
app.get('/profile', (req, res) => {
const username = sanitize(req.query.username);
res.render('profile', { username }); // 'profile' is a fixed filename
});
// Pug: template path is hardcoded; a whitelist controls any variation
app.get('/report/:type', (req, res) => {
const allowed = ['summary', 'detail'];
const type = allowed.includes(req.params.type) ? req.params.type : 'summary';
res.render(`reports/${type}`, { data: req.reportData });
});
// Handlebars: precompile all templates at startup; accept nothing new at runtime
const templates = {
email: handlebars.compile(fs.readFileSync('./views/email.hbs', 'utf8')),
};
app.post('/send', (req, res) => {
const html = templates.email({ name: req.body.name });
res.send(html);
});
Lock down template selection with a whitelist. Precompile at startup so no string-to-template compilation happens per request. Keep process, req, and req.session out of the render context entirely: pass only the specific values the template actually needs.
If the template cannot see process.env, it cannot leak it.
Why AI keeps generating this
LLMs optimise for functional examples that communicate a concept quickly. SSTI vulnerabilities are born from the same patterns that make demos readable: dynamic, flexible, minimal code. Ask an AI to build "a flexible template system" and you will get something that compiles templates dynamically, because that is what "flexible" implies in a programming context.
That is not an excuse for skipping code review. It is a reason to be more systematic about it. Seaworthy's static analysis flags unsanitised user input flowing into template engine calls across Node.js projects, including Express apps using EJS, Pug, and Handlebars.
本文由 AI 生成,总结了公开可用的来源。
Seaworthy 会扫描你的代码仓库,发现类似本文提到的各种问题。
安全漏洞、泄露的密钥和配置错误——在部署前捕获。免费运行,无需账户。