Feb 21, 2026·5 min read·6 visits
The Lettermint SDK failed to reset its internal state after sending an email. If you reused the client instance (a common pattern in Node.js), the attachments and metadata from Email A would be silently attached to Email B. Fixed in v1.5.1.
A state management vulnerability in the Lettermint Node.js SDK allows sensitive email data to persist across transactions when the client is reused. Due to an implementation flaw in the fluent interface design, properties like attachments, CCs, and headers were not cleared after sending, causing them to bleed into subsequent email requests. This effectively turns a shared client instance into a data leakage hose.
Developers love fluent interfaces. You know the type: object.doThis().thenThat().finalize(). It reads like English, looks clean in a pull request, and makes us feel like sophisticated architects. But in the world of synchronous state management, fluent interfaces are often a concealed bear trap waiting to snap on your ankle.
In the case of CVE-2026-27492, the lettermint-node SDK fell victim to this exact pattern. The library was designed to let developers build emails incrementally. You create a client, you add a recipient, you attach a file, and you hit send. It’s elegant, until you realize how the sausage is made underneath.
The vulnerability isn't a buffer overflow or a deserialization gadget. It's a logic error born from a fundamental misunderstanding of object lifecycles in a long-running Node.js process. When you treat a stateful object (the email builder) as a stateless service (the API client), things get messy fast.
Imagine a restaurant where the busboy never clears the table. The first customer leaves their half-eaten burger and a signed credit card receipt. The second customer sits down, orders a salad, and unwittingly inherits the burger and the credit card details. This is exactly what was happening inside lettermint-node.
The EmailEndpoint class maintained a persistent internal object called this.payload. When you called methods like .to() or .attach(), it modified this internal state. Crucially, the .send() method—the logical conclusion of the transaction—did not wipe the slate clean.
If a developer instantiated the client once (globally) and reused it for performance reasons—a standard best practice in Node.js to manage connection pooling—the SDK became a toxic cache. The payload object would simply keep accumulating data. A 'Password Reset' email sent to User A might unknowingly include the 'Invoice.pdf' attached to the previous email sent to User B. It wasn't just leaking metadata; it was cross-contaminating entire data streams.
Let's look at the smoking gun. The vulnerable code relied on a persistent mutation of this.payload. Here is a simplified view of the architecture prior to version 1.5.1:
// Vulnerable Logic
class EmailEndpoint {
constructor() {
// this.payload is created once per instance
this.payload = {};
}
attach(file) {
this.payload.attachments.push(file);
return this;
}
async send() {
// Sends the current state of payload
await httpClient.post('/send', this.payload);
// ERROR: The payload is never cleared here!
}
}The fix seems obvious in hindsight, but the implementation details matter. The vendor didn't just need to clear the data; they needed to ensure it was cleared even if the network request failed. If the cleanup only happened on a successful 200 OK, a timeout or 500 error would leave the object dirty, poisoning the retry or the next request.
The patch (Commit 83343f1) introduced a finally block to guarantee sanitation:
// The Fix in v1.5.1
async send() {
try {
return await this.httpClient.post('/send', this.payload);
} finally {
// This runs success or fail
this.reset();
}
}
private reset() {
this.payload = { from: '', to: [], subject: '' };
}This forces a hard reset of the internal state after every attempt, essentially flushing the buffer.
To exploit this, we don't need to send malicious packets. We just need to rely on the target application's laziness. We are looking for a Node.js backend (Express, Fastify, NestJS) where the Lettermint client is defined as a global constant or a singleton service.
Here is how an attacker might verify the leak in a black-box scenario. Suppose the target application sends a confirmation email when you update your profile, and a different email when you request a data export.
But the real danger is Inter-User Leakage. If User A triggers the heavy email, and User B triggers the light email milliseconds later on the same server process, User B receives User A's data.
Here is the part the advisory glosses over: Version 1.5.1 fixes sequential reuse, but it does NOT fix concurrent reuse.
Even with the reset() in the finally block, this SDK is fundamentally thread-unsafe (or rather, "async-unsafe"). Node.js is single-threaded, but it is asynchronous. If an application handles two requests concurrently and awaits I/O (like a database call) while building the email, the operations can interleave.
Consider this flow:
sdk.to('admin').awaits a database query.sdk.to('hacker') on the same instance.sdk.send().Because this.payload is shared, Request A will now send the email to both 'admin' and 'hacker'. The patch cleans up after the send, but it does nothing to prevent race conditions during the build process. The only true fix is to never, ever store request-specific state on a global object.
CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:N/A:N| Product | Affected Versions | Fixed Version |
|---|---|---|
lettermint-node Lettermint | <= 1.5.0 | 1.5.1 |
| Attribute | Detail |
|---|---|
| CWE | CWE-488 (Data Element to Wrong Session) |
| CVSS | 4.7 (Medium) |
| Attack Vector | Local (Context Dependent) |
| Exploit Maturity | PoC Available |
| Authentication | Single Instance Reuse Required |
| Impact | Data Leakage / PII Exposure |
Exposure of Data Element to Wrong Session