projectsend/projectsend: Social-account session can create a permanent local credential without fresh provider reauthentication
What fixes it
- 2.7.0projectsend/projectsend, composerGitHub
composer require "projectsend/projectsend:^2.7.0"
A fixed version on one release line is not the upgrade target for every line. Check your exact version and any vendor backports.
Description
Summary
Mallory, who controls a stolen but valid web session for Alice's Social account, can create an attacker-chosen local password without knowing Alice's provider credentials, without controlling the identity provider, and without completing fresh provider reauthentication. ProjectSend changes the account's authoritative authentication source from Social to Local and preserves Mallory's initiating session.
Mallory can then use the password she just created to satisfy ProjectSend's password-confirmation middleware. The confirmed session can initiate and complete enrollment of an attacker-controlled second factor when ProjectSend requires one. Because the account is now classified as Local, ProjectSend also permits removal of Alice's last connected provider. The temporary stolen session has therefore created a permanent local credential that remains useful after the original session or provider access is revoked.
The full chain was executed against the running ProjectSend v2.6.0 application twice, with an independent clean-snapshot restore before each run. Every transition used an actual HTTP request and response: a copied valid session set the first password without current_password, retained the initiating session, confirmed the new password, initiated 2FA, completed enrollment with a real generated TOTP code, displayed recovery codes, removed the last provider, logged out, and performed a fresh local login in a new cookie jar. The fresh password login correctly entered the newly enrolled 2FA challenge; an attacker-controlled recovery code from the enrollment response completed authentication and the resulting session reached /my-files with HTTP 200. Both runs matched.
Relationship to the intended feature and PR #1756
This report does not claim that allowing a legitimate provider-authenticated user to establish a local password is inherently unsafe. The affected behavior was deliberately introduced in commit ff9ad1074208b789790a9361dbb6c82d08c48de5, titled “Let an account that signs in through a provider set a password,” to resolve a lockout in which provider-provisioned accounts could not set a password and therefore could not pass the password-confirmation gate required for two-factor enrollment. The commit and its password-controller and feature-test diff were independently verified. It was released in ProjectSend v2.5.0 and remains present in v2.6.0.
The vulnerability is that ProjectSend treats possession of any existing Social-account session as sufficient authority to create that durable credential. It does not require fresh authentication with the linked provider, a single-use recovery proof, or another credential independent of the session being upgraded.
This is a regression or variant of the security invariant documented in PR #1756, titled “Make linking a provider re-prove the password.” The PR exists, was merged as f4fd1949919c1f904dab4856ec3d08ec071f4927, and its description and route diff place provider connection behind password.confirm because a connected provider is a durable login credential that outlives the session which created it. Its diff deliberately leaves provider disconnection outside that middleware. The first-password path creates another durable login credential but does not apply an equivalent fresh-authentication requirement.
CVSS Privileges Required rationale
The PR:L value was re-derived against published NVD practice rather than treating session theft as an unscored assumption. CVE-2021-36563 explicitly describes exploitation with either valid credentials or a hijacked session and is scored PR:L by NVD. CVE-2017-14005, an authenticated unverified-password-change flaw enabling future access, is also scored PR:L. NVD has inconsistent edge cases—for example, CVE-2023-28003 assigns PR:N to continued use of an already hijacked session, while its CNA assigns PR:L—but that record concerns insufficient session expiration rather than an authenticated credential-mutation endpoint.
The closest endpoint and starting-authority precedents therefore support PR:L. This also follows the CVSS v3.1 definition: Privileges Required measures authority possessed before exploiting the vulnerable component, and the stolen Alice session supplies basic user capabilities. The method by which Mallory obtained the session is outside this finding; it is a stated prerequisite, not part of the vulnerable password-setting operation. The vector remains CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:L (8.3 High).
This relationship is provided proactively to assist duplicate triage. The intended first-password feature is public; the security consequence reported here is the conversion of a stolen session into permanent account ownership and displacement of Alice's provider authentication with an attacker-controlled credential.
Details
The password-setting route in routes/settings.php:60-64 is inside the ordinary authenticated-session group and adds only rate limiting. It does not require password.confirm, fresh provider authentication, or another proof independent of the existing session:
Route::put('settings/password', [PasswordController::class, 'update'])
->middleware('throttle:6,1,password-update')
->name('password.update');In app/Http/Controllers/Settings/PasswordController.php:54-66, LDAP accounts are refused, but a Social account is explicitly permitted to omit current_password:
abort_if($user->auth_source === AuthSource::Ldap, 403);
$setsFirstPassword = $user->auth_source === AuthSource::Social;
$validated = $request->validate([
'current_password' => $setsFirstPassword ? ['nullable'] : ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],
]);This distinction is a valid usability requirement—provider-provisioned accounts hold an unknown generated password—but the omitted password check is not replaced with fresh authentication at the linked provider. Mallory's stolen session is therefore the only existing proof required to choose Alice's new password.
In PasswordController.php:68-90, ProjectSend hashes Mallory's chosen password, changes Alice's auth_source to Local, saves both values, and calls Auth::logoutOtherDevices():
$attributes = ['password' => Hash::make($validated['password'])];
if ($setsFirstPassword) {
$attributes['auth_source'] = AuthSource::Local;
}
$user->forceFill($attributes)->save();
Auth::logoutOtherDevices($validated['password']);logoutOtherDevices() invalidates Alice's other sessions, but its current-session password hash is re-stamped so the session performing the change remains authenticated. This protects Mallory's stolen session while causing other sessions to fail Laravel's session-authentication check after their next request.
The new password is also accepted by the ordinary password-confirmation endpoint. app/Http/Controllers/Auth/ConfirmablePasswordController.php:56-67 verifies the submitted password against the account's current authentication source and records a successful confirmation in the same session:
if (! $passwords->verify($user, (string) $request->string('password'))) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
}
$request->session()->put('auth.password_confirmed_at', time());The isolated test run directly confirmed this step using Mallory's newly selected local password.
ProjectSend correctly recognizes in routes/settings.php:96-106 that two-factor changes must not be authorized by a stolen session alone. Enabling, disabling, and regenerating recovery codes are all protected by password.confirm:
Route::middleware('password.confirm')->group(function () {
Route::post('settings/two-factor', [TwoFactorEnrollmentController::class, 'store'])->name('two-factor.enable');
Route::post('settings/two-factor/recovery-codes', [TwoFactorEnrollmentController::class, 'regenerateRecoveryCodes'])
->name('two-factor.recovery-codes');
Route::delete('settings/two-factor', [TwoFactorEnrollmentController::class, 'destroy'])->name('two-factor.disable');
});That control no longer distinguishes Alice from Mallory because Mallory created the password that now satisfies it. A normal provider-provisioned Social account does not necessarily begin with enabled 2FA; this report relies on the attacker's ability to enroll a factor after creating the password, not on an assumption that a pre-existing factor is present. If a migrated or administratively created Social-state account already has 2FA, the same confirmed session also reaches the disable route.
Finally, app/Modules/Identity/Http/Controllers/ConnectedAccountsController.php:90-107 prevents removal of the last provider only while the account is not Local:
$remaining = SocialAccount::query()
->where('user_id', $user->getKey())
->where('provider', '!=', $case->value)
->count();
if ($remaining === 0 && $user->auth_source !== AuthSource::Local) {
throw ValidationException::withMessages([
'provider' => __('This is the only way you can sign in. Set a password first, then disconnect :provider.', [
'provider' => $case->label(),
]),
]);
}
$link->delete();The password update has already changed auth_source to Local, so this guard allows Mallory to delete Alice's last provider link. Provider disconnection itself has no password-confirmation middleware.
The root cause is that AuthSource::Social suppresses current-credential verification without substituting a fresh provider or recovery proof before a session is allowed to create a durable local credential, change the authoritative authentication source, and preserve itself.
Proof of concept
Perform these steps only on a disposable local ProjectSend installation. They change authentication credentials and can remove the account owner's original sign-in methods.
- Check out ProjectSend v2.6.0 at commit
a9b17ddc1e784ccb8885aecf0a29a7b13c362f72and prepare its normal test environment.
- As a preliminary control, run the repository's existing provider-password feature tests:
php artisan test tests/Feature/Identity/ProviderAccountPasswordTest.php
The preserved isolated-lab run recorded:
PASS Tests: 6 passed (50 assertions)
These tests confirm all of the following:
- a user whose
auth_sourceisSocialcan submitPUT /settings/passwordwithoutcurrent_password; - the submitted password becomes the account's valid local password;
auth_sourcechanges fromSocialtoLocal;- ordinary Local accounts still have to provide the password they are replacing;
- LDAP accounts are refused;
- the attacker-chosen password is accepted at
POST /confirm-password; - the resulting confirmation permits the password-protected two-factor enrollment route.
- For the end-to-end HTTP proof, create an account named Alice whose
auth_sourceisSocial, give it one connected provider, establish a legitimate post-login session, and copy that authenticated cookie jar to Mallory's request context to model session theft. From this point onward, Mallory's only starting capability is the copied session; the setup credential is not used in any attack or fresh-login request. A request toGET /settings/profilewith the copied jar returned HTTP 200 in both runs.
- Using Alice's stolen session and its normal CSRF token, submit the following request without a
current_passwordfield:
PUT /settings/password Content-Type: application/x-www-form-urlencoded
password=mallory-chosen-password& password_confirmation=mallory-chosen-password
In both runtime runs the response was HTTP 302 to /settings/profile. Database state confirmed that the password was replaced, auth_source became Local, and the initiating session remained valid; a subsequent GET /settings/profile returned HTTP 200.
- Continue using the same session and submit:
POST /confirm-password Content-Type: application/x-www-form-urlencoded
password=mallory-chosen-password
In both runs the response was HTTP 302 to /dashboard, confirming that the newly created password satisfied the actual password-confirmation endpoint.
- With that password-confirmed session, initiate enrollment of Mallory's own second factor:
POST /settings/two-factor
In both runs this request returned HTTP 302. The validation then read the pending secret created for this disposable account, generated a current TOTP code from it, and submitted that real code to the confirmation endpoint:
POST /settings/two-factor/confirm Content-Type: application/x-www-form-urlencoded
code=<valid code generated from Mallory's pending secret>
In both runs confirmation returned HTTP 302. GET /settings/two-factor then returned HTTP 200 and displayed recovery codes, establishing that enrollment completed through the running application. If installation policy enforces 2FA, Mallory can therefore satisfy enforcement using her own factor. A Social-state account that already carries enabled 2FA is an edge state rather than a required exploit precondition; in that state, the newly confirmed password also authorizes DELETE /settings/two-factor.
- Remove Alice's final connected provider, using Google as an example provider configured for the disposable installation:
DELETE /settings/connected-accounts/google
In both runs the response was HTTP 302 and the connected-provider record was absent afterward. The request was permitted because the earlier password operation changed auth_source to Local; the final-provider guard therefore no longer rejected the deletion.
- End the original session and use a new cookie jar to sign in using Alice's account identifier and
mallory-chosen-password:
POST /logout -> HTTP 302 GET /login -> HTTP 200 POST /login -> HTTP 302, Location: /two-factor-challenge
Because Mallory enrolled 2FA in step 6, the password login correctly reached the factor challenge. Submit one recovery code displayed to Mallory after enrollment:
GET /two-factor-challenge -> HTTP 200 POST /two-factor-challenge -> HTTP 302, Location: /dashboard recovery_code=<attacker-controlled recovery code> GET /my-files -> HTTP 200
The database and cache were restored to the same clean baseline and steps 3–8 were repeated independently. Both runs produced the same status codes, redirects, state transitions, completed TOTP enrollment, provider deletion, fresh local password login, attacker-factor completion, and client-portal access. The entire chain was therefore executed end-to-end twice; none of its final transitions is inferred only from a unit-test name or source reading.
No live identity provider or public ProjectSend deployment was contacted during testing.
Impact
A stolen Social-account session is normally temporary: Alice or an administrator can invalidate the session, and revoking the linked provider account should prevent future provider logins. This flaw allows Mallory to convert that temporary access into a local password known only to her.
After the transition, Mallory can pass password-confirmation checks using the credential she created, enroll an attacker-controlled second factor when required, and delete Alice's last connected provider because the account is now Local. Mallory can then return through normal local login after the original stolen session has expired or been revoked. In the less common case where a Social-state account already has 2FA, the same attacker-created password also authorizes its removal, but that edge case is not required for persistent takeover.
The persistent takeover statement was reproduced twice with an ended original session and a completely new login cookie jar. Each fresh password login entered the attacker-enrolled 2FA challenge, and an attacker-controlled recovery code completed authentication to a client session that reached /my-files with HTTP 200.
For a client account, this exposes the private files, comments, profile data, and other resources assigned to that client for as long as the attacker retains the new password. If a Social login has been connected to a more privileged account, the impact follows that account's existing privileges. The assessment did not demonstrate privilege beyond Alice's account, compromise of the identity provider, bypass of TLS, code execution, or installation-wide administrator access from an ordinary client account.
Patches / Workarounds
No patch is available yet. Proposed remediation:
Require proof independent of the current ProjectSend session before converting a Social account to Local. Acceptable designs include fresh authentication with the same linked provider subject or a single-use account-recovery proof delivered through an already verified recovery channel. Merely checking that the existing session was originally created through a provider does not provide fresh proof.
Perform the password write and auth_source transition atomically. After a successful conversion, invalidate every existing ProjectSend session, including the initiating session, and require a new login using the newly established credential.
Do not allow a newly created local password to immediately authorize replacement or removal of the account's established authentication methods without additional assurance. As defense in depth, require an existing provider proof, existing TOTP or recovery code when one exists, or an independently authorized recovery flow before changing two-factor state or removing the last provider during the conversion process.
Add regression tests covering:
- a Social session alone cannot set a first local password;
- a matching, fresh provider-subject authentication or single-use recovery proof permits the transition;
- a mismatched provider subject is rejected;
- the successful transition invalidates the initiating session as well as other sessions;
- an attacker-created local password cannot immediately authorize replacement of account factors or removal of the last provider;
- Local accounts still require their current password;
- LDAP accounts remain unable to create an ineffective local password through this route.
As an operational workaround, administrators can disable Social login or avoid provider-provisioned accounts until a patch is available. This reduces exposure but does not repair accounts that have already been converted or detect prior exploitation. Administrators should review password-update, authentication-source, two-factor, and provider-unlink audit events occurring close together for the same account.
Coordinated disclosure
ProjectSend's SECURITY.md directs vulnerability reports through GitHub's private vulnerability-reporting channel and does not specify a disclosure deadline. I propose coordinated disclosure within 90 days of receipt, or earlier after a fix is released and users have had a reasonable opportunity to update. I am available to validate a proposed fix before release.
Credit
- Researcher: WHS4th Team TokenManiZo
- Contact: afjss678@gmail.com
Text from GitHub Advisory Database
Affected versions 1
- projectsend/projectsend, composer>= 2.5.0, <= 2.6.0GitHub