cd /news/ai-safety/the-password-reset-email-was-real-th… · home › topics › ai-safety › article
[ARTICLE · art-142044] src=dev.to ↗ pub= topic=ai-safety verified=true sentiment=· neutral

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

A security analysis of Coolify's password-reset poisoning vulnerability (GHSA-cgj8-7m5q-x5gv) shows how a chain of trusted forwarded headers, a host-validation cache bug, and a request-derived reset URL let an attacker redirect a genuine reset token to an attacker-controlled host. The advisory credits bugbunny.ai and lists v4.0.0-beta.471 as patched, and a small offline JavaScript model demonstrates that deriving reset destinations from the Host or X-Forwarded-Host header sends the token to the forged host, while a fixed configured origin prevents it.

by read4 min views2 publishedSep 29, 2026

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.

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

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:

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:

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.

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, 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?

── more in #ai-safety 4 stories · sorted by recency
── more on @coolify 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/the-password-reset-e…] indexed:0 read:4min 2026-09-29 · —