4 Commits

Author SHA1 Message Date
ignacionelson 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.
2026-09-09 07:32:26 -03:00
ignacionelson 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.
2026-09-08 19:00:46 -03:00
denkfabrik-li 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.
2026-08-28 06:40:50 +02:00
ignacionelson 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.
2026-08-14 01:38:12 -03:00