mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-16 16:45:07 +00:00
main
42 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8372f42525 |
Ask the picker's own question of what comes back from it
Reported by @skeletonsec as GHSA-w29w-pj29-x7ww. Deleting an account that owns files makes the admin choose who inherits them. The picker narrows that list for a client-scoped staff member to their own roster, and says why two methods up: "a client-scoped staff member is not shown the name of somebody they can reach nothing of, and a picker is no more a reason to hand one over than a listing is." The write asked something else entirely — exists, active, and not the account being deleted. All three are true of every account on the installation. So a scoped staffer could name an id the picker had deliberately kept off the list, and a roster client's files and folders landed with a client on somebody else's roster: readable, editable and deletable there, because a client owns what they uploaded and visibleToClient() includes uploaded_by. The entry doors scope the source account and always did — guardTarget goes through canAssignClient. It is the destination nobody scoped. candidates() and validate() now run one predicate, reachableTargets(), rather than two that happened to agree. Two that agree by inspection is what this was: the narrowing existed, was correct, and was only ever applied to the list. The refusal deliberately reads as "no such account". An out-of-roster id and an id belonging to nobody now produce the same message, because a refusal that distinguishes them lets a scoped staffer walk the id space and learn which accounts exist outside their roster. That is why Rule::exists is gone rather than kept alongside: one code path, one answer. A test pins the two messages as identical instead of naming either. Both the web screen and the API twin come through this one validate(), so both are fixed by it — and the test file proves each separately rather than assuming the sharing holds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx |
||
|
|
83a8fe2288 |
Claim the installation instead of checking whether it is free
Reported by @ry2811 as GHSA-w3w9-prpw-qx77, with a working two-worker reproducer. Setup asked the database whether any staff user existed, and created one some time later, in a separate statement with nothing joining the two. So two POSTs arriving together both read "no staff" and both inserted a System Administrator. Different addresses do not collide; `users.email` is the only unique key and it has nothing to say about there being one first administrator. The gap is not narrow. Between the check and the insert sits password hashing at BCRYPT_ROUNDS=12, which is slow on purpose, so the window is hundreds of milliseconds wide and observable without trying. What makes this worth fixing is not that a stranger can set up an unconfigured installation — first-run setup is open to whoever reaches it first, and always was. It is that racing the operator is *quiet*. The operator's own request also succeeds, also redirects to /setup/success, and the installation they get looks exactly like the one they expected. The second administrator is discovered later or not at all, and closing setup afterwards does not revoke it. FirstAdministrator::claim() makes it one operation. The row it locks is the System Administrator role, because the obvious candidate cannot work: there are no staff rows on a fresh install and a lock over an empty result serialises nothing. That role row is written by the roles migration and rewritten on every boot, so it is always there to be locked. The second caller waits on it, and by the time it has the lock the first caller's user is committed and visible to the re-check it then makes. Everything the request writes moved inside the claim, including the site name. A request that loses now writes nothing at all, rather than renaming the installation on its way to the login screen. `projectsend:admin --if-none` had the same shape and is fixed the same way — two containers coming up against one database is the version of this that needs no attacker. The early check stays where it is so an unattended boot does not prompt for a password it is about to discard; it is simply asked again under the lock. Both tests fail on the unfixed code. They stage the interleaving rather than attempting real concurrency, creating the winning administrator from a query listener after the request has made its first check — which is exactly the window, and the re-check is the only thing that closes it. The lock itself is invisible to them: the suite runs SQLite, where lockForUpdate() compiles to nothing. That half was verified against MySQL 8.4 by running the reporter's race for real, two processes through the full HTTP kernel: two administrators before, one after, repeatably. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx |
||
|
|
b96d060ad8 |
Compare an address ourselves, instead of asking the collation
Reported by @choewonwoo1817 as GHSA-wgxf-v8cr-37mj, with a working
end-to-end reproducer against Keycloak.
`where('email', $address)` is not an exact match. It is whatever the
database says equality means, and the collation INSTALL.md tells people to
create — utf8mb4_unicode_ci — folds accents:
administrator@example.com = administrator@éxample.com -> 1
Those are two different domains. The second is xn--xample-9ua.com, which
somebody else can register and honestly verify at an OIDC provider. So an
attacker with no account here could sign in as themselves and be handed
the first account: SocialAuthenticator found it, linked their subject to
it permanently, and started a session. No password, no interaction from
the owner, an administrator session where that account was one.
Comparison now happens in PHP, in one place, on every driver. Case is
still folded because that is a real requirement — addresses are stored
lowercased and a provider may send any case — and mb_strtolower folds case
without folding accents, which is exactly the line to draw.
Three call sites move to it and two deliberately do not. Loose matching is
right when *refusing* and wrong when *selecting*: AvailableEmailRule and
ClientProvisioning ask "is this address free", where a collation that says
no to a near-miss refuses more registrations, which is the safe direction.
The three that ask "which account is this" are the social path, the login
form (where a password still gated it, so it was confusion rather than
takeover) and the erasure command (irreversible, and the wrong row is the
wrong person).
The test story is the part worth reading. The suite runs on SQLite, whose
`=` is byte-exact, so this defect does not exist there and never did —
which is how it survived six releases with everything green. A test
written the obvious way passes on unfixed code. So the comparison is
pinned by driver-independent tests that always run, and the chain is
proved by AccountLookupCollationTest, which skips unless the connection is
MySQL and carries the command to run it. Run against real MySQL with the
real collation: it fails on the old code and passes on the new.
|
||
|
|
0a3410140d |
Keep an erased staff member's library away from a client
The reassignment target is one installation-wide id used for every erasure, and the picker offers clients deliberately: erasing a client and handing their files to another client is what the setting is for. Applied to a staff account the same id means something else. A staff library is usually the whole installation's, so a client named there inherits all of it — through an unattended scheduled job, with no per-account confirmation, because this is the default rather than a choice somebody makes at the moment of deleting. So a staff account's content may only go to staff. With nobody valid to hand it to, handleContent() already cascades, which keeps the existing promise that content is never orphaned — it now also never becomes a disclosure. Not in the settings validation, which is where it looks like it belongs. That runs when the target is chosen, and whose account will be erased later is not knowable then. Both halves are only in hand here. Found while checking a list from the portal session, who had it as one where() on `type`. That would have been too broad: it would also have stopped a client's files reaching another client, which is the case the setting exists to serve. The condition is on the account being erased, not on the target alone. |
||
|
|
ab5fa2da8b |
Make Entra prove the address, not just the directory
Reported by Dickson Massawe as GHSA-2rfh-v3j2-2jg7. Pinning the tenant was half an answer. It defeats the classic cross-tenant nOAuth, where a stranger's own directory asserts your address, because a foreign tenant carries a different tid. It does nothing about the same attack from inside the pinned tenant: Entra's email claim is user-mutable — a B2B guest's otherMails among its sources — so a colleague or an invited guest could present an administrator's address and have their subject bound to that account. Tenant-pinning answers "which directory said this". It never answered "does this person own that address". xms_edov is Microsoft's own answer to the second, and their guidance says to require it wherever email identifies an account. Absent counts as unverified, which is the only safe reading given it is absent by default. Nobody is locked out by this, which is worth saying because it looked like a breaking change until I read SocialAuthenticator::resolve in order. An account already linked resolves by subject at step 3, before trust is consulted at all — those keep working untouched. A first-time link to an existing account is refused with the message that already exists for exactly this case, which names the way through: sign in with your password and connect the provider from your settings. A brand-new account is still created; it goes to the approval queue rather than auto-approving. The settings screen and docs/testing-social-login.md now tell an operator to add the claim, and there is an upgrade note. The tests exercise fromSocialite() on raw claims, which nothing did before: tests/Feature/Auth/SocialLoginTest.php builds a SocialIdentity by hand and so never reaches this mapping. That is how the branch could trust a tenant match alone with a full suite passing. |
||
|
|
6560346280 |
Mark the first administrator's address verified, as intended
The last two paths that passed email_verified_at into User::create() and lost it: the setup screen, and projectsend:admin for a container that comes up from environment variables. It is deliberately absent from $fillable, so mass assignment drops it without a word, and both meant to set it. The intent is plain in both cases — the first administrator typed their own address into the form in front of them, and whoever provisioned the container supplied it themselves. There is nobody to confirm it to. Inert today, since MustVerifyEmail is not enabled on the model, but the column is what a later switch would read: turning verification on would have locked out the one account that cannot be helped by another administrator. Both are now pinned by a test that fails when the forceFill is removed. StaffAccounts had already fixed this for staff and named the rest; with client accounts done earlier today, that list is empty. Also says on User::$fillable what absence from it buys and what it does not. It stops a request smuggling a value in; it does not tell code that meant to set the value that it failed. Four separate paths made the same mistake against the same comment. |
||
|
|
1a3260a397 |
Merge pull request #1758 from denkfabrik-li/fix/confirm-password-asks-the-directory
Let the confirm-password screen ask where the password lives |
||
|
|
3e24ccd42f |
Let the confirm-password screen ask where the password lives
ConfirmablePasswordController checked the local hash and nothing else:
Auth::guard('web')->validate(['email' => ..., 'password' => ...])
An account provisioned from a directory has no local password. It holds a
Str::password(64) generated at provisioning time that nobody has ever
seen, and the application knows this -- LdapAuthenticator::isDirectoryAccount()
is the question, and the sign-in form asks it before deciding what to
check. This screen did not, so it refused those accounts the only password
they have.
That is not a cosmetic refusal. `password.confirm` stands in front of
enrolling in two-factor, so a directory-provisioned client could not enrol
at all. Set TwoFactorEnforcement to `clients` or `all` and EnforceTwoFactor
redirects every request they make to two-factor.show -- a screen whose
"enable" button leads to a door they cannot open. PR #1708 fixed the
routing half of that ("Let an enforced user reach the far side of the
confirm-password screen"); this is the credential half.
The rule now lives in one place. PasswordVerification is the sibling of
SignIn on the other side of the line SignIn draws -- SignIn is everything
after a credential checks out, this is the one question asked before it --
and it exists for the reason SignIn gives for existing: "the way they get
broken is by being written twice". LoginRequest keeps its ordering, its
provisioning and its rate limiting, and delegates the check itself.
Behaviour preserved exactly on the sign-in path: local hash first so an
account that answers locally generates no directory traffic, directory
only for accounts whose credentials live there, the stale-hash re-hash on
the local branch only, and the ldap_dn stamp on the directory branch. All
23 existing LDAP sign-in tests pass unchanged.
One thing this closes on the way past. Because the old check went straight
to the local hash, a directory account's placeholder *would* have confirmed
if anybody ever learned it -- a door the sign-in form does not have, since
it skips the local branch for those accounts. It now behaves the same on
both screens; there is a test.
**What this does not fix, and should be read as a limitation.** Accounts
provisioned by a social provider are in the same position -- a random local
password nobody holds -- and they are not directory accounts, so this
changes nothing for them. Their route to a local password is the password
reset, which #1748 made work end to end by moving auth_source to Local when
the reset completes. A social account that has never done that still cannot
confirm a password, and so still cannot enrol in two-factor.
Tests: three fail against the unfixed pair, including the placeholder case
above. Two more pin what must not change -- a wrong directory password is
still refused, and a local account with LDAP switched on still confirms
against its own hash.
|
||
|
|
cad112522d |
Narrow the conversion list to the clients its own refusal allows
/users/convert lists the accounts a conversion can be started from. For
the promotion direction those are clients, and the query asked only for
the type:
User::query()->where('type', UserType::Client)
The write beside it does not. AccountConversion::guardToStaff() ends with
abort_unless($this->library->canAssignClient($actor, $target), 404);
and says why: a promotion is the most far-reaching thing that can be done
to a client, so reaching one outside the actor's roster "through this door
and no other is not a rule, it is a gap".
The gap was on the way in. A client-scoped staff member holding
manage_users and edit_users was refused the promotion with a 404 -- the
refusal that is careful not to distinguish a stranger from an account that
is not there -- and then shown that same person's name, email, role,
status and consequence counts in the list the refusal came from,
searchable by name or address and paginated to the end.
StaffLibraryScope::clients() is canAssignClient()'s listing half, written
for this: "so a screen narrows by the same rule its buttons are guarded
with rather than restating it -- which is how ClientsController came to
list every client on the installation, name and email, to a viewer who
could reach nothing of theirs." The picker twenty lines below already went
through the same boundary via assignableClientIds().
Only the client direction is narrowed. The staff direction is left exactly
as it was: whoever may demote a staff member may see the staff roster, and
what limits a demotion is guardTarget() on the write, not the listing.
Tests: the listing half added to AccountConversionScopeTest, which until
now covered only the refusals. Two of the five fail against the unfixed
controller -- the stranger's address in the list, and reaching it by exact
search. The other three pin what must not change: the actor still sees
their own client, unscoped staff still see everybody, and the demotion
list still lists staff.
|
||
|
|
90009b7029 |
Merge pull request #1738 from denkfabrik-li/fix/totp-replay-claim-atomically
TwoFactorService::verify() asked Cache::has(), verified the code, then Cache::put(). Between the read and the write the key is free, so two requests carrying the same code could both be told yes -- which is precisely what the replay guard exists to prevent, and the window an intercepted code has is the whole of its validity either side. Cache::add() writes only if the key is absent, so of two requests carrying the same valid code exactly one gets true back, and has() is gone: a failed claim is "already used". Verification still runs first, so a wrong code never touches the cache and cannot burn the window for the code the person is about to type correctly. The 90-second claim, the key's shape, and the recovery codes are all unchanged. Verified before merging: 11 passed on the trial-merge, 1 failed / 10 passed with app/ reset. The existing "a totp code cannot be replayed" test is green either way, because it covers the sequential case, which was never the problem. Being on the authentication path, the wider suites were run too: tests/Feature/Identity and tests/Feature/Auth together, 393 passed. Cache::add() is only as atomic as the store under it, so every store an installation could realistically run was checked in the vendored framework rather than assumed: database (the default when CACHE_STORE is unset) decides on insertOrIgnore(...) > 0 against the cache table's primary key; redis and memcached have native atomic adds; and the file store takes an exclusive flock before it reads and writes. Reported and fixed by @denkfabrik-li. |
||
|
|
037439e1f2 |
Merge pull request #1730 from denkfabrik-li/fix/provisioning-over-deleted-address
The unique index on users.email spans soft-deleted rows -- AvailableEmailRule is built on exactly that -- so a deleted account keeps its address until erasure removes the row. The registration form learns this from validation. The machine paths have no form to validate: a directory or an identity provider hands over an address and ClientProvisioning::provision() inserts it, so a client deleted earlier signing in through a provider that may auto-provision got a QueryException, and what the person met was a 500 in the middle of their sign-in. Same shape through LDAP at POST /login. Both provisioners now ask ClientProvisioning::addressIsFree() first and refuse. The social flow reuses the refusal it already gives every other identity it cannot provision -- "There is no account here for that address." -- which is also all a stranger should learn: whether an address was once an account here is not the provider's to publish. The LDAP flow falls through to the ordinary failed sign-in. The deleted account is deliberately not resurrected and not linked. Restoring one because a directory still lists the address is a decision for a person, not a side effect of somebody signing in -- and a linking shortcut here would be an account takeover. Everything about an address belonging to a live account is untouched. Verified before merging: 47 passed on the trial-merge, 2 failed / 45 passed with app/ reset. addressIsFree() queries withTrashed(), the same span as the unique index it protects, so the check and the constraint agree. Worth noting that both new warning lines record the email address, which is consistent with what these paths already log but is PII in the application log. Reported and fixed by @denkfabrik-li. |
||
|
|
674781e57a |
Claim a TOTP code atomically instead of checking then writing
verify() asked Cache::has(), verified, then Cache::put(). Between the read and the write the key is free, so two requests carrying the same code could both be told yes -- which is exactly what the replay guard exists to prevent, and the window an intercepted code has is the whole of its validity either side. The claim is now the answer: Cache::add() writes only if the key is absent, so of two requests carrying the same valid code exactly one gets true back. That is the same mechanism, for the same reason, as the preview log's debounce -- "Cache::add is the whole mechanism: it writes only if the key is absent ... without a read-then-write race between two of them". Verification still happens first, so a wrong code never touches the cache and cannot burn the window for the code the person is about to type correctly. One test, modelling the interleaving it is about: the winner's claim has landed, and the loser's has() answers from before that write. Without the fix the loser is signed in. |
||
|
|
9ddd39c41d |
Refuse to provision over a deleted account's address instead of crashing
The unique index on `email` spans soft-deleted rows -- AvailableEmailRule is built on exactly that, so a deleted account keeps its address until erasure removes the row. The registration form learns this from validation. The machine paths have no form: a directory or an identity provider hands over an address and provision() inserts it. Measured on main, a client deleted last week signing in through a provider that may auto-provision: GET /auth/google/callback → 500 (QueryException, unique constraint) Same shape through LDAP at POST /login. Nothing is created, nothing is signed in, and what the person meets is a server error. Both provisioners now ask ClientProvisioning::addressIsFree() first and refuse. The social flow already has a refusal for an identity it cannot provision -- "There is no account here for that address." -- which is also all a stranger should learn: whether an address was once an account here is not the provider's to publish. The LDAP flow falls through to the ordinary failed sign-in. Deliberately not resurrecting the deleted account. Restoring one because a directory says the address exists is a decision for a person, not a side effect of somebody logging in. Two tests, one per path: the sign-in is refused, nothing is created, and the trashed row is still trashed. Both go red without the fix. |
||
|
|
9d4b096c19 |
Narrow the reassignment picker to what a viewer may see
`reassign_candidates` is the delete dialog's picker: every active account
in the installation, by name and by role label. The same list is shared
on the clients index, the users index, both edit screens and privacy
settings, and it was narrowed by nothing.
Two lines above it on the clients index sits the listing itself, narrowed
through `scope->clients($viewer)` with a comment saying why: "a
client-scoped staff member is not shown the name and email of somebody
they can reach nothing of". The picker beside it handed over every client
in the installation, plus every staff account and its role name. The
filter by `can('delete_clients')` happens in React, which decides what is
rendered, not what is sent.
So the client half of the candidate list goes through the same
StaffLibraryScope as the listing, and each screen sends the picker only to
a viewer holding the delete permission it exists for. Staff accounts are
not narrowed -- they are not narrowed anywhere else either -- and an
unscoped viewer's list is unchanged, because StaffLibraryScope::clients()
returns every client for them.
Privacy settings keeps the whole installation on purpose: that picker sets
the erasure default stored once for everybody, behind edit_settings, so
narrowing it by whoever happens to be editing would store the wrong
answer. The parameter is nullable for that one caller, and the docblock
says so.
Four tests. Without the fix three go red; the fourth is the guard that an
administrator still sees every active account.
|
||
|
|
58497ef776 |
Merge pull request #1716 from denkfabrik-li/fix/sole-administrator-self-deletion
ProfileController::destroy() validated the current password and soft-deleted, without asking guardLastAdministrator() -- the rule the other four doors ask, at the one door where the account being removed is certainly signed in. The sole administrator could empty their own installation, and EnsureSetupIsComplete, which asks exists() and so skips trashed rows, then handed the first-run setup form to whoever loaded the page next. That form creates an active System Administrator, unauthenticated. Verified before merging: on main the sole administrator's self-deletion succeeds and setup reopens; both new tests are red there and green here. Suite at 2088, PHPStan clean. Two locks, because one of these questions is asked at five doors and the other at one. The guard closes the door. And "has this installation been set up" stops meaning "does it have a working administrator right now" -- a trashed staff row is still evidence that setup happened, counted now in both the middleware and SetupController::setupIsComplete(), which have to agree or the result is a redirect loop or an open form. Worth recording: erasure force-deletes a self-deleted account after its grace period, so the second lock would expire on its own. It does not matter because the first lock stops the installation reaching that state, but a future change to either should know the other is not permanent. An installation that has already lost its last administrator now finds setup shut. That is the point: recovery is php artisan projectsend:admin, which is also how every unattended container installs itself. Reported and fixed by @denkfabrik-li. |
||
|
|
abaca20261 |
Merge pull request #1713 from denkfabrik-li/fix/api-self-deactivation-boolean
The validation rule accepts 0 and "0" as well as false and does not cast, so a strict comparison against the validated array let two of the three spellings past the self-deactivation guard -- and the model's own boolean cast then stored exactly the value the guard had just decided was not a deactivation.
Reproduced on main before merging: {"active": false} is refused, {"active": 0} and {"active": "0"} both return 200 and switch the account off. Green on the branch, suite at 2074, PHPStan clean.
The fix reads the flag once with Request::boolean() and gives that same value to the guard and to the write -- the rule RolesController::guardScopeRemoval already documents for the same reason. Validation is unchanged, so the accepted inputs are the same; one of them just stops meaning two different things on its way through the method.
Follow-up for the release: this is a caller-visible change (200 to 422) and wants a line in api-changelog.md.
Reported and fixed by @denkfabrik-li.
|
||
|
|
602c7bed94 |
Merge pull request #1708 from denkfabrik-li/fix/confirm-password-under-enforcement
EnforceTwoFactor exempts by route name, and only the GET half of the confirm-password screen had one -- Route::named() answers false for a null name, so the submission was never exempt. Enrolling requires password confirmation, so with enforcement on nobody could enrol at all: the form rendered, its POST was redirected to two-factor.show, auth.password_confirmed_at was never written, and every account on the installation was left with logout as its only working route. Including the administrator who turned the setting on. Reproduced on main before merging: POST /confirm-password redirects to /settings/two-factor and the session flag stays unset. The widened pattern was checked against the route table -- password.confirm* reaches password.confirm and the newly named password.confirm.store and nothing else; password.reset, password.store and the rest are not under that prefix. Exempting the submission grants nothing further, since every other route stays bounced and store() still validates the password. Reported and fixed by @denkfabrik-li. |
||
|
|
2eb23dbc07 |
Stop four comments saying user management is Community-only
It stopped being true in
|
||
|
|
13b56186f4 |
Say the seat limit before the form, not after it
On a managed installation with its staff seats full, /users/create opened as though there were room. You typed a name, an address and a password you had to invent, pressed Save, and the plan limit came back as a validation error under the email field -- which reads as a complaint about the address rather than a fact about the plan. A full installation is an ordinary state on a plan sold by the seat, so it is now stated up front. The list carries the seat position, the button goes dead once the last seat is taken and says why, and the create screen turns away anyone who reaches it by link or bookmark. The guard in store() is untouched: that is still the rule, this is only the door. The refusal is worded once, in SeatAllowance, and the screen is handed that sentence rather than writing its own -- two wordings of one limit is how somebody ends up believing there are two limits. `full` is derived there too, from the same comparison the guard refuses on, so a screen cannot disagree with it about the edge (used > limit, after an operator lowers a limit) and offer a button for a form that cannot be submitted. Clients get the same treatment: the cap exists there too, and reached it the same way. Self-hosted installations have no limit, so they are shown nothing about one. |
||
|
|
28e18497b5 |
Refuse the last administrator deleting themselves, and keep setup shut
ProfileController::destroy() validates current_password and soft-deletes. It never asks StaffAccounts::guardLastAdministrator(), and every other door does: Staff update(), guardDeletable(), and both directions of the role conversion. This is the one door where the account being removed is certainly signed in. An installation with a single administrator therefore had a button that emptied it. Measured on main: DELETE /settings/profile 302, the account is gone live staff rows 0 (the row is trashed, not removed) anonymous GET / 302 -> /setup anonymous POST /setup a new active System Administrator EnsureSetupIsComplete asks ->exists(), which excludes trashed rows, and routes/web.php registers GET and POST setup with no auth and no guest middleware -- correctly, since a fresh installation has nobody to authenticate. SetupController::store() re-checks the same condition, so both halves agreed with each other and both were wrong once the last staff row was trashed. Two locks, because one of them is asked at five doors and the other at one. First: destroy() now asks guardLastAdministrator(), the same call with the same message as everywhere else. An administrator with a colleague still goes, a non-administrator staff member still goes, and a client still closes their own account. Second: "has this installation been set up" is not the same question as "does it have a working administrator right now", and only the first one belongs in EnsureSetupIsComplete. A trashed staff row is still evidence that setup happened, so it now counts -- in the middleware and in SetupController::setupIsComplete(), which have to agree or the result is either a redirect loop or an open form. That second lock holds even if a future door forgets the first one. Measured with the guard bypassed entirely and the row trashed directly: GET / answers with the login screen and POST /setup creates nothing. Worth stating plainly: an installation that has already lost its last administrator will now find setup shut rather than open. That is the point -- the recovery path for it is `php artisan projectsend:admin`, which is also how every unattended container installs itself, not a form that anybody on the internet can reach. Six tests, two measured red against the unfixed code (2 failed / 4 passed) -- one per lock. The other four are the boundaries: a colleague present, a staff member who is not an administrator, a client, and a genuinely fresh installation that must still reach setup. Two existing tests needed saying more clearly rather than changing: ProfileUpdateTest's deletion cases now create a second administrator, so that what they assert is self-deletion and not this new refusal; and GettingStartedTest's "fresh installation" cases forceDelete rather than delete, because a soft-deleted staff row is no longer a fresh installation -- which is the whole of the second lock. Full suite passes (2054 passed / 2 skipped), PHPStan level 8 clean. |
||
|
|
3e15237f90 |
Refuse self-deactivation over the API however the boolean is written
Api\UsersController::update() compares the validated value strictly:
if ($user->is($actor) && ($validated['active'] ?? true) === false) {
The `boolean` rule accepts 0 and "0" as well as false, and it does not
cast. `0 === false` is false, so the refusal never fires -- and the
model's own `boolean` cast then stores as false exactly the value the
guard had just decided was not a deactivation.
Measured against main, with a second administrator present so that
guardLastAdministrator is not what answers:
{"active": false} -> 422, still active
{"active": 0} -> 200, active is now false
{"active": "0"} -> 200, active is now false
The method's own docblock says it is "Refused with a 422 if the change
would leave the installation with no active administrator, or if you
would be deactivating yourself", and the web screen does refuse. This is
the API half of that sentence.
RolesController::guardScopeRemoval documents the rule this breaks, in the
same words: callers resolve the flag with Request::boolean() and hand the
same value to the guard and to the write, deliberately, because reading
the validated array and comparing it strictly "would let a request
through here that the model's `boolean` cast then stores as false anyway
-- the guard and the write disagreeing about one value is exactly the
shape this guard exists to prevent".
So read it once, with Request::boolean(), and give that one value to both.
Not changed: the validation rule. It stays `boolean`, so the accepted
inputs are the same as before -- what changes is that one of them stops
meaning two different things on its way through. Nor anything about
deactivating somebody else: all three forms still work, and there are
tests saying so.
Six cases from two datasets. Two measured red against the unfixed
controller (2 failed / 4 passed): 0 and "0" on yourself. `false` was
already refused, and the three "somebody else" cases are green either way
-- they guard against the fix over-refusing, not against the bug.
Full suite passes (2054 passed / 2 skipped), PHPStan level 8 clean.
|
||
|
|
1dc274e896 |
Let an enforced user reach the far side of the confirm-password screen
EnforceTwoFactor exempts by route name, and only the GET half of confirm-password has one. routes/auth.php:95 names the form `password.confirm`; :98 registers its submission with no name at all, and Route::named() answers false for a null name. So the loop the exemption exists to prevent is still there, one step further along. With Setting::TwoFactorEnforcement set to staff, clients or all, an un-enrolled account walks: GET /dashboard -> two-factor.show GET /system/settings/security -> two-factor.show PATCH /system/settings/security -> two-factor.show POST /settings/two-factor -> /confirm-password (RequirePassword) GET /confirm-password -> 200, the form renders POST /confirm-password -> two-factor.show <- not exempt `auth.password_confirmed_at` is never written, so enrolling can never start, and every route that is not on the exemption list stays shut -- including Settings -> Security, the one screen that could turn enforcement back off. Logout is the only door left; recovery is CLI or database access. It takes one administrator turning the setting on to reach it, and it reaches every account on the installation at once, including their own. The fix is the name. `password.confirm*` then covers both halves of one screen, matching `two-factor.*` in the same expression; the namespace belongs entirely to a flow enrolment already depends on being reachable, and the route table has nothing else under it -- `password.confirm` (GET) and `password.confirm.store` (POST) are the two it reaches. Exempting the submission grants nothing further. store() validates the password, writes a session flag and redirects; the redirect it issues enters this middleware like any other request, so Settings -> Security is still answered with two-factor.show after confirming. What changes is that enrolment can now be started. Two tests, both measured red against the unfixed middleware: the password confirmation sticks, and enrolment can be started afterwards (the secret is written and the screen reports `pending`). Also named the redirect the existing test settles for. `->assertRedirect()` with no target passes on this middleware bouncing the request back to two-factor.show, which is the shape that file exists to refuse. It is a clarification rather than a guard -- that assertion is green either way, since the redirect it sees comes from RequirePassword. Full suite passes (2050 passed / 2 skipped), PHPStan level 8 clean. |
||
|
|
463e86f82b |
Refuse an account past the seat count an operator sold
Opening user management on cloud (
|
||
|
|
1760dc70f8 |
Merge pull request #1700 from denkfabrik-li/fix/role-scope-authority
Nobody lifts a limit they are standing inside |
||
|
|
ef822f2103 |
Merge pull request #1697 from denkfabrik-li/fix/assigned-clients-authority
Nobody hands out reach they do not hold either Two resolutions against branches that landed first. #1678 and this one each add a constructor property and an import to StaffAccounts, so both are kept. And #1702's merge note called this one exactly: its "converting an account to staff cannot hand out clients either" case promoted a stranger client, which #1702 now refuses at 404 before validation runs. Pointed at a client the actor holds, as that note proposed, so the request reaches the assigned_clients rule the case is actually about. |
||
|
|
9fc5042f4e |
Merge pull request #1688 from denkfabrik-li/fix/atomic-account-deletion
Delete an account and dispose of its content in one transaction Resolved the conflict with #1678 the way that PR's merge note predicted: the erasure stamp goes inside the new transaction, so a deletion that rolls back cannot leave a live account carrying a date on which it would be erased. |
||
|
|
c5d32c06f6 |
Merge pull request #1684 from denkfabrik-li/fix/create-only-redirect-403
Land a successful create where a create-only role can actually go |
||
|
|
ad4d75d8fe |
Merge pull request #1678 from denkfabrik-li/fix/deleted-account-email-reserved
Let a deleted account's email address come back into use
Closes #1648, and with it the last open item of #1647's audit of unique
indexes on soft-deleting tables.
Resolved a trivial conflict in both ClientsControllers: this branch and
today's
|
||
|
|
6a5c9e55aa |
Merge pull request #1702 from denkfabrik-li/fix/convert-client-account-scope
Promoting a client is still binding a client account |
||
|
|
706ebf6166 |
Nobody lifts a limit they are standing inside
StaffAccounts opens with the rule: "Nobody hands out authority they do not hold ... that turns one permission into every permission and makes the rest of the matrix decorative." mayGrant() enforces it for a role's permissions, guardTarget() applies the same test to an existing account, and RolesController::guardGrantablePermissions() names the attack in full -- a non-administrator holding manage_users minting a role that carries more than they do, and then holding it. A role carries one more thing, and it is the larger one. `client_scoped` decides whether the role reaches the clients assigned to its holder or the whole library, which is the boundary StaffLibraryScope, ActivityLogScope and every listing in the application are built around. Nothing weighed it. store() and update() wrote the flag straight from the request, and mayGrant() looked only at permissions -- so `manage_users` on a client-scoped role was enough to take the limit off that role and keep working, or to mint a role without one and move into it. Either way the next request read the whole library, and the `assigned_clients` roster that #1697 protects stopped meaning anything for that account. Both halves of the existing pair get the missing clause: - guardScopeRemoval() in RolesController refuses a client-scoped actor who creates a role without the limit, or takes the limit off one that has it. Phrased as "removes the limit" rather than "is not limited", so only what this request changes is weighed -- the same reasoning guardGrantablePermissions() gives for looking at the diff. Editing an already-unlimited role's permissions is not this actor lifting a limit. Both writers resolve the flag with Request::boolean() and hand that same value to the guard and to the write: the `boolean` validation rule accepts "0" and 0 as well as false and validates without casting, so reading the validated array and comparing it strictly would leave this guard and the model's own `boolean` cast disagreeing about one value -- which is the shape the guard exists to prevent. - mayGrant() refuses a client-scoped actor granting a role that is not client-scoped, which closes assigning an existing one. It reaches both surfaces at once: assignableRoleIds() validates role_id on the web and API staff forms and on the account converter, assignableRoles() fills the pickers, and guardTarget() covers the account itself. Administrators are unaffected -- mayGrant() returns early for them, and an administrator role is never client-scoped. Unscoped staff are unaffected: the clause is conditioned on the actor's own scope, so a non-administrator with manage_users and no limit creates, edits and grants exactly as before. The seeded roles are untouched; update() already refused to move the flag on a system role, which is why the stock Client Manager was never the way in. Two changes a client-scoped holder of manage_users will notice, both following from mayGrant(): - the role picker on the staff form and the account converter now offers only client-scoped roles, rather than offering one the request behind it would refuse; - editing or deleting a staff account whose role is not client-scoped now answers 403, through guardTarget(), on the same "if you could not grant their role you have no business editing that account" rule that already applied to permissions. The roles API is read-only (GET /roles is the whole surface), so this half has no API twin to mirror; the account half is covered above. |
||
|
|
7ebc9b0905 |
Spend a recovery code once, the way the docblock says
consumeRecoveryCode() reads the whole list, filters the used code out, and writes the whole list back. Two requests that both read before either writes each store their own copy, and the second write puts back the code the first removed. So a spent code comes back, and the same code offered twice is accepted twice -- while the method's first line says "each code works exactly once". Nobody gets in through this who was not already holding a valid code, so it is a promise not being kept rather than a door standing open. The promise is worth keeping anyway: it is the whole reason a printed sheet of recovery codes can be crossed off, and it is what makes a code that somebody watched being typed in stop working. The decision now comes from the row as it stands, re-read under a lock inside the transaction that writes it -- the shape SendNotificationDigest already uses to claim the rows it is about to delete. A conditional update, as in PublicShareController's downloads_count and the delivered_at claim in #1692, is the other precedent in the tree, but the column is `encrypted:array`: there is nothing in it a database can compare, so the comparison has to happen after decryption, under something that holds the row while it does. The lock is what makes it atomic against a request arriving at the same moment. The re-read is what makes the decision right, and it is the half a test can show: SQLite ignores lockForUpdate, so the accompanying tests pin the re-read and say so rather than claiming to prove the locking. config/database.php runs MySQL or Postgres in production, and both honour it. Saving through the caller's own instance keeps that instance in step with the row, so a caller cannot go on to decide from a list the database no longer has. |
||
|
|
b9f826282a |
Promoting a client is still binding a client account
Every route that binds one client — the edit screen, the update, the
delete, the second-factor reset, the file browser — asks whether this
staff member may manage that client. POST users/convert/{user} binds one
too, and asks nothing about it.
AccountConversion::guardToStaff() says why it skips
StaffAccounts::guardTarget, and the reason is sound as far as it goes:
guardTarget asks "could the actor have granted the target's role", which
is meaningless of a client, and what limits a promotion is the role being
*granted* — enforced by the controller validating role_id against
assignableRoleIds(). That answers the question about the role. Nothing
answers the one about the target.
So a client-scoped staff member holding manage_users, edit_users and
edit_clients could promote any client on the installation. It is the
most far-reaching thing that can be done to a client account: the portal
access goes, the assignments that made them somebody's client go inert,
and they come out holding whatever staff role the actor picked from
their own list. The client is never told.
guardToStaff() now asks StaffLibraryScope::canAssignClient — the same
predicate ResolvesShareTargets uses to decide who a file may be shared
with, and true by construction for unscoped staff, so the ordinary
administrator path is untouched. 404 rather than 403, matching both the
isClient() check the controller makes on the way in and the answer the
clients routes give: a client this staff member may not manage should
not be distinguishable from one that is not there.
guardToClient() is unchanged. Its target is a staff account, guardTarget
is the right question to ask about one, and it was already being asked.
The account list on the converter screen is deliberately left as it is.
It runs under can:edit_users and shows every account of the chosen
direction, the same way every other staff surface that lists clients
shows all of them; narrowing a listing is a product decision, not this
fix. What changes is that the button on the row now refuses rather than
going through.
|
||
|
|
4806b81dc3 |
Let a deleted account's email address come back into use
An account deleted by an administrator was soft-deleted with erase_after
null, so projectsend:purge-erasures — which filters on
whereNotNull('erase_after') — never reached it, and the unique index on
users.email kept the address reserved forever. Anyone re-creating the
account got "The email has already been taken", naming a conflict nothing
on any screen could show or clear (#1648).
Both halves of the issue's option 3:
Every deletion path now schedules the erasure. The stamp lives in
ErasureSchedule — self-deletion switched to it, and StaffAccounts::delete
(shared by the web screen and the API) and both client controllers call
it right before delete(). Same grace period, same purge, whoever deleted
the account. Deliberately no backfill for rows deleted before this
change: stamping them during an update would start a countdown to data
erasure that nobody chose at deletion time; the message below covers
them instead.
The staff creation paths swap unique:users,email for AvailableEmailRule,
which refuses exactly the same things but can explain the one refusal
the stock message can't: an address held by a deleted account now names
the date it becomes available, and one deleted before scheduling existed
points at projectsend:erase-account. A living account keeps the stock
message, and public registration keeps the stock rule — telling an
anonymous visitor the address belongs to a deleted account would confirm
it had an account here.
|
||
|
|
3af8235729 |
Nobody hands out reach they do not hold either
StaffAccounts opens with the rule for roles: "Nobody hands out authority they do not hold … that turns one permission into every permission and makes the rest of the matrix decorative." mayGrant enforces it, and guardTarget applies the same test to an existing account. The client roster never got the same treatment. `assigned_clients` was validated as `exists:users,id where type = client` and passed straight to syncAssignedClients, with nothing anywhere asking whether the actor holds the clients they are handing out. Assigning a client is not a label: it is that client's whole library, given to whoever is on the other end. The case that matters is the actor's own account. guardTarget returns immediately when the target is the actor — editing your own name and email is not a question of authority — so a client-scoped staff member with edit_users could PATCH their own id with every client on the installation and read the whole library from then on. assignableClientIds() answers the roster question the way assignableRoleIds() answers the role one, and the five places that accept `assigned_clients` — users store and update on both surfaces, and the account conversion — validate against it. It returns the full roster for an unrestricted actor, so every caller validates against one list instead of composing a conditional rule; that list is already client-typed, which is why one rule replaces the exists() and the type filter together. The two pickers that offer the roster are narrowed to the same list, so a form no longer offers a client the request behind it will refuse. syncAssignedClients is untouched: which ids stick to which role is its decision and it was never the problem. |
||
|
|
61c385e423 |
Delete an account and dispose of its content in one transaction
Deleting a staff or client account is two writes: soft-delete the account, then cascade or reassign the files and folders it owns. All four destroy() paths (Users + Clients, web + API) ran them one after the other with nothing tying them together. If the second write throws, the account is already gone but its content is not handled. The concrete way in is the reassign branch: validate() checks reassign_to_id with exists(active), but apply() re-resolves it with findOrFail() a moment later (AccountContentDeletion:108), so a target deactivated or deleted in between throws — leaving a soft-deleted account whose files still point at it, and a UserDeleted log for a deletion that did not finish. Wrap the delete()+apply() pair in a single DB::transaction() in each of the four destroy() methods. validate() and the authorization guards stay outside it: they are read-only and must be able to reject before anything is written. cascadeDelete()/reassignTo() already open their own transaction, which nests as a savepoint under this one, so the account soft-delete, its activity log, and the content work now commit or roll back together. Tests: a DeletedAccountContent double that reports content to handle and then throws while handling it (tests/Helpers.php) drives one test per destroy() endpoint asserting the account survives the failure and no UserDeleted entry is written; each goes red against the un-wrapped controller. |
||
|
|
e6dc271f27 |
Land a successful create where a create-only role can actually go
Four create flows redirected to the new record's edit page on success, but store is gated by create_* while the edit page is gated by edit_*, and PermissionChecker has no create-implies-edit rule. A role holding create_* without edit_* would create the record -- write, activity log and notifications all run -- and then meet a 403 on the success redirect, with no way to tell the action worked and every reason to submit a duplicate. Categories is reachable with plain UI clicks, since the sidebar shows it from create_categories alone. Keep landing on the edit page for anyone who may edit, and divert only those who can't -- to the create form, which shares store's own gate and is therefore reachable by exactly whoever just created the record; the success toast shows there. The index would not do: Clients/Groups lists are gated by manage_*, which store itself does not require. Implying edit_* from create_* would not do either -- edit has no own/others split here, so it would silently hand a deliberately narrow create-only role edit (two-factor reset included) on every existing record. |
||
|
|
6ab90aee79 |
Say plainly that deleting an account is two calls, not one
StaffAccounts::delete() soft-deletes the row. What happens to the files and folders that account owns is a separate collaborator, and a caller that stops at the first one leaves them pointing at an account that no longer exists. The docblock mentioned "the content-reassignment step" in passing, as context for the return value, which is not the same as saying it is required. Worth stating outright because the mistake hides: validate() returns an empty array when the account owns nothing, so an account with no files deletes perfectly through delete() alone, and keeps doing so until somebody deletes a colleague who had actually done some work. Found while reviewing a design that was about to call delete() on its own. |
||
|
|
6086821d6c |
Close the other three doors that answered a write with a 302
#1680 fixed the redirect rendered from an exception and said plainly what it did not cover: EnsureSetupIsComplete, EnsureAccountIsActive and EnforceTwoFactor answer before HandleInertiaRequests is ever entered, so a response they return never unwinds through Inertia's 302 to 303 upgrade either. Same 405, reached a different way — an account deactivated while its owner was part-way through a form, or one being made to enrol in two-factor. The rule now lives in one place rather than four. Three copies of "if the method is PUT, PATCH or DELETE" is how the fourth caller gets it wrong, and WriteSafeRedirect can carry the explanation of why 303 — which is worth more than the three lines it replaces, because nothing about a bare setStatusCode call says what a browser does with a 302. PUT /timezone is the route the setup test uses: it is one of only two writes a guest can reach and the only one that middleware does not exempt, so the case is real rather than defensive. All three new tests were run against the unfixed middleware and fail there. Extends the work of @denkfabrik-li, who found the gap and wrote it down. |
||
|
|
5bfc5a0883 | Send the browser to the provider, not the XHR | ||
|
|
4ce6793da9 |
Show a new installation's administrator around, once
Setup ended by handing somebody a login form and an empty dashboard. Everything this application can do was one menu away, and which menu was theirs to discover. The first time the administrator signs in to a new installation they now land on a short ordered list of what is worth doing first — add a client, upload a file, group the people who get the same things, choose how the file lists and the email look, point it at a mail server, add the team, check the scheduler — each a link straight to the screen that does it. The list is filtered twice, and both filters matter. By permission, because a link that answers 403 is worse than no link. And by edition: a managed installation is not sent off to configure a mail server somebody else runs, to create staff accounts that are not its to create, or to check a scheduler it does not host. Those three drop out on Cloud and the other five remain. Two steps tick themselves, because the database can answer them: a client exists, a file exists. Nothing else is checkable without guessing — a theme that was never changed looks exactly like one chosen deliberately — and a tick meaning "we assume so" is worse than no tick. The invitation to the Discord is at the very bottom, after the list. Somebody who has just installed this came with a job in mind, and opening with a social invitation is the fastest way to lose them. The marker is raised where a first administrator comes into existence — the setup screen and `projectsend:admin`, so a container provisioned from environment variables is welcomed too — and it is false by default, so an installation that updates into this feature is not congratulated on an install it finished a year ago. RedirectToWhatsNew becomes RedirectToGreeting and answers for both: they are the same interruption, and a second middleware on the same route would have to know about the first to avoid arguing with it. Installing wins; release notes for a version you never ran are the wrong greeting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6ddfc1aa5d |
Greet the administrator once, on the first visit after an update
An update finished and nothing said so. The dashboard looked identical to yesterday's, and whatever the release brought was in a file nobody opens. The first time the installation's administrator opens ProjectSend after an update, they now land on a page that names the version they are on, invites them to the Discord — the same invitation update.sh prints, made again where they are actually looking — and then lays out what the release brought. The notes come from CHANGELOG.md inside the release, not from GitHub: the one moment this page exists for is the moment after an update, possibly on a server with no outbound access, describing code already on disk. Parsed rather than rendered, so nothing in it can become HTML. Once, and to one person. The update happened to the installation, so greeting five staff members — each having to dismiss a page they did not ask for — would turn a pleasant moment into a support question. It goes to the oldest active administrator, which on any installation that went through setup is whoever set it up. No owner flag was invented for this: administrators are equal in authority, and changing that for a greeting is not a trade worth making. Only forwards, and only for a real update. A fresh install has nothing to catch up on, a container reboot has not updated anything, and somebody restoring an older release is dealing with a problem rather than celebrating. Managed installations never see it at all — nobody signed in there performed the update it thanks them for, which is the same gate the System card and About's environment block already carry. The redirect is attached to the dashboard alone, not the web group: it catches a login and the sidebar logo both, without ever interrupting a download to congratulate somebody. Reading the page clears the marker, but the address keeps working — closing it by accident should not be unrecoverable — and About now links to it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6e47d76ba6 |
ProjectSend 2.0.0
Client file sharing, rebuilt from the ground up: a private area per client, resumable uploads, folders, groups and categories, sharing with expiry dates and download limits, comments, file versions, an activity log, a REST API, and sixteen languages. This repository begins here. ProjectSend 2 was developed privately, and that development history is not published — the previous generation remains available, with its own history, at projectsend/legacy. Free software under the GNU General Public License v2, or (at your option) any later version. |