Skip to content

fix(security): a driver's password can no longer be set by a general update - #304

Merged
roncodes merged 6 commits into
dev-v0.6.61from
fix/driver-password-change-requires-current
Aug 31, 2026
Merged

fix(security): a driver's password can no longer be set by a general update#304
roncodes merged 6 commits into
dev-v0.6.61from
fix/driver-password-change-requires-current

Conversation

@roncodes

@roncodes roncodes commented Aug 23, 2026

Copy link
Copy Markdown
Member

The problem

PUT|PATCH /v1/drivers/{id} passed password straight through:

$userDetails = $request->only(['name', 'password', 'email', 'phone']);
$driverUser->update($userDetails);

Nothing asked for the current password. So anyone holding a driver's token —
or an unlocked handset — could set a new one and own the account.
The value is
hashed on the way in, so this is not a storage problem; it is an authorisation
one, which is worse, because everything looks correct afterwards.

Found while building the Navigator redesign: the app had to ship with password
change deliberately disabled because there was no safe way to offer it.

The change

Changing a password is an authorisation decision, not an attribute update, so it
gets its own operations:

Route Requires
POST /v1/drivers/{id}/change-password current_password, password (confirmed, min 8)
POST /v1/drivers/forgot-password identity — sends a code by email or SMS
POST /v1/drivers/reset-password identity, code, password (confirmed, min 8)

update() no longer accepts password at all. Create still may — setting a
password on an account that does not exist yet proves nothing about anyone.

Follows the pattern already established for customers, and reuses the
VerificationCode mechanism drivers already use for OTP sign-in
(driver_password_reset alongside the existing driver_login).

Three deliberate details

  • A change revokes every other token and returns a fresh one. The old
    password should not keep other sessions alive, but a driver changing their
    password mid-shift must not be signed out of the phone in their hand — so the
    caller is re-issued in the same response.
  • forgot-password answers identically whether or not the identity exists. A
    reset endpoint that 404s on an unknown number is a way to enumerate a
    company's drivers.
  • Wrong code, expired code and unknown identity return the same message, so
    reset cannot be used as an oracle either.

Tests

A regression test asserting a password sent to update() never reaches the
user record.

I could not run it locally. Every test touching a controller in this package
fatals here with Trait "Illuminate\Foundation\Auth\Access\AuthorizesRequests" not found
— the whole file, and identically on a clean checkout of the base branch. It is a
missing illuminate/foundation in the local server_vendor, not this change.
CI has the full install. Flagging rather than implying a green run I did not see.

Worth a second opinion

Revoking other tokens on change is the behaviour I would want as a driver, but it
is a behaviour change for any existing integration that holds long-lived driver
tokens. Happy to drop it if you would rather that landed separately.


Two further defects, found while making the Postman collection exercise these endpoints

Both were caught by running the collection against a live instance rather than by the tests, and
both would have made the new endpoints unusable in practice.

A password given at driver creation was never kept. password is guarded on User, so
User::create() dropped it silently — while CreateDriverRequest has always accepted and validated
one. A driver created through the public API could never sign in with the password their operator
chose for them, and the first change-password call was refused because the stored hash was never
theirs. It is now set after creation, where the model's mutator hashes it. (CustomerController
has the same shape at createUser(); left alone as it is outside this PR's subject.)

changePassword compared against a column it could not see. It resolved the account through
Driver::getUser(), which goes through the user relation — and that relation's ->select([...])
names a subset of columns that does not include password. The comparison therefore ran against an
empty string and refused every caller, whatever they typed:

POST /v1/drivers/{id}/change-password   →  422 The current password is incorrect.

with the correct password, against a driver whose stored hash verifies. Calling the same method
in-process on the same driver returns 200. Anything checking a password now loads the record
itself.

Coverage stays at 100%. The helper harness only had the hashing contract and no implementation,
so the mutator had nothing to call; PHP's own hashing is now bound behind the contract so the
password path is actually exercised.

…update

PUT/PATCH /v1/drivers/{id} passed `password` straight through to the driver's
user record. Anyone holding a driver's token — or an unlocked handset — could
set a new password without proving they knew the old one, and the account was
theirs. Nothing in the request rules or the controller asked for the current
password, and nothing logged that it had changed.

Changing a password is an authorisation decision, not an attribute update, so
it gets its own operations:

  POST /v1/drivers/{id}/change-password   current_password + password (confirmed)
  POST /v1/drivers/forgot-password        identity -> code by email or SMS
  POST /v1/drivers/reset-password         identity + code + password

`update()` no longer accepts `password` at all. Creating a driver still may:
setting a password on an account that does not exist yet proves nothing about
anyone.

Three things the implementation is deliberate about. A change revokes every
other token and hands the caller a fresh one in the same response, so the
password change ends other sessions without signing the driver out of the phone
in their hand. `forgot-password` answers identically whether or not the identity
exists, because a reset endpoint that 404s on an unknown number is a way to
enumerate a company's drivers. And a wrong code, an expired code and an unknown
identity all return the same message, so reset cannot be used as an oracle
either.

Follows the pattern already established for customers, and reuses the
VerificationCode mechanism drivers already use for OTP sign-in.

The regression test asserts a password sent to update() never reaches the user.
It could not be run locally: every test touching a controller in this package
fatals here with "Trait AuthorizesRequests not found", on a clean checkout too —
a missing illuminate/foundation in the local server_vendor, not this change.
…pendency

CI enforces 100% line coverage; the three new methods were 61 uncovered lines.

Validation now follows the house style rather than $request->validate(). The
customer password endpoints next door do explicit checks and return apiError,
and $request->validate() is a foundation macro — matching the neighbours means
the same shape of response and code that can be exercised without booting an
application.

Four small seams make the rest testable without changing any production
behaviour: the identity lookup, the reset-code lookup, sending the code, and the
password comparison. Each is a one-line method delegating to what it did
inline, and the probe overrides them.

Fifteen tests covering every branch: both missing inputs, a password too short,
a mismatched confirmation, a missing driver, a driver with no user account, the
wrong current password changing nothing, and the successful change ending other
sessions while returning a fresh token. For reset: the same-answer-for-everything
property is asserted directly — an unknown identity and a bad code return
identical bodies, so neither can be used as an oracle — along with the code being
spent and every session ending, because a reset is a recovery from losing
control and nothing should keep working.

The tests stub the foundation traits and response() behind existence guards, as
the driver contracts test already does for one Laravel class. In CI, where the
real ones are installed, the guards do nothing. Locally they are what makes a
controller test runnable at all — I had previously concluded these could not be
run here, which was wrong.
The seams that made the endpoints testable were themselves uncovered, because
the probe replaces every one of them. Each is a one-line delegation — to
Eloquent, to the verification-code generator, to the hasher, to the application
— and what is worth asserting is that they delegate at all: a password
comparison that quietly returned true, or a code sender that quietly did
nothing, would each turn a security control into decoration.

Both delivery branches are exercised, an identity shaped like an email address
and one shaped like a phone number, since they take different paths.
@codecov

codecov Bot commented Aug 23, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (81dcea2) to head (0f4112c).
⚠️ Report is 3 commits behind head on dev-v0.6.61.

Additional details and impacted files
@@               Coverage Diff                @@
##             dev-v0.6.61      #304    +/-   ##
================================================
  Coverage         100.00%   100.00%            
- Complexity          9829      9866    +37     
================================================
  Files                523       523            
  Lines              37918     38021   +103     
================================================
+ Hits               37918     38021   +103     
Flag Coverage Δ
backend 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

The coverage gate rounded to 100% while the file sat at 545/546, and codecov's
patch check — which does not round — caught the one line: a `return` placed
after a call that can throw, in the email branch of the reset-code sender.
Nothing can reach it, so if/else replaces the early return.

Also runs php-cs-fixer over the two files this branch touches. Its alignment
pass reaches further, but reformatting files this change never went near does
not belong in a security fix.
`password` is guarded on User, so `User::create()` dropped it without a
word — while CreateDriverRequest has always accepted and validated one.
A driver created through the public API could never sign in with the
password their operator chose for them, and the first change-password
call would be refused because the stored hash was never theirs.

Set it after creation, where the model's mutator hashes it.

The helper test harness only had the hashing contract, not an
implementation, so the mutator had nothing to call; bind PHP's own
hashing behind the contract so the password path is exercised.
changePassword resolved the account through Driver::getUser(), which
goes through the `user` relation — and that relation selects a named
subset of columns with `password` not among them. The comparison
therefore ran against an empty string and refused every caller,
whatever they typed. Verified against a live instance: the same call
made in-process returns 200 where the endpoint returned 422.

Load the record directly when a password is at stake.
@roncodes
roncodes merged commit 680a490 into dev-v0.6.61 Aug 31, 2026
8 checks passed
@roncodes
roncodes deleted the fix/driver-password-change-requires-current branch August 31, 2026 04:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant