Password reset works because the secret travels out of band. You ask the server to reset, the server emails a token to the inbox, and the in-band response says nothing useful. The caller has to read the inbox to finish the flow. That is the entire security model. Flowise's enterprise account service did not enforce it.
CVE-2025-58434: Flowise Sent the Reset Token to the Inbox. It Also Sent It to the Caller.
patterns
cve
proof of concept
- SteamPunk424/CVE-2025-58434-Unauthenticated-Password-Reset-Flowwise%20(%E2%98%850)
- mananispiwpiw/CVE-2025-58434-PoC%20(%E2%98%850)
- 0xDaeras/Flowise-CVE-2025-58434-Chain-59528%20(%E2%98%851)
- v3rycl0p3r/CVE-PoC-CVE-2025-58434%20(%E2%98%850)
- vincent-vbg/CVE-2025-58434-PoC%20(%E2%98%850)
- 00lucasm/CVE-2025-58434-Flowiseai-Auth-Bypass-PoC%20(%E2%98%850)
- r3vpwnx/CVE-2025-58434%20(%E2%98%850)
The model
Forgot-password endpoints are the textbook example of a side channel doing real work. The HTTP caller asks for a reset; the server sends a one-time secret to a recipient address it already trusts (an inbox the legitimate user controls); the HTTP response says nothing useful. Anyone who can observe that response is told only that the request was accepted. To complete the reset they have to read the inbox.
This is not authorization in the bearer-token sense. The inbox itself is the credential. The endpoint trades an unauthenticated request for a delivery to the trusted address; the second leg of the flow (POST a new password with the token) is what actually proves the caller controls that address. The whole construction collapses if the token comes back to the caller in the same response.
That is the failure mode here. CVE-2025-58434 is one line in forgotPassword that returned the wrong object.
The function
Flowise is an open-source builder for LLM applications. The enterprise plan ships an account service at packages/server/src/enterprise/services/account.service.ts. In version 3.0.5, released August 2025, that service exposed a forgotPassword method whose final operations told the whole story.
The data argument is an AccountDTO carrying a partial User parsed from the request body. The service hydrates that user from the database, mints a temporary token, persists it back to the user row, builds a reset link from process.env.APP_URL and the token, and emails it.
data.user = user
data.user.tempToken = generateTempToken()
data.user.tokenExpiry = ...
data.user = await this.userService.saveUser(data.user, queryRunner)
const resetLink = `${process.env.APP_URL}/reset-password?token=${data.user.tempToken}`
await sendPasswordResetEmail(data.user.email!, resetLink)
...
return datasaveUser is a manager.save wrapper, so the assignment back to data.user rehydrates the local variable with the full row TypeORM persisted. Every column comes back: email, name, tempToken, tokenExpiry, and credential (the bcrypt hash of the current password).
sendPasswordResetEmail uses that data correctly. It plucks data.user.email and the freshly built reset link and hands them to the SMTP layer. The token leaves over the trusted side channel.
Then return data. The route is POST /api/v1/account/forgot-password, registered in account.route.ts without any auth middleware:
router.post('/forgot-password', accountController.forgotPassword)The controller in account.controller.ts does what every other controller in this service does:
const data = await accountService.forgotPassword(req.body)
return res.status(StatusCodes.CREATED).json(data)res.json(data) walks the AccountDTO and serializes everything reachable. The full User row, including tempToken, tokenExpiry, and the credential bcrypt hash, lands in the HTTP response to the unauthenticated caller. The token Flowise just emailed to the trusted address is also in the body the caller already has.
The second leg of the reset flow does not require a session. It requires the email and the token. That was always by design; the email was supposed to be the credential. The PoCs use the response token directly: step one POSTs to /forgot-password and parses tempToken out of the JSON body, step two POSTs to /reset-password with email plus token plus a chosen password. Two unauthenticated requests, account takeover for any address. No mailbox access required.
The patch
The fix landed in pull request #5167, "Secure password reset endpoints," merged September 4, 2025 by Ong Chung Yau. The diff against forgotPassword is one line:
- return data
+ return sanitizeUser(data.user)sanitizeUser was added in the same PR, in packages/server/src/utils/sanitize.util.ts:
export function sanitizeUser(user: Partial<User>) {
delete user.credential
delete user.tempToken
delete user.tokenExpiry
return user
}Three deletes. The function strips the three User columns that should never make it across the in-band boundary: the bcrypt hash, the active password-reset token, and its expiry. The same helper now runs at every other boundary in the account service that previously returned a User: register, verify, resendVerificationEmail, confirmEmailChange, resetPassword. The PR is small and precise.
GHSA-wgpv-6j63-x5ph published September 12, 2025. v3.0.6 tagged September 15, 2025. CVSS 9.8 critical.
A second fix landed six months later, in PR #5913 by Christopher Holland of Workday, hardening resetPassword against an empty-string tempToken comparison bypass. That one is its own story; the relevant point is that the password-reset surface kept producing bugs for a year after the original ship date. The function is small. The blast radius is total.
What it costs to write the bug
Look at the vulnerable function as a code review exercise. There is one local variable, data, of type AccountDTO. It feeds two consumers in the last six lines:
sendPasswordResetEmail(data.user.email!, resetLink). Out of band. Trusted recipient.return data. In band. Caller-supplied recipient. Pipes intores.json.
The function did not distinguish between the two recipients.
That is the entire bug. Not a parsing issue, not a missing input check, not a race. The same object was handed to a privileged delivery channel and to an unauthenticated network response. The privileged channel used the right field. The network response serialized everything else along with it.
This shape is why sanitizeUser works as a one-line fix. The sanitizer does not change what is computed, what is persisted, or what is emailed. It changes what the function says when its caller asks "what did you do?" That answer was being marshaled into HTTP and the answer was too detailed.
It is also why the fix is a thin patch over a deeper failure mode. sanitizeUser is a denylist: three columns the author remembered to scrub. Add a fourth sensitive column to the User entity tomorrow (a webhook signing secret, an OAuth refresh token, an MFA seed) and sanitizeUser keeps returning it until someone updates the helper. The deeper question is why the in-band response is a User entity at all, instead of a typed view object with only the fields the caller is allowed to see. The ORM happily round-trips full rows. The serializer happily emits whatever is reachable. The contract between the service and the controller does not exist as a type.
The pattern
There is an editorial frame for this. The reset flow's security depends on a strict separation between two channels: out of band (email to the trusted address) and in band (HTTP response to the unauthenticated caller). The secret travels on exactly one of them. When a single mutable object feeds both consumers and nobody flags the conflation, the channels collapse. The "out of band only" promise becomes "out of band, also in band."
Coin it: out-of-band also in-band. The pattern is not specific to password reset. Email-confirmation tokens, MFA backup codes generated server-side, signed magic-link parameters, OTP seeds for QR provisioning, account-recovery codes returned at signup, any flow whose security model is "the secret rides the side channel, the response says nothing." Every one of those flows has a service function that ends with return X where X is the object that also got handed to the side-channel sender. Every one of them is one accidental serializer away from this CVE.
The parent pattern is trust inversion. Flowise's reset emailer is a privileged sender. It holds SMTP credentials, it speaks to a trusted address, it carries a fresh secret. The controller that calls it is unprivileged: it speaks to whoever knocked on the door. When the same object travels through both, the privileged sender's secret rides on the unprivileged channel. The tool that authorizes the user (the email delivery) becomes the surface that bypasses the user (the JSON response). That is the trust inversion. out-of-band-also-in-band names the specific mechanism by which it happened.
There is also a smaller meta-pattern about the fix shape. sanitizeUser is a stand-in for response DTOs without the type system to back it. TypeORM and class-validator both support DTO mapping; Flowise's account service does not use either at this layer. The "fix" is a runtime delete on the entity, mutating it before serialization. It works, and it is the right fix to ship in the patch window, but it leaves the function's signature pretending the boundary is between an in-band and an out-of-band User, when the actual boundary should be between a User and a UserResponse. Until that second type exists, every new column on User is a CVE-shaped opportunity.
What this looked like in the wild
The GHSA shipped September 12, 2025. The first public PoC dropped April 20, 2026, by SteamPunk424, seven months later. A second PoC by mananispiwpiw, crediting Isaac David and Arthur Gervais, dropped May 1, 2026. Both PoCs are simple Python scripts that prompt for a target host and an email, send the two requests, and print the new password. No obfuscation, no exploitation chain, no opsec layer. The README of the second PoC even includes the equivalent curl invocation, in case the reader wanted the request shaped without Python in the loop.
The seven-month gap between patch and PoC is the interesting telemetry. Most enterprise Flowise deployments will have rolled past 3.0.5 by now, either through the September 3.0.6 release or one of the later 3.x bumps. The exposed surface is the long tail of self-hosted instances where someone deployed during the 3.0.5 window and never upgraded. Public PoCs at this stage are aimed at internet-scan operators, not zero-day buyers. The CVE is patched, the patch is clean, the PoCs are public but late, and the population is the long tail.
That is the right priority for an editorial post. Not "this is on fire" but "this is the shape of a bug that ships in every framework that returns its ORM rows by default."
What to look at in your own code
If you maintain a service that does anything resembling password reset, MFA enrollment, magic links, or account recovery, the audit is mechanical. Find the function that mints the secret. Look at its return statement. Check what serializer the caller hands the return value to. If the same object that gets passed to the email sender is the object that gets passed to res.json, you have the Flowise bug.
The fix has two flavors. Short term, a sanitizer that strips the secret-bearing columns before the in-band response. Long term, a typed UserResponse (or whatever the resource is) so the function signature itself rejects the conflation. The short-term fix patches the instance. The long-term fix patches the design. The patch worth shipping does both.
The email arrived. So did everything else.