A timing defense only protects the branches that reach it.
Identical wording did not mean identical timing.
A password-reset endpoint is meant to reveal nothing about whether an address has an account. Directus equalized the visible response and added a fixed delay so that existing and non-existing accounts would take the same time. The security property is that every path through the handler takes that delay before it returns.
The upstream advisory records a timing-based user-enumeration issue. The delay was real, but it did not cover one branch, so response time—rather than response text—distinguished account states.
One branch returned before the delay ran.
The handler defined a fixed stall measured from the start of the request. A non-existent account stalled before its error, and a valid account with an accepted reset URL stalled before returning. A third branch did not: a valid account combined with a disallowed reset URL threw a payload-validation error between the two stall calls, returning in tens of milliseconds.
That ordering turned the handler into an oracle. Sending a reset request with a deliberately disallowed URL, an attacker observed a fast rejection for an existing account and the full delay for a non-existent one. The difference was large enough to read remotely without precise instrumentation.
- Look up the account
The user is fetched before the URL check.
- Reject a bad URL
The throw happens with no delay applied.
- Fast response leaks state
An existing account returns quickly.
- Check the URL first
Validation runs before the account lookup.
- Shared delay downstream
Remaining branches reach the stall.
- Uniform timing
Account states are indistinguishable.
Conceptual control flow reconstructed from the upstream source. It is not a captured request or a TraceMint execution trace.
Move the URL check ahead of the account lookup.
The upstream change validates the reset URL before the account is fetched. Because the disallowed-URL rejection now happens before any account-dependent work, it no longer distinguishes an existing account from a non-existing one; the account-dependent branches continue to the shared delay.
The fix is small in the source—the same check, relocated—but it changes which branches the delay governs. The invariant that mattered was not the presence of a stall but its coverage of every exit that depends on account state.
if (url && isUrlAllowed(url, env['PASSWORD_RESET_URL_ALLOW_LIST'] as string) === false) {if (url && isUrlAllowed(url, env['PASSWORD_RESET_URL_ALLOW_LIST'] as string) === false) {Selected upstream lines; indentation normalized. Links include surrounding code at immutable revisions.
Enumeration, not account compromise.
The advisory rates the issue Moderate. It identifies affected ranges for both the application and the API package and their patched versions. The disclosed capability is distinguishing whether an address has an account through response timing; it is not by itself account access. Version fields on the summary page follow the reviewed advisory database.
- An unauthenticated caller
- The reset endpoint is reachable without a session.
- A disallowed reset URL
- The crafted request steers execution onto the fast branch.
- Observable timing
- The gap between branches is large enough to measure remotely.
Time every exit, not just the final return.
A regression review sends reset requests for a known-existing and a known-absent address, each with a disallowed URL, and compares response times. A patched build should show no meaningful difference. Trace each throw and early return in the handler and confirm the delay covers it. The scenarios below are suggested review checks, not reported TraceMint test results.
| Scenario | Expected boundary |
|---|---|
| Existing account, valid URL | The response takes the full delay. |
| Non-existing account, valid URL | The response takes the same delay. |
| Existing account, disallowed URL | The rejection no longer returns faster than the other branches. |
| Repeated timing samples | Aggregated timing does not separate the two account states. |
Coverage, not presence, is the property.
For reviewers, a timing defense is a claim about every path, so read each exit and ask whether it reaches the delay. A stall written at the end of a function does not protect a branch that leaves before it. Input validation ordered ahead of state-dependent work is often the simplest way to keep early rejections uniform.
The public advisory credits the TraceMint account as reporter, and the maintainers supplied the remediation. This study analyzes the public advisory and immutable source revisions; it does not establish a private discovery sequence or access to any deployment.
Follow the evidence.
Public upstream records and immutable code revisions, reviewed 2026-09-22.
- Directus security advisory
Impact, affected and patched versions, and reporter credit.
- Reset handler before the fix
Immutable parent revision · ea238b16 · URL check after the account lookup.
- Upstream corrective commit
e69aa7a5 · check URL validity before the account lookup (#26485).
- Reset handler after the fix
URL validation runs before any account-dependent work.
Brief code excerpts from directus/directus. Upstream license: Business Source License 1.1. All remediation described here is upstream work.
Read the original record
This case study analyzes an already-public advisory and upstream source. For the complete impact, remediation and credit record, see the Directus security advisory.
Editorial publication is separate from disclosure. No private discovery timeline or method has been inferred.