The security invariant

A timing defense only protects the branches that reach it.

01 / The boundary

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.

02 / Request path

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.

Every exit must pass the delay
Before the fix
  1. Look up the account

    The user is fetched before the URL check.

  2. Reject a bad URL

    The throw happens with no delay applied.

  3. Fast response leaks state

    An existing account returns quickly.

After the fix
  1. Check the URL first

    Validation runs before the account lookup.

  2. Shared delay downstream

    Remaining branches reach the stall.

  3. 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.

03 / Source & fix

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.

Before · URL check after the lookup, no delaySource
if (url && isUrlAllowed(url, env['PASSWORD_RESET_URL_ALLOW_LIST'] as string) === false) {
api/src/services/users.tsea238b16
After · URL check before the lookupSource
if (url && isUrlAllowed(url, env['PASSWORD_RESET_URL_ALLOW_LIST'] as string) === false) {
api/src/services/users.tse69aa7a5

Selected upstream lines; indentation normalized. Links include surrounding code at immutable revisions.

04 / Impact & conditions

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.
05 / Review & regression

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.

Suggested regression checks
ScenarioExpected boundary
Existing account, valid URLThe response takes the full delay.
Non-existing account, valid URLThe response takes the same delay.
Existing account, disallowed URLThe rejection no longer returns faster than the other branches.
Repeated timing samplesAggregated timing does not separate the two account states.
06 / Lessons & limits

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.

Source record

Follow the evidence.

Public upstream records and immutable code revisions, reviewed 2026-09-22.

  1. Directus security advisory

    Impact, affected and patched versions, and reporter credit.

  2. Reset handler before the fix

    Immutable parent revision · ea238b16 · URL check after the account lookup.

  3. Upstream corrective commit

    e69aa7a5 · check URL validity before the account lookup (#26485).

  4. 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.

More credited contributions