mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-23 20:06:20 +00:00
305c79bc96
Reported by @hackchang (GHSA-c6h9-hcm7-j3x9). GHSA-r3hg-3fxw-rcmr scoped the group controllers; invitations were written afterwards and were not, so the same reach was open through a different door. A client-scoped staff member with `create_clients` could read every group's id and name off the invitation form, name any of them on an invitation, and have the invited client added to it at redemption — a group whose files they cannot see and whose members are not theirs. The ordinary way to do that, adding a member to a group, refuses on StaffLibraryScope::allowsGroupMembership(); the invitation path never asked. Three places, because the hole had three halves: - the form lists `$this->scope->groups($viewer)`, as GroupsController already does; - the request is validated against those groups rather than every group there is, since a request need not come from the form; - redemption asks allowsGroupMembership() of the invitation's sender before writing the membership. The last one is the one that matters. An invitation is a grant that lands days later, when the sender is not present to be checked, and the ones written before today are still outstanding. A refused membership is dropped and logged rather than failing the redemption: the account is what the person holding the link came for, and it is theirs either way. An invitation whose sender has since been deleted keeps its group — there is no longer a reach to exceed.
250 lines
11 KiB
PHP
250 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Clients\Http\Controllers;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\User;
|
|
use App\Modules\Audit\Action;
|
|
use App\Modules\Audit\ActivityLogger;
|
|
use App\Modules\Clients\ClientProvisioning;
|
|
use App\Modules\Clients\Models\Invitation;
|
|
use App\Modules\Clients\Notifications\ClientInvitationNotification;
|
|
use App\Modules\Files\Access\StaffLibraryScope;
|
|
use App\Modules\Platform\Settings\Setting;
|
|
use App\Modules\Platform\Settings\Settings;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Notification;
|
|
use Illuminate\Validation\Rules\Password;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Inertia\Inertia;
|
|
use Inertia\Response;
|
|
|
|
/**
|
|
* A client redeeming the link an invitation emailed them — the invited
|
|
* counterpart to RegistrationController's public form. Reaching the form
|
|
* at all is the whole difference: it is gated by a specific address
|
|
* having a live token rather than by Setting::ClientsCanRegister, and the
|
|
* account that comes out of it is provisioned exactly the way any other
|
|
* self-registration is (ClientProvisioning), so an installation with
|
|
* auto-approve off still puts one in the same queue as everybody else.
|
|
*/
|
|
class InvitationRedemptionController extends Controller
|
|
{
|
|
/**
|
|
* How many times an invitation may be renewed by the person holding
|
|
* it, before a staff member has to send a new one.
|
|
*
|
|
* Without a limit, expiry stops meaning anything: whoever holds a dead
|
|
* link can re-arm it, so the window an operator configured is only as
|
|
* short as the longest anybody bothers to wait. Three is enough for
|
|
* somebody who genuinely keeps missing it, and short enough that a link
|
|
* sitting somewhere it should not be — a forwarded thread, a shared
|
|
* inbox, a mailbox that changed hands — eventually stops answering on
|
|
* its own, as the expiry setting says it will.
|
|
*
|
|
* Revoking is the immediate version of the same decision, and does not
|
|
* wait for this: see InvitationController::destroy().
|
|
*/
|
|
private const SELF_RESEND_LIMIT = 3;
|
|
|
|
public function __construct(
|
|
private readonly ClientProvisioning $provisioning,
|
|
private readonly ActivityLogger $activity,
|
|
private readonly Settings $settings,
|
|
private readonly StaffLibraryScope $scope,
|
|
) {}
|
|
|
|
public function create(Request $request): Response
|
|
{
|
|
$token = (string) $request->route('token');
|
|
$invitation = $this->findUsable($token);
|
|
|
|
return Inertia::render('auth/invite', [
|
|
'token' => $token,
|
|
'email' => $invitation->email ?? '',
|
|
'name' => $invitation->name ?? '',
|
|
'status' => $request->session()->get('status'),
|
|
// Same shape as NewPasswordController::create()'s $expired: one
|
|
// answer for "no such token" and "spent or expired token",
|
|
// because telling them apart would tell a guesser which
|
|
// addresses this installation has invited.
|
|
'expired' => $invitation === null,
|
|
]);
|
|
}
|
|
|
|
public function store(Request $request): RedirectResponse
|
|
{
|
|
$validated = $request->validate([
|
|
'token' => ['required', 'string'],
|
|
'name' => ['required', 'string', 'max:255'],
|
|
'password' => ['required', 'confirmed', Password::defaults()],
|
|
]);
|
|
|
|
$invitation = $this->findUsable($validated['token']);
|
|
|
|
if ($invitation === null) {
|
|
throw ValidationException::withMessages([
|
|
'token' => [__('This invitation is no longer valid. Ask whoever invited you to send a new one.')],
|
|
]);
|
|
}
|
|
|
|
// An invitation is live for days, and the address it names can be
|
|
// taken in the meantime — staff got impatient and made the account
|
|
// by hand, or the person registered through the public form. The
|
|
// unique index on users.email spans trashed rows, so provision()
|
|
// would raise a QueryException here rather than refusing: a 500 on
|
|
// the screen of somebody who has just typed a password. Every other
|
|
// caller with no form to validate asks this first, for this reason
|
|
// — see ClientProvisioning::addressIsFree().
|
|
if (! $this->provisioning->addressIsFree($invitation->email)) {
|
|
throw ValidationException::withMessages([
|
|
// Says what happened, because the person holding this link
|
|
// already knows the address is theirs — it is the one the
|
|
// invitation was sent to. There is nothing here to disclose
|
|
// that the invitation itself did not.
|
|
'token' => [__('An account already exists for this email address. Try signing in instead, or reset your password.')],
|
|
]);
|
|
}
|
|
|
|
$client = $this->provisioning->provision(
|
|
name: $validated['name'],
|
|
email: $invitation->email,
|
|
password: $validated['password'],
|
|
action: Action::ClientInvitationRedeemed,
|
|
// Always, regardless of Setting::ClientsAutoApprove: an
|
|
// invitation names a specific address a staff member already
|
|
// decided to let in, which is the trust an approval queue
|
|
// exists to establish for the address it never named.
|
|
autoApprove: true,
|
|
storageQuotaMb: $invitation->storage_quota_mb,
|
|
);
|
|
|
|
if ($invitation->group !== null && $this->mayJoin($invitation, $client)) {
|
|
$invitation->group->members()->syncWithoutDetaching([$client->id]);
|
|
}
|
|
|
|
$invitation->forceFill(['status' => Invitation::STATUS_REDEEMED])->save();
|
|
|
|
return redirect()->route('login')->with(
|
|
'status',
|
|
$client->account_requested
|
|
? __('Your account has been created. You will be able to log in once it is approved.')
|
|
: __('Your account has been created. You can log in now.'),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Resends a fresh link to the same address without anybody deciding
|
|
* to — the invited person asked for it, not an administrator. A spent
|
|
* or genuinely unknown token answers the same as an expired one: this
|
|
* is the one door on the flow an anonymous visitor can knock on
|
|
* repeatedly, so it must not become a way to learn which addresses
|
|
* were ever invited.
|
|
*
|
|
* The new link goes to the address on the invitation, never to whoever
|
|
* asked, so holding a leaked URL gets nobody a working one. What this
|
|
* does spend is the operator's expiry window, which is why
|
|
* SELF_RESEND_LIMIT caps how often it can be spent, and why staff can
|
|
* end it outright by revoking.
|
|
*/
|
|
public function resend(Request $request): RedirectResponse
|
|
{
|
|
$token = (string) $request->route('token');
|
|
$invitation = $this->findUsable($token, includingExpired: true);
|
|
|
|
// Spent, unknown, revoked, or renewed as often as it may be: all
|
|
// four answer the same sentence below, and none of them sends
|
|
// anything. Only the last of the four is a link whose holder did
|
|
// nothing wrong, and telling them apart here would tell a guesser
|
|
// which addresses this installation has invited.
|
|
if ($invitation !== null && $invitation->resends < self::SELF_RESEND_LIMIT) {
|
|
$fresh = Invitation::issue(
|
|
email: $invitation->email,
|
|
name: $invitation->name,
|
|
group: $invitation->group,
|
|
invitedBy: $invitation->invitedBy,
|
|
expiresAt: now()->addHours((int) $this->settings->get(Setting::ClientInvitationExpiryHours)),
|
|
storageQuotaMb: $invitation->storage_quota_mb,
|
|
resends: $invitation->resends + 1,
|
|
);
|
|
|
|
Notification::route('mail', $fresh->email)->notify(
|
|
new ClientInvitationNotification($fresh->name ?? $fresh->email, $fresh->token),
|
|
);
|
|
|
|
// Nobody is signed in, so this records no actor — which is the
|
|
// point of logging it. Sending and redeeming were already in
|
|
// the trail; renewing was the one step that moved an invitation
|
|
// along with no staff member behind it and left no trace.
|
|
// Bounded by the limit above, so an anonymous door cannot flood
|
|
// the log.
|
|
$this->activity->log(Action::ClientInvitationResent, context: ['email' => $fresh->email]);
|
|
}
|
|
|
|
return back()->with('status', __('If that invitation can still be resent, a new one is on its way.'));
|
|
}
|
|
|
|
/**
|
|
* The invitation $token names, if it is still one store() would
|
|
* accept — pending and not expired, unless $includingExpired asks for
|
|
* the resend door's wider question instead.
|
|
*/
|
|
/**
|
|
* Whether the membership this invitation carries is one its sender may
|
|
* grant — the same question GroupMembersController::store() asks before
|
|
* adding anybody to a group.
|
|
*
|
|
* Asked here as well as when the invitation was written, and this is
|
|
* the half that matters: an invitation is a grant that lands days
|
|
* later, when the person who sent it is not present to be checked, and
|
|
* one written before this check existed can still be outstanding. A
|
|
* refused membership is dropped rather than failing the redemption —
|
|
* the account is what the person holding the link came for, and it is
|
|
* theirs either way.
|
|
*
|
|
* An invitation whose sender is gone (the account was deleted and the
|
|
* column nulls out) keeps its group: there is no longer a reach to
|
|
* exceed, and dropping it would quietly undo what an administrator
|
|
* arranged.
|
|
*/
|
|
private function mayJoin(Invitation $invitation, User $client): bool
|
|
{
|
|
$inviter = $invitation->invitedBy;
|
|
$group = $invitation->group;
|
|
|
|
if ($inviter === null || $group === null) {
|
|
return true;
|
|
}
|
|
|
|
if ($this->scope->allowsGroupMembership($inviter, $group, $client)) {
|
|
return true;
|
|
}
|
|
|
|
Log::warning('An invitation named a group its sender may not add anybody to; the account was created without it.', [
|
|
'invitation' => $invitation->id,
|
|
'group' => $group->id,
|
|
]);
|
|
|
|
return false;
|
|
}
|
|
|
|
private function findUsable(string $token, bool $includingExpired = false): ?Invitation
|
|
{
|
|
$invitation = Invitation::query()->pending()->where('token', $token)->first();
|
|
|
|
if ($invitation === null) {
|
|
return null;
|
|
}
|
|
|
|
if (! $includingExpired && $invitation->isExpired()) {
|
|
return null;
|
|
}
|
|
|
|
return $invitation;
|
|
}
|
|
}
|