Wrap It in a Dict and the Gate Opens: Crawl4AI’s CVE-2026-109237 Reads the Server’s Secrets

NVD published CVE-2026-109237 at 17:17Z on 12 October 2026 against Crawl4AI, the Apache-2.0 crawler-for-LLMs that sits underneath a very large number of RAG and agent ingestion pipelines — 85,428 stars, 8,869 forks, pushed again this morning. The record is VulnCheck’s, scored 8.6 high on CVSS 4.0 (AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:N) and 8.1 on CVSS 3.1, CWE-502, affecting 0.9.0 before 0.9.4. The one-line version: Crawl4AI’s Docker API server has a type gate that stops an untrusted request body from constructing dangerous config objects, and you get past it by putting the object inside a dictionary.

This is one of the better-written advisories of the month, and it is worth saying why before anything else. GHSA-5w5p-vcv6-mm3f was published on 23 September 2026 — the same day v0.9.4 shipped to GitHub (12:14Z) and PyPI (12:14Z), with the advisory following at 15:02Z. It names file, function and line for both defects, states the escalation, states the limits of the escalation, gives a workaround, and credits the reporter, Adam Jordan (adamyordan), for “a precise source-level root-cause analysis and a working proof of concept.” We checked every structural claim in it against the source at both tags. All of them hold.

Two defects, one payload

The gate is real and it was designed carefully. crawl4ai/async_configs.py at v0.9.3 defines a Provenance enum (line 166) distinguishing TRUSTED (“SDK / in-process construction”) from UNTRUSTED (“a network request body; gated by the allowlists below”). UNTRUSTED_ALLOWED_TYPES (line 187) enumerates roughly twenty classes a request body may build — the crawler configs, the non-LLM extraction strategies, the safe scalar enums. LLMConfig is deliberately not among them, and LLMConfig.__init__ carries a comment saying so in plain language: untrusted callers must “never resolve env vars or read provider keys from the environment — that is the credential-exfil gadget.” Under UNTRUSTED it raises on an api_token that starts with env:; under TRUSTED it runs os.getenv(api_token[4:]). There is also a field filter (UNTRUSTED_GLOBAL_FORBIDDEN_FIELDS, line 233) blocking filesystem and code-execution sinks, and a clamp for attacker-influenced quantities. Somebody thought about this.

The first defect is that the gate is applied to typed objects, and one branch of the deserializer turns a typed object into something that is no longer one. At async_configs.py:469:

if data["type"] == "dict" and "value" in data:
    return {k: from_serializable_dict(v, provenance) for k, v in data["value"].items()}

Note that provenance is threaded through the recursion. That is not the bug. The bug is .items(). Given {"type": "dict", "value": {"type": "LLMConfig", "params": {…}}}, the comprehension iterates the two keys of the inner object — "type" (a string, returned as-is) and "params" (a plain dict) — and returns an ordinary Python dict that happens to look exactly like a serialized LLMConfig. The inner object is never presented to the type check twelve lines below as a whole, so the check never fires. Sent directly, the same object is refused.

The second defect is where the laundered dict lands. BrowserConfig.from_kwargs (line 980) and CrawlerRunConfig.from_kwargs (line 2112) both contain this, identically:

kwargs = {
    k: from_serializable_dict(v) if isinstance(v, dict) and "type" in v else v
    for k, v in kwargs.items()
}

No provenance argument. And from_serializable_dict opens with if provenance is None: provenance = Provenance.TRUSTED (lines 445-446). So the dict that came out of the laundering step — still carrying "type": "LLMConfig" — is re-deserialized a second time, this time as trusted, and the forbidden class is constructed with full environment access. _filter_untrusted_fields cannot save it: the function inspects params.items() of the object it is handed, and at the moment of laundering the object it would need to inspect is not an object.

Two defaults doing exactly what they were written to do, meeting in the middle. Neither line is wrong on its own reading. from_kwargs is an SDK constructor and defaulting to trusted is correct for an SDK constructor; the dict branch recursing with the caller’s provenance is correct for a dict. The hole is the composition, which is the part no single code review looks at.

What the attacker gets

The advisory is specific about reachability and we repeat it as reported: the path is live from /config/dump, /crawl, /crawl/stream, /md and the job router, all of which call load(…, provenance=UNTRUSTED), and /config/dump echoes the resolved value straight back to the caller. Each gated object has to be wrapped individually — the reporter’s payload wraps both the LLMExtractionStrategy and the inner LLMConfig.

The impact chain, again as reported and not reproduced by us: an ordinary non-admin caller reads any server environment variable, which means provider keys (OPENAI_API_KEY, ANTHROPIC_API_KEY) and CRAWL4AI_API_TOKEN itself — so one leaked low-tier token yields the static credential every caller shares. Where SECRET_KEY is set in the environment, which is the documented deployment, the recovered value forges a JWT that passes the admin-scope check; the reporter verified this against POST /monitor/stats/reset. The advisory adds the detail that matters for incident response: rotating the API token does not revoke that forged JWT, only rotating SECRET_KEY does. The same wrapper also builds otherwise-permitted types with forbidden fields — the reporter confirmed PDFContentScrapingStrategy with image_save_dir, an arbitrary-path image write.

The privilege boundary being crossed is not theoretical in an agent deployment, and the advisory says why: the MCP bridge mints the server’s non-admin data scope for every tool call. Any agent that can reach a Crawl4AI MCP endpoint holds exactly the privilege level this bug escalates from. The 4.0 vector’s PR:L is doing less work than it looks like it is doing.

Credit where the advisory earns it again: it also states the condition that reduces severity. If SECRET_KEY is unset, the per-process ephemeral value never enters the environment and the admin-forgery escalation disappears, leaving disclosure of whatever else is set. Advisories that volunteer their own limiting conditions are rarer than they should be.

The fix, and why it is the right one

We read v0.9.4 and main HEAD. Both defects are closed, and closed at the level they live at rather than patched at the symptom.

The dict branch now materialises the unwrapped value and re-inspects it before returning (lines 533-547), with a new _is_typed_shape helper and a comment that explains the attack to the next reader rather than just the fix:

if provenance == Provenance.UNTRUSTED and _is_typed_shape(unwrapped):
    raise UntrustedConfigError(
        "an untrusted request may not wrap a typed object in "
        "{'type': 'dict', 'value': ...}"
    )

And both from_kwargs implementations now take provenance as a parameter and thread it through (lines 1054-1062 and 2187-2195), so the second deserialization can no longer silently upgrade to trusted. That second change is the structural one: it removes the class of bug, not the instance. Any future laundering trick that gets a typed shape into from_kwargs now hits a gate instead of a default.

The general lesson is one this site has now written three ways in a fortnight, and it keeps arriving as a composition failure rather than a missing check. nginx-ui’s config validator was bypassed through a second interface that did not call it; Continue CLI’s rule-writer split commands on separators while its rule-checker regexed the whole string; here, the gate and the constructor disagree about what counts as a typed object. The audit that falls out is mechanical and worth running on anything that deserializes a request body into live objects: enumerate every path by which attacker data reaches your constructor, and show that each one carries the trust label. A trust parameter that defaults to trusted is a trust parameter that will eventually be omitted.

What to do

  • Upgrade to crawl4ai 0.9.4 or later. It is the current PyPI release (published 23 September 2026) and there is nothing newer to wait for. Versions 0.9.0 through 0.9.3 are affected; 0.9.0 shipped 18 June, so the window is roughly three months wide.
  • Treat this as a credential incident, not just a patch, if you exposed the API server. Rotate provider keys, rotate CRAWL4AI_API_TOKEN, and — this is the one people will miss — rotate SECRET_KEY. Rotating the API token alone leaves any forged admin JWT valid.
  • Apply the advisory’s workaround as defence in depth even after upgrading. Do not put secrets the crawler does not need into the server’s environment, and consider leaving SECRET_KEY unset so the ephemeral per-process value is used. Put the API behind an authenticating proxy.
  • If an agent reaches this server over MCP, scope it. The MCP bridge hands every tool call the non-admin data scope, which is the exact starting privilege this bug escalates from. A prompt-injected agent is a non-admin API client.

Verification note. The CVE identifier, publication timestamp (17:17:14Z, 12 October 2026), vulnStatus: Received, both CVSS vectors and base scores, CWE-502, the assigning CNA (disclosure@vulncheck.com) and the affected/fixed version ranges were read first-hand from the NVD 2.0 API. The advisory summary, details, impact, patch and workaround sections and the reporter credit were read first-hand in full from GHSA-5w5p-vcv6-mm3f via the GitHub API and are quoted or attributed as the maintainers’ and reporter’s statements, not as our findings. Independently, we fetched crawl4ai/async_configs.py at tags v0.9.3 and v0.9.4 and at main HEAD and read the cited constructs directly: the Provenance enum, UNTRUSTED_ALLOWED_TYPES, UNTRUSTED_GLOBAL_FORBIDDEN_FIELDS, _filter_untrusted_fields, the LLMConfig env: handling, the dict-unwrap branch, and both from_kwargs implementations before and after. All line numbers given are the ones we read at those tags; all code excerpts are quoted verbatim. Release dates, tag list, star/fork/push counts and PyPI publication times were read first-hand from the GitHub and PyPI APIs. We did not install Crawl4AI, start the Docker API server, construct the laundering payload, or send a request to any deployment; the reachability, admin-JWT forgery and arbitrary image-write claims are the reporter’s, verified by them and not by us. The composition-failure reading and the cross-advisory comparison are our editorial analysis. We have no evidence of exploitation and imply none.

Sources: