| name | sast-ssti |
| description | Detect Server-Side Template Injection (SSTI) vulnerabilities in a codebase using a three-phase approach: recon (find template rendering sites that use dynamic strings), batched verify (trace user input to those sites in parallel subagents, 3 candidates each), and merge (consolidate batch results). Requires sast/architecture.md (run sast-analysis first). Outputs findings to sast/ssti-results.md. Use when asked to find SSTI or template injection bugs. |
Server-Side Template Injection (SSTI) Detection
You are performing a focused security assessment to find Server-Side Template Injection vulnerabilities in a codebase. This skill uses a three-phase approach with subagents: recon (find candidate rendering sites where the template string is dynamic), batched verify (trace whether user input reaches each site's template argument, in parallel batches of 3), and merge (consolidate batch results into the final report).
Prerequisites: sast/architecture.md must exist. Run the analysis skill first if it doesn't.
What is SSTI
Server-Side Template Injection occurs when user-supplied input is embedded directly into a template string that is then evaluated by a template engine. Unlike passing user data as context variables to a static template, SSTI means the user can write template syntax that the engine will execute — leading to arbitrary code execution, file read, or full server compromise.
The core pattern: unvalidated user input is used as the template string passed to a template engine's render/compile/evaluate function.
What SSTI IS
What SSTI is NOT
Do not flag these patterns:
Patterns That Prevent SSTI
When you see these patterns, the code is likely not vulnerable:
1. Static template file with dynamic context (most common safe pattern)
return render_template("user_profile.html", username=request.args.get("name"))
res.render("dashboard", { user: req.user })
2. Allowlist validation for template names
ALLOWED_TEMPLATES = {"invoice.html", "receipt.html", "summary.html"}
template_name = request.args.get("tmpl", "invoice.html")
if template_name not in ALLOWED_TEMPLATES:
abort(400)
return render_template(template_name)
3. Logic-less / sandboxed engines that don't support code execution
const output = Mustache.render(userTemplate, ctx);
Vulnerable vs. Secure Examples
Python — Flask / Jinja2
@app.route('/greet')
def greet():
name = request.args.get('name', '')
template = f"<h1>Hello {name}!</h1>"
return render_template_string(template)
@app.route('/greet')
def greet():
name = request.args.get('name', '')
return render_template("greet.html", name=name)
@app.route('/preview')
def preview():
tmpl = request.form.get('template')
return Environment().from_string(tmpl).render()
@app.route('/preview')
def preview():
data = request.form.get('data')
return env.get_template("preview.html").render(data=data)
Node.js — EJS
app.get('/render', (req, res) => {
const tmpl = req.query.template;
res.send(ejs.render(tmpl, { user: req.user }));
});
app.get('/render', (req, res) => {
res.render('report', { content: req.query.content });
});
Node.js — Nunjucks
app.post('/preview', (req, res) => {
const output = nunjucks.renderString(req.body.tmpl, { user: req.user });
res.send(output);
});
app.post('/preview', (req, res) => {
res.render('preview.html', { content: req.body.content });
});
Node.js — Handlebars
app.get('/email', (req, res) => {
const template = Handlebars.compile(req.query.tmpl);
res.send(template({ user: req.user }));
});
const template = Handlebars.compile(fs.readFileSync('email.hbs', 'utf8'));
app.get('/email', (req, res) => {
res.send(template({ name: req.query.name }));
});
Ruby — ERB
get '/render' do
tmpl = params[:template]
ERB.new(tmpl).result(binding)
end
get '/render' do
@name = params[:name]
erb :profile
end
Java — FreeMarker
@PostMapping("/preview")
public String preview(@RequestParam String tmplStr, Model model) throws Exception {
Template t = new Template("preview", new StringReader(tmplStr), cfg);
StringWriter out = new StringWriter();
t.process(model.asMap(), out);
return out.toString();
}
@GetMapping("/report")
public String report(@RequestParam String userId, Model model) {
model.addAttribute("user", userService.findById(userId));
return "report";
}
Java — Velocity
public String render(String userTemplate) {
VelocityContext ctx = new VelocityContext();
StringWriter sw = new StringWriter();
Velocity.evaluate(ctx, sw, "template", userTemplate);
return sw.toString();
}
Template t = Velocity.getTemplate("report.vm");
t.merge(ctx, sw);
Java — Thymeleaf (Spring)
@GetMapping("/hello")
public String hello(@RequestParam String lang, Model model) {
return "user/" + lang + "/welcome";
}
private static final Set<String> ALLOWED_LANGS = Set.of("en", "fr", "de");
@GetMapping("/hello")
public String hello(@RequestParam String lang, Model model) {
if (!ALLOWED_LANGS.contains(lang)) return "error";
return "user/" + lang + "/welcome";
}
PHP — Twig
$app->get('/render', function (Request $request) use ($twig) {
$tmpl = $request->query->get('template');
return $twig->createTemplate($tmpl)->render([]);
});
$app->get('/profile', function (Request $request) use ($twig) {
return $twig->render('profile.html.twig', ['name' => $request->query->get('name')]);
});
PHP — Smarty
$template = $_GET['tmpl'];
$smarty->fetch("string:" . $template);
$smarty->assign('name', $_GET['name']);
$smarty->display('profile.tpl');
Go — text/template
func handler(w http.ResponseWriter, r *http.Request) {
tmpl := r.URL.Query().Get("tmpl")
t, _ := template.New("x").Parse(tmpl)
t.Execute(w, data)
}
func handler(w http.ResponseWriter, r *http.Request) {
t := template.Must(template.ParseFiles("tmpl/page.html"))
t.Execute(w, map[string]string{"Name": r.URL.Query().Get("name")})
}
Execution
This skill runs in three phases using subagents. Pass the contents of sast/architecture.md to all subagents as context.
Phase 1: Find Template Rendering Sites Using Dynamic Strings
Launch a subagent with the following instructions:
Goal: Find every location in the codebase where a template engine renders, compiles, or evaluates a dynamically built string as the template itself — rather than loading a static template file. Write results to sast/ssti-recon.md.
Context: You will be given the project's architecture summary. Use it to understand the tech stack, template engines in use, and how views/responses are rendered.
What to search for — vulnerable template rendering patterns:
Flag any call where the first argument (the template string) is a variable, a concatenated string, or any non-literal value. You are not yet checking whether that variable comes from user input — that is Phase 2's job.
- Python — Jinja2 / Flask:
render_template_string(var) — any non-literal argument
Environment().from_string(var) or env.from_string(var)
jinja2.Template(var).render(...)
Template(var) where Template is imported from jinja2
- Python — Mako:
Template(var).render(...) where Template is from mako.template
mako.template.Template(var)
- Node.js — EJS:
ejs.render(var, ...) or ejs.renderFile(var, ...) where var is not a static string literal
- Node.js — Nunjucks:
nunjucks.renderString(var, ...) — any non-literal first argument
env.renderString(var, ...)
- Node.js — Handlebars:
Handlebars.compile(var) — any non-literal argument
Handlebars.precompile(var)
- Node.js — Pug/Jade:
pug.render(var, ...) — any non-literal argument
pug.compile(var, ...)
- Node.js — Lodash/Underscore:
_.template(var) — any non-literal argument
Handlebars.compile(var)
- Node.js — Swig / Twig.js:
After Phase 1: Check for Candidates Before Proceeding
After Phase 1 completes, read sast/ssti-recon.md. If the recon found zero candidate rendering sites (the summary reports "Found 0" or the "Candidate Rendering Sites" section is empty or absent), skip Phase 2 and Phase 3 entirely. Instead, write the following content to sast/ssti-results.md and stop:
# SSTI Analysis Results
No vulnerabilities found.
Only proceed to Phase 2 if Phase 1 found at least one candidate rendering site.
Phase 2: Verify — Trace User Input (Batched)
After Phase 1 completes, read sast/ssti-recon.md and split the candidate rendering sites into batches of up to 3 candidates each. Launch one subagent per batch in parallel. Each subagent traces taint for only its assigned candidates and writes results to its own batch file.
Batching procedure (you, the orchestrator, do this — not a subagent):
- Read
sast/ssti-recon.md and count the numbered candidate sections under "Candidate Rendering Sites" (### 1., ### 2., etc.).
- Divide them into batches of up to 3. For example, 8 candidates → 3 batches (1-3, 4-6, 7-8).
- For each batch, extract the full text of those candidate sections from the recon file.
- Launch all batch subagents in parallel, passing each one only its assigned candidates.
- Each subagent writes to
sast/ssti-batch-N.md where N is the 1-based batch number.
- Identify the project's primary language/framework from
sast/architecture.md and select only the matching examples from the "Vulnerable vs. Secure Examples" section above. For example, if the project uses Python/Flask with Jinja2, include only the "Python — Flask / Jinja2" examples. Include these selected examples in each subagent's instructions where indicated by [TECH-STACK EXAMPLES] below.
Give each batch subagent the following instructions (substitute the batch-specific values):
Goal: For each assigned candidate rendering site, determine whether a user-supplied value reaches the dynamic template string argument. Our goal is to find SSTI vulnerabilities.Write results to sast/ssti-batch-[N].md.
Your assigned candidates (from the recon phase):
[Paste the full text of the assigned candidate sections here, preserving the original numbering]
Context: You will be given the project's architecture summary. Use it to understand request entry points, middleware, and how data flows through the application.
SSTI reference — what to trace:
For each rendering site, trace the dynamic template argument backwards to its origin.
- Direct user input — the argument is assigned directly from a request source with no transformation:
- HTTP query params:
request.GET.get(...), req.query.x, params[:x], $_GET['x'], c.Query("x")
- Path parameters:
request.path_params['id'], req.params.id, params[:id]
- Request body / form fields:
request.POST.get(...), req.body.x, params[:x], $_POST['x']
- HTTP headers:
request.headers.get(...), req.headers['x']
- Cookies:
request.COOKIES.get(...), req.cookies.x
- File upload content: if a file's content is read and passed as the template string
- Indirect user input — the argument is derived from user input through transformations, function calls, or intermediate assignments. Trace the full chain:
- Variable assigned from a function return value → check that function's parameter origin
- Variable passed as a function argument → check the call site(s)
- Variable read from a class attribute or shared state set elsewhere → find the setter
- Variable conditionally assigned — check all branches
- Second-order input — the template string is read from the database, a config store, or a file, but the stored value originally came from user input (e.g., user-submitted "custom email template" feature):
- Find where this value was written — was it stored from a user-supplied field?
Phase 3: Merge — Consolidate Batch Results
After all Phase 2 batch subagents complete, read every sast/ssti-batch-*.md file and merge them into a single sast/ssti-results.md. You (the orchestrator) do this directly — no subagent needed.
Merge procedure:
- Read all
sast/ssti-batch-1.md, sast/ssti-batch-2.md, ... files.
- Collect all findings from each batch file and combine them into one list, preserving the original classification and all detail fields.
- Count totals across all batches for the executive summary.
- Write the merged report to
sast/ssti-results.md using this format:
# SSTI Analysis Results: [Project Name]
## Executive Summary
- Rendering sites analyzed: [total across all batches]
- Vulnerable: [N]
- Likely Vulnerable: [N]
- Not Vulnerable: [N]
- Needs Manual Review: [N]
## Findings
[All findings from all batches, grouped by classification:
VULNERABLE first, then LIKELY VULNERABLE, then NEEDS MANUAL REVIEW, then NOT VULNERABLE.
Preserve every field from the batch results exactly as written.]
- After writing
sast/ssti-results.md, delete all intermediate batch files (sast/ssti-batch-*.md).
Important Reminders
- Read
sast/architecture.md and pass its content to all subagents as context.
- Phase 2 must run AFTER Phase 1 completes — it depends on the recon output.
- Phase 3 must run AFTER all Phase 2 batches complete — it depends on all batch outputs.
- Batch size is 3 candidates per subagent. If there are 1-3 candidates total, use a single subagent. If there are 10, use 4 subagents (3+3+3+1).
- Launch all batch subagents in parallel — do not run them sequentially.
- Each batch subagent receives only its assigned candidates' text from the recon file, not the entire recon file. This keeps each subagent's context small and focused.
- Phase 1 is purely structural: flag any dynamic (non-literal) variable used as the template string argument. Do not attempt to trace user input in Phase 1 — that is Phase 2's job.
- Phase 2 is purely taint analysis: for each site assigned to a batch, trace the dynamic template argument back to its origin. If it comes from a user-controlled source, the site is a real vulnerability.
- The critical distinction is template string vs. template context: user input passed as a variable name/value inside
render_template("page.html", user=input) is safe. User input passed as the template string itself to render_template_string(input) is dangerous.
- Second-order SSTI is easy to miss: a "custom template" feature may let users store Jinja2/Twig syntax in the database. When that stored template is later loaded and rendered server-side without sandboxing, it's SSTI. In Phase 2, treat DB-read template strings as potentially tainted.
- Thymeleaf fragment expressions: in Spring Boot, if a controller returns a view name constructed from user input (e.g.,
return "user/" + lang + "/view"), Thymeleaf may process Spring EL expressions embedded in the path segment, enabling RCE. Flag any controller that builds a view name string using user-supplied values.
- Blocklist filtering is not a mitigation: attempts to strip
{{, }}, <%, %> etc. from user input are routinely bypassed via encoding, alternate syntax, or nested expressions. Do not classify a finding as "Not Vulnerable" solely because filtering is present.
- When in doubt, classify as "Needs Manual Review" rather than "Not Vulnerable". False negatives are worse than false positives in security assessment.
- Include engine-appropriate proof-of-concept payloads for all Vulnerable and Likely Vulnerable findings. Payloads should first test with a math expression (e.g., ) to confirm template execution before escalating to RCE payloads.