Files
projectsend/app/Http/Middleware/HandleInertiaRequests.php
T
denkfabrik-li 8a6543073b Group membership is a library boundary, not just a list
The four routes that edit a group's membership -- add and remove, web
and API -- contain no authorization call of any kind. `can:edit_groups`
in front of them is the whole of it, and a permission is not a boundary.

The authorization sweep looked at these and let them stand, on the
grounds that groups are installation-wide by design: GroupsController
::index lists every group unfiltered, so list and single-object access
agree, and there is no listing/direct-access mismatch to fix. That is
true, and it is the answer to the question of who may *see* a group.
This is a different question: what a write to one *does*.

Joining a group hands the new member everything shared with it. When
that member is one of a client-scoped staff member's own clients,
File::scopeVisibleToClient hands the same content straight back to them
-- that scope is what StaffLibraryScope::files() is built out of. So the
one write turns a file they get a 403 on into a file in their library,
and the download that follows is a 200. ResolvesShareTargets draws that
line on the sharing path through canAssignGroup(); nobody drew it on the
membership path, and canAssignGroup() is *derived from membership*, so
whoever may edit the list also decides what the list entitles them to.

StaffLibraryScope::allowsGroupMembership answers it directly instead of
through the derived predicate, which is the wrong tool here twice over.
Membership asks about reach, so it checks reach: the client must be one
this staff member holds, and the group must not already reach past their
library -- no file assigned to it, and no folder shared with it, outside
StaffLibraryScope. A group nothing has been shared with passes trivially,
which matters, because canAssignGroup() would have said no to a group
that has no members yet and left a scoped staff member unable to put the
first client into one they had just created.

The same write has a second door. MembershipRequestsController::approve
joins a client to a group with identical consequences, under
`approve_groups_memberships_requests`, and deny() decides about somebody
else's client and emails them about it. Both go through the same
boundary, answering 404 to match the guard already above approve().

The queue and its sidebar badge are narrowed to the clients the viewer
holds, through one scope on the model that both read -- the rule the
comment badge in HandleInertiaRequests already states two branches down
("a client-scoped staff member is not shown a number they cannot act
on"), and the reason VisibleCommentScope owns its own pendingTotal()
rather than leaving the middleware to count for itself. Each row carries
the client's name and email, so an unnarrowed queue was also handing
those over for clients outside the roster. Unscoped staff still see every
pending request.

That narrowing is on the client, not on the group: whether a group is
reachable depends on what is shared with it, which is not a question to
ask row by row in a listing. A scoped viewer may therefore still be
shown a request they would be refused on -- one of their own clients
asking to join a group out of their reach. The names were the part that
leaked.

Unscoped staff are unaffected throughout -- both halves of the predicate
are true for them by construction. No seeded role reaches this: Client
Manager is the only client-scoped role that ships, and it holds no group
permissions, so a custom role is needed to get here at all.

The published API document gains a 403 on both member routes.
Regenerated with php artisan scramble:export; Scramble reads abort_unless
out of the method body but not out of a private helper, which is why the
guard is written out at each of the four call sites rather than shared.
2026-08-26 08:49:48 +02:00

280 lines
11 KiB
PHP

<?php
namespace App\Http\Middleware;
use App\Models\User;
use App\Modules\Comments\Access\VisibleCommentScope;
use App\Modules\Groups\Models\MembershipRequest;
use App\Modules\Identity\Passwords\PasswordPolicy;
use App\Modules\Identity\Permissions\Permission;
use App\Modules\Identity\Permissions\PermissionChecker;
use App\Modules\Identity\Social\SocialSettings;
use App\Modules\Identity\UserType;
use App\Modules\Notifications\InAppNotification;
use App\Modules\Platform\Attribution\Attribution;
use App\Modules\Platform\Capabilities\CapabilityRegistry;
use App\Modules\Platform\Captcha\Captcha;
use App\Modules\Platform\Installation\Installation;
use App\Modules\Platform\Localization\LocaleRegistry;
use App\Modules\Platform\Localization\TimezoneRegistry;
use App\Modules\Platform\OfficialLinks;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Modules\Platform\Updates\LatestReleaseInfo;
use App\Modules\Platform\Updates\RunningCodeState;
use Illuminate\Foundation\Inspiring;
use Illuminate\Http\Request;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
{
/**
* The root template that's loaded on the first page visit.
*
* @see https://inertiajs.com/server-side-setup#root-template
*
* @var string
*/
protected $rootView = 'app';
/**
* Determines the current asset version.
*
* @see https://inertiajs.com/asset-versioning
*/
public function version(Request $request): ?string
{
return parent::version($request);
}
/**
* Define the props that are shared by default.
*
* @see https://inertiajs.com/shared-data
*
* @return array<string, mixed>
*/
public function share(Request $request): array
{
[$message, $author] = str(Inspiring::quotes()->random())->explode('-');
$capabilities = app(CapabilityRegistry::class);
return array_merge(parent::share($request), [
...parent::share($request),
'name' => app(Settings::class)->get(Setting::SiteName),
'quote' => ['message' => trim((string) $message), 'author' => trim((string) $author)],
'auth' => [
'user' => $request->user(),
'permissions' => ($user = $request->user()) !== null
? app(PermissionChecker::class)->grantedKeys($user)
: [],
],
'edition' => $capabilities->edition()->value,
'noindex' => app(Settings::class)->get(Setting::DiscourageSearchIndexing),
'version' => config('projectsend.version'),
// Resolved rather than handed over raw: each edition has its
// own front door, and a managed installation offers no
// donation link at all. See OfficialLinks.
'links' => app(OfficialLinks::class)->toArray(),
// Whether the client- and visitor-facing surfaces name
// ProjectSend. True everywhere unless a package answers
// otherwise — see ResolvingAttribution. Staff surfaces
// ignore this and always show it.
'attribution' => app(Attribution::class)->visible(),
'capabilities' => $capabilities->enabledKeys(),
// Shared rather than passed by each page: the sign-in buttons,
// the registration form and the Connected accounts nav entry
// all need the same list, and a nav entry to a screen with
// nothing on it is worse than no entry.
'social_login' => SocialSettings::available(),
// Shared for the same reason: seven unrelated surfaces — three
// auth pages and the file page of each public theme — need the
// identical provider and site key. Null when nothing is
// configured, and never the secret.
'captcha' => app(Captcha::class)->forDisplay(),
// Shared for the same reason again: eight forms across the auth
// pages, the account settings and the staff/client editors all
// ask somebody to choose a password, and each has to be able to
// say what this installation will accept *before* the submit
// rather than only in the error afterwards.
'password_policy' => app(PasswordPolicy::class)->descriptor(),
'pending' => $this->pendingCounts($request),
'update_notice' => $this->updateNotice($request),
'code_notice' => $this->codeNotice($request),
'locale' => app()->getLocale(),
// The clock this viewer reads dates by, and whether it is a
// choice or a fallback. The frontend needs both: the first to
// format with, the second because a viewer still on the
// fallback is one whose browser we have not asked yet — see
// timezone-detector.tsx.
'timezone' => app(TimezoneRegistry::class)->resolve($request->user()),
'timezone_is_explicit' => $request->user()?->timezone !== null,
'locales' => app(LocaleRegistry::class)->enabled(),
'locales_disabled' => $this->disabledLocaleCount($request),
'translations' => $this->translations(app()->getLocale()),
'flash' => [
'success' => $request->session()->get('success'),
'error' => $request->session()->get('error'),
],
]);
}
/**
* Pending-approval counts for sidebar badges, computed only for
* viewers holding the matching approval permission.
*
* @return array<string, int>
*/
protected function pendingCounts(Request $request): array
{
$user = $request->user();
if ($user === null) {
return [];
}
$checker = app(PermissionChecker::class);
$counts = [];
if ($checker->allows($user, Permission::ApproveAccountRequests)) {
$counts['account_requests'] = User::query()
->where('type', UserType::Client)
->where('account_requested', true)
->count();
}
if ($checker->allows($user, Permission::ApproveGroupsMembershipsRequests)) {
// Narrowed like the queue it badges, and by the same scope —
// a client-scoped staff member is not shown a number they
// cannot act on. Same rule the comments badge below states.
$counts['membership_requests'] = MembershipRequest::query()
->pending()
->whereHas('user')
->whereHas('group')
->approvableBy($user)
->count();
}
if ($checker->allows($user, Permission::ModerateComments)) {
// Library-scoped, like the screen it badges: a client-scoped
// staff member is not shown a number they cannot act on. The
// permission check inside pendingTotal is therefore redundant
// here and deliberately kept — the scope owns that rule, and
// this middleware should not be a second place it lives.
$counts['comments'] = app(VisibleCommentScope::class)->pendingTotal($user);
}
// Unlike the counts above, every authenticated user (staff or
// client) has their own personal notifications — no permission
// gate here.
$counts['notifications_unread'] = InAppNotification::query()
->where('user_id', $user->id)
->whereNull('read_at')
->count();
return $counts;
}
/**
* How many installed translation catalogues are currently switched off,
* for the "N more languages available" line the switcher shows above its
* link to the Languages screen.
*
* Zero for everyone who cannot act on it — clients, anonymous visitors on
* the public pages and the login screen, and staff without edit_settings.
* A dead-end link is worse than none, and how an installation is
* configured is nobody else's business.
*/
protected function disabledLocaleCount(Request $request): int
{
$user = $request->user();
if ($user === null || ! $user->isStaff() || ! app(PermissionChecker::class)->allows($user, Permission::EditSettings)) {
return 0;
}
$locales = app(LocaleRegistry::class);
return count($locales->installed()) - count($locales->enabled());
}
/**
* The topbar's persistent "update available" icon — unlike the
* dashboard System card (informational, gated only on
* view_system_info), this is the actionable surface, so it's
* restricted to staff who actually hold manage_updates.
*
* Carries install_kind so the dialog can print instructions this
* particular server can actually follow — see Installation. Attached
* here rather than shared globally: it describes the deployment, which
* is nobody's business but the staff who maintain it.
*
* @return array{version: string, title: string, notes: string, url: string, published_at: string, install_kind: string}|null
*/
protected function updateNotice(Request $request): ?array
{
$user = $request->user();
if ($user === null || ! app(PermissionChecker::class)->allows($user, Permission::ManageUpdates)) {
return null;
}
$release = app(LatestReleaseInfo::class)->current();
return $release === null
? null
: [...$release, 'install_kind' => app(Installation::class)->kind()->value];
}
/**
* Whether this process is running the code the installation was last
* updated to — see RunningCodeState for the failure it catches.
*
* Gated on view_system_info rather than manage_updates: the latter is
* edition-gated (Capability::SystemUpdates), and a server executing
* code that does not match its own database is not a feature anyone
* buys, it is a fact about the machine.
*
* @return array{reason: string, applied: string, running: string, applied_at: string, install_kind: string}|null
*/
protected function codeNotice(Request $request): ?array
{
$user = $request->user();
if ($user === null || ! app(PermissionChecker::class)->allows($user, Permission::ViewSystemInfo)) {
return null;
}
return app(RunningCodeState::class)->current();
}
/**
* App strings use English text as the translation key, so "en" ships no
* messages — the key itself is the fallback.
*
* Asked of the framework's own loader rather than read out of
* lang/{locale}.json directly, so that a package which registers its
* catalogue with loadJsonTranslationsFrom() reaches the frontend too.
* Reading the file worked for as long as every translatable string
* belonged to this repository; the companion packages own screens of
* their own, and theirs were rendering in English in every language
* because their catalogue never got this far.
*
* Precedence comes from the loader and is the useful way round: an
* installation's own lang/{locale}.json is merged last and therefore
* wins, so a package string can be overridden locally.
*
* @return array<string, string>
*/
protected function translations(string $locale): array
{
if ($locale === 'en') {
return [];
}
/** @var array<string, string> */
return app('translator')->getLoader()->load($locale, '*', '*');
}
}