{"slug": "the-password-reset-email-was-real-the-destination-wasn-t", "title": "The Password Reset Email Was Real. The Destination Wasn't.", "summary": "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.", "body_md": "The application sends the password-reset email. Its own template. Its own delivery system.\n\nThe link carries a genuine reset token.\n\nBut the destination belongs to someone else.\n\nThat is the unsettling part of password-reset poisoning: the application can assemble and deliver the dangerous message itself.\n\nCoolify'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.\n\nThe 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.\n\nThe 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).\n\nHere is a separate, deliberately small JavaScript model. All domains and tokens are placeholders. It sends no requests or emails.\n\n``` js\nfunction buildLink(origin, token) {\n  const link = new URL('/reset-password', origin);\n  link.searchParams.set('token', token);\n  return link.href;\n}\n\nfunction vulnerable(headers, token) {\n  const host = headers['x-forwarded-host'] || headers.host;\n  return buildLink(`https://${host}`, token);\n}\n```\n\nNow supply these inputs:\n\n``` js\nconst headers = {\n  host: 'app.example',\n  'x-forwarded-host': 'collector.example'\n};\n```\n\nThe token is `DEMO_ONLY`. Which server receives it if someone follows the resulting URL?\n\nThe answer comes from the first line inside `vulnerable`. The forwarded value wins. The resulting link is:\n\n```\nhttps://collector.example/reset-password?token=DEMO_ONLY\n```\n\nNotice 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.\n\nThe mistake happened before any of those checks could help: request metadata was allowed to choose the destination for a credential.\n\nSuppose you delete support for `x-forwarded-host` and use only `headers.host`.\n\nIn this model, the caller still controls `headers.host`. You have removed one route to the wrong destination while leaving another.\n\nFor this single-origin example, the repaired function takes its origin from deployment configuration:\n\n``` js\nconst APP_ORIGIN = 'https://app.example';\n\nfunction repaired(_headers, token) {\n  return buildLink(APP_ORIGIN, token);\n}\n```\n\nThis 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).\n\nThe 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.\n\nThe offline model was run on Node.js 24.18.0. It produced these results:\n\n| Input | Original destination | Repaired destination | \n|---|---|---|\n| Ordinary host | `app.example` | `app.example` | \n| Forged forwarded host | `collector.example` | `app.example` | \n| Forged Host | `collector.example` | `app.example` | \n\nTwelve 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.\n\nThose 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.\n\nMy 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.\n\nWhen a test says “reset email sent,” what exactly did it verify?\n\nDelivery is one assertion. The destination is another. A test can prove the first while completely missing the second.\n\nI 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.\n\n**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.\n\n**What does your password-reset test assert about the actual link in the email?**", "url": "https://wpnews.pro/news/the-password-reset-email-was-real-the-destination-wasn-t", "canonical_source": "https://dev.to/ookeolioli222/the-password-reset-email-was-real-the-destination-wasnt-568a", "published_at": "2026-09-29 20:34:44+00:00", "updated_at": "2026-09-29 20:46:44.105185+00:00", "lang": "en", "topics": ["ai-safety", "developer-tools"], "entities": ["Coolify", "bugbunny.ai", "OWASP", "Node.js", "GitHub"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/the-password-reset-email-was-real-the-destination-wasn-t", "markdown": "https://wpnews.pro/news/the-password-reset-email-was-real-the-destination-wasn-t.md", "text": "https://wpnews.pro/news/the-password-reset-email-was-real-the-destination-wasn-t.txt", "jsonld": "https://wpnews.pro/news/the-password-reset-email-was-real-the-destination-wasn-t.jsonld"}}