When a Send Button Becomes a Trusted Mail Relay
When a Send Button Becomes a Trusted Mail Relay
Hello hackers. This one is a bug I found in a BBP program, it was a hardened program that had very little attack surface and very few endpoints.
What even is Peppol
Peppol is a governed network for exchanging structured business documents, e-invoices, purchase orders, that sort of thing. Businesses connect through certified Access Point providers, and the network handles routing and discovery so two companies on different software can exchange compliant documents without building a custom pipe to every counterparty. The important part: everything Peppol touches carries an implied layer of legitimacy. That reputation bleeds into any platform sitting on top of it.

The four-corner model, published by OpenPeppol.
So when I found a bug that lets any random account use the platform’s send infrastructure, the real sender identity, the branded templates, the SPF/DKIM records, all of that comes along for free.
How I found it
The first useful clue did not come from the target flow directly. I found an online version of the same document-sending experience inside another application connected to the ecosystem. That version exposed more of the client-side flow than the hardened target did, so I treated it like a map.
I started reversing the browser-side behavior: watched the network tab, followed the JavaScript, and compared what changed between upload, draft creation, preview, and send. A few parameters stood out because they were being passed around as client-controlled context rather than being derived fully server side: draft/document identifiers, recipient email fields, message fields, and a company/supplier context value.
The online version made the final send call look roughly like this. I have normalized the names and redacted the host, cookies, and tokens, but the important shape is the same: the client was supplying the draft, recipient, message content, and the company/supplier context used by the send flow.
POST /api/integration/send/<draft-id>/email HTTP/2Host: online-version.exampleCookie: session=<redacted>Content-Type: application/jsonX-CSRF-Token: <redacted>
{ "companyId": "<company-id-from-ui>", "supplierId": "<supplier-id-from-ui>", "documentId": "<draft-id>", "recipientEmail": "controlled-inbox@example.com", "subject": "Invoice document", "message": "Please see the attached document.", "ccSender": true}That gave me enough shape to go back to the main target and try the same sequence with a fresh consumer account, no linked companies, no prior history. First thing I checked was what company context the account actually had:
GET /api/companies{"data":[]}Empty. Fine, expected for a new account. The interesting part was what still worked when I replayed the reversed flow with that missing context. The integration flow lived under /api/integration/*, and the PDF upload plus draft creation endpoints were still accepting requests from my fresh, company-less account. Most apps gate this flow behind a company check: no company, no document, can’t proceed. This one didn’t. The upload went through, a draft got created, and I had an ID.
That was the first real pivot. I did not have a company attached to the account, but I did have a server-side draft generated by the platform. The reversed online flow told me what the next request needed. The target flow told me the fresh account should not be allowed to complete it.
From the online version I already knew the send step expected a company/supplier-ish value. So instead of supplying a valid company ID, I submitted the draft with a deliberately wrong one and pointed the recipient to a controlled inbox.
The normal expected flow looked like this:
flowchart LR A[Fresh account] --> B[Upload PDF] B --> C[Draft created] C --> D[Replay send with wrong company ID] D --> E{Supplier/company validation} E -->|fails| F[404 returned] E -. side effect already happened .-> G[Trusted email delivered]At that point the question was not just “can I create a draft?” It was: does validation happen before the side effect, or only before the response?
The request that shouldn’t have worked
Using the parameters recovered from the online version, I took the draft ID and hit the email send endpoint with a deliberately invalid company value and a controlled inbox I own for testing. This is the request that should not have worked:
POST /api/integration/send/<draft-id>/email HTTP/2Host: target.exampleCookie: session=<fresh-account-session>Content-Type: application/jsonX-CSRF-Token: <redacted>Accept: application/json
{ "companyId": "00000000-0000-0000-0000-000000000404", "supplierId": "<supplier-or-participant-id-from-draft>", "documentId": "<draft-id>", "recipientEmail": "controlled-inbox@example.com", "subject": "Test document", "message": "Testing the delivery path from a fresh account.", "ccSender": true}The mismatch is what mattered. The documentId was real because the platform had already created the draft. The recipient, subject, and body were mine. The companyId was intentionally bogus. If validation happened first, the request should fail quietly with no email, no queue entry, no state change.
The response came back:
{ "message": "Supplier not found", "code": "VALIDATION_ERROR", "status": 404}Classic. Looks like a failure, logs as a failure, probably gets ignored as a failure. But I had the controlled inbox open in another tab. An email had arrived anyway. The response said the supplier/company was not found, but the mail had already gone out with the real sender address, my subject, my body, my uploaded PDF as an attachment, and a copy to my own mailbox because the platform CC’s the sender on outbound documents. All from the platform’s real infrastructure.
The draft state also quietly updated to EMAIL_SENT. The API said 404, but every actual side effect of a successful send had already happened.
| Field | Result |
|---|---|
| Recipient | Controlled by the caller |
| Subject | Controlled by the caller |
| Body | Controlled by the caller |
| Attachment | Controlled by the uploaded PDF |
| Sender | Real platform address |
The actual failure
The root problem is ordering and trust in client-supplied workflow context. The send path accepted enough parameters from the client to reach the mail-sending branch, queued or triggered the outbound email, and only then surfaced the invalid company/supplier state as a 404. Validation existed, but it did not protect the side effect.
A safe implementation would look like this instead:
flowchart TD R[Request] --> A[Authorize actor and tenant] A --> O[Load server-side draft] O --> V[Validate company, supplier, state, recipient] V -->|invalid| X[Reject, no side effect created] V -->|valid| Q[Queue message] Q --> S[Mark draft sent after provider accepts]The email should never be queued until validation passes. The draft should never flip to EMAIL_SENT until the provider confirms. Client-provided company, supplier, recipient, and draft context should be treated as hints at most; the server needs to reload and authorize the real relationship before doing anything irreversible.
The most important test to run: if the API returns an error, can I prove no email, webhook, or queue record was created? If the answer is no, the workflow has a trust-boundary problem.
How to find this pattern yourself
This bug pattern shows up anywhere an application turns a user action into a trusted outbound operation. You don’t need an e-invoicing platform. Look for flows that end in a real-world side effect the platform’s brand is attached to: transactional email, e-signature envelopes, payment initiation, webhooks to third parties, government submissions.
The question is always the same: what server-side fact makes this send legitimate, and is that fact checked before the side effect?
Start passive. Search loaded scripts, form actions, and network traffic for terms like send, submit, notify, invoice, supplier, webhook. If there is another online version, mobile web flow, partner portal, or embedded version of the same feature, compare it against the hardened app. Less polished sibling apps often expose the parameter names and request order more clearly. A match is just a lead. This console snippet does it without sending any requests:
(() => { const re = /peppol|openpeppol|e[-_ ]?invoice|access[ -]?point|participant|supplier|webhook|send(invoice|document|email)/i; const values = [ ...[...document.scripts].map((s) => s.src), ...[...document.forms].map((f) => f.action), ...performance.getEntriesByType("resource").map((e) => e.name), ]; console.table(values.filter((v) => re.test(v)).map((v) => ({ value: v }))); console.info("Matches are leads only. Verify in an authorized test environment.");})();Once you find a send-type flow, step through the happy path, then replay the send with an invalid or missing prerequisite. Remove the company ID, use a nonexistent supplier, change the company context, skip the draft step entirely, or reuse parameters learned from another version of the app. Check both the HTTP response and the actual side effect separately. A 404 doesn’t mean the email didn’t go out.
What I check now after every send button
Trusted infrastructure amplifies small logic mistakes. A missing sender check becomes a branded email relay. A missing state check becomes a premature invoice submission. A missing relationship check becomes a notification to someone who was never supposed to receive it.
Whenever I see a send, submit, notify, or sign endpoint now, I ask one question: can I prove the server checked the legitimating fact before the side effect happened? Not assume, prove. The HTTP response and the side effect are two different things, and in bugs like this, they disagree.
Let me know what you think. Discord is open.
← Back to writeups