# The Password Reset Email Was Real. The Destination Wasn't.

> Source: <https://dev.to/ookeolioli222/the-password-reset-email-was-real-the-destination-wasnt-568a>
> Published: 2026-09-29 20:34:44+00:00

The application sends the password-reset email. Its own template. Its own delivery system.

The link carries a genuine reset token.

But the destination belongs to someone else.

That is the unsettling part of password-reset poisoning: the application can assemble and deliver the dangerous message itself.

Coolify's advisory describes a chain involving trusted forwarded headers, a host-validation cache bug, and a reset URL derived from the request. On an empty cache, validation was skipped; the code that would populate that cache sat behind the skipped path.

The reported attack required the forged header to reach the application and the recipient to click the link. It was not an automatic compromise of every deployment.

The advisory credits bugbunny.ai and lists `v4.0.0-beta.471` as patched. This is an analysis of that disclosure, not a claim of discovering or reproducing the Coolify vulnerability. [Read the original advisory](https://github.com/coollabsio/coolify/security/advisories/GHSA-cgj8-7m5q-x5gv).

Here is a separate, deliberately small JavaScript model. All domains and tokens are placeholders. It sends no requests or emails.

``` js
function buildLink(origin, token) {
  const link = new URL('/reset-password', origin);
  link.searchParams.set('token', token);
  return link.href;
}

function vulnerable(headers, token) {
  const host = headers['x-forwarded-host'] || headers.host;
  return buildLink(`https://${host}`, token);
}
```

Now supply these inputs:

``` js
const headers = {
  host: 'app.example',
  'x-forwarded-host': 'collector.example'
};
```

The token is `DEMO_ONLY`. Which server receives it if someone follows the resulting URL?

The answer comes from the first line inside `vulnerable`. The forwarded value wins. The resulting link is:

```
https://collector.example/reset-password?token=DEMO_ONLY
```

Notice how little is wrong with this URL as a string. It parses. It uses HTTPS. It has the expected path. The token is correctly encoded.

The mistake happened before any of those checks could help: request metadata was allowed to choose the destination for a credential.

Suppose you delete support for `x-forwarded-host` and use only `headers.host`.

In this model, the caller still controls `headers.host`. You have removed one route to the wrong destination while leaving another.

For this single-origin example, the repaired function takes its origin from deployment configuration:

``` js
const APP_ORIGIN = 'https://app.example';

function repaired(_headers, token) {
  return buildLink(APP_ORIGIN, token);
}
```

This matches the principle in OWASP's reset-password guidance: do not derive reset destinations from an untrusted Host header; use a fixed destination or validate against trusted domains. [OWASP guidance](https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html).

The constant here represents configuration controlled by the operator. It must not be another name for user input. A product supporting multiple customer domains needs an explicit, verified mapping rather than this single-origin shortcut.

The offline model was run on Node.js 24.18.0. It produced these results:

| Input | Original destination | Repaired destination | 
|---|---|---|
| Ordinary host | `app.example` | `app.example` | 
| Forged forwarded host | `collector.example` | `app.example` | 
| Forged Host | `collector.example` | `app.example` | 

Twelve assertions passed. They checked the repaired origin, path and token across the three cases, confirmed the two vulnerable destinations, and checked a token containing reserved characters.

Those results establish the behavior of these small functions. They do not establish how a deployed proxy rewrites headers, whether a framework rejects the request, or whether a complete reset flow is secure.

My next integration test would inspect the link in a captured email after a request travels through the application's actual proxy stack. I would repeat it with a cold cache. That is a proposed next test, not a result reported here.

When a test says “reset email sent,” what exactly did it verify?

Delivery is one assertion. The destination is another. A test can prove the first while completely missing the second.

I would ask the reviewer to trace the origin backwards: from the email template, through the URL builder, to whichever input selected it. That turns a vague question about whether the flow looks secure into a specific question about who controls one consequential value.

**Disclosure:** I am building [Breachloom](https://breachloom.com/?lang=en&utm_source=devto&utm_medium=article&utm_campaign=reset_destination), a browser-based security practice platform. If you want to practice investigating failures and repairing vulnerable code, start with its free exercise. If that format suits you, consider PRO for continued practice. This article's model is a standalone example, not a claim that the Coolify case is available as a Breachloom mission.

**What does your password-reset test assert about the actual link in the email?**
