fix(security): a driver's password can no longer be set by a general update - #304
Merged
roncodes merged 6 commits intoAug 31, 2026
Merged
Conversation
…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 Report✅ All modified and coverable lines are covered by tests. 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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
PUT|PATCH /v1/drivers/{id}passedpasswordstraight through: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:
POST /v1/drivers/{id}/change-passwordcurrent_password,password(confirmed, min 8)POST /v1/drivers/forgot-passwordidentity— sends a code by email or SMSPOST /v1/drivers/reset-passwordidentity,code,password(confirmed, min 8)update()no longer acceptspasswordat all. Create still may — setting apassword on an account that does not exist yet proves nothing about anyone.
Follows the pattern already established for customers, and reuses the
VerificationCodemechanism drivers already use for OTP sign-in(
driver_password_resetalongside the existingdriver_login).Three deliberate details
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-passwordanswers identically whether or not the identity exists. Areset endpoint that 404s on an unknown number is a way to enumerate a
company's drivers.
reset cannot be used as an oracle either.
Tests
A regression test asserting a
passwordsent toupdate()never reaches theuser 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/foundationin the localserver_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.
passwordis guarded onUser, soUser::create()dropped it silently — whileCreateDriverRequesthas always accepted and validatedone. A driver created through the public API could never sign in with the password their operator
chose for them, and the first
change-passwordcall was refused because the stored hash was nevertheirs. It is now set after creation, where the model's mutator hashes it. (
CustomerControllerhas the same shape at
createUser(); left alone as it is outside this PR's subject.)changePasswordcompared against a column it could not see. It resolved the account throughDriver::getUser(), which goes through theuserrelation — and that relation's->select([...])names a subset of columns that does not include
password. The comparison therefore ran against anempty string and refused every caller, whatever they typed:
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 recorditself.
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.