Files
projectsend/app/Modules/Files/Http/Resources/Api/FileResource.php
T
ignacionelson 7da4635f13 Say which clients a scoped staff member may be told about
A staff member limited to their own assigned clients could read the names
and ids of clients on nobody's roster but their own, out of ordinary file
metadata.

The file boundary was never wrong. Sharing means a file can legitimately
reach a scoped viewer through client A while client B uploaded it, or
while B also receives it -- StaffLibraryScope::buildFiles is right to
permit that, and a B-only file is still a 403. What was wrong is that
every response then went on to name B. FileResource serialised the loaded
uploader and each assignment unfiltered; ShareTargets::assigned took no
viewer at all, so the details panel published the recipient list as it
stands and forSubject narrowed available_clients while handing
assigned_clients straight through. FoldersController::fileRow,
FilesController::edit, FileDetailsController and ClientFilesController
each named the uploader the same way. The API's uploaded_by filter asked
the question without any name attached: it answered "does this client of
yours put files in front of a client of mine" for any id a caller cared
to try.

12a8ebe3 said the rule out loud while fixing topClientsByStorage -- "the
file was theirs to read and the uploader's name was not theirs to see" --
and then the rule stayed in that widget. So it is a class now.
ClientIdentityScope is the one decision, asked by every surface that
names a client, and it deliberately answers about clients only: a
colleague's name is not a client identity, and hiding it would hide who
uploaded most of the library from the people who work in it. Groups go
through it too, on the same argument -- a group is a list of clients
wearing one name -- which the report did not cover but is the same leak.

Two judgement calls worth naming. assigned() keeps returning the whole
truth and gains a warning, because VisibleCommentScope resolves
notification recipients from it and a recipient filtered out of that list
is one who never hears about a message addressed to them; assignedFor()
is the display half. And FileResource asks at serialisation rather than
in its callers' eager loads, which is the opposite of how the version
counterparts next door are narrowed: that one is set-shaped and folds
into a query, this one is a per-row roster check across eight call sites
in four controllers, two of them re-loading assignments after a write.

The tests assert on whole response bodies rather than on named keys. The
leak was never in one field -- the same name arrived through the
uploader, through the recipient list and through four screens -- so a
body that does not contain the name anywhere is the only assertion that
would have caught all of it. Ten of the eighteen fail without this
change; the rest are the negative controls, including that an unscoped
administrator still sees every name and that the uploaded_by filter still
works for a client on the roster and for staff.

Reported by @Noorkhalel, GHSA-whmp-p9hv-r7j7. Their write-up named every
affected surface and the root cause in each, which is most of why this
took one pass.
2026-09-03 00:56:41 -03:00

168 lines
8.0 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Files\Http\Resources\Api;
use App\Modules\Files\Access\ClientIdentityScope;
use App\Modules\Files\DownloadLimitScope;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\FileAssignment;
use App\Modules\Groups\Models\Group;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin File
*
* Every field is listed explicitly. Never $file->toArray() here: that
* would publish whatever the next migration adds, and two of this model's
* columns must not leave the server at all —
*
* - `path` and `disk` describe where the bytes physically live. A caller
* has no use for them (downloads go through the download endpoint,
* which authorizes and then hands off), and publishing them leaks the
* storage layout, which is exactly the map you would want before
* attempting to reach the bytes another way.
* - `checksum` is included deliberately, since verifying an integration's
* own download is a real use case, and it reveals nothing about
* location.
*
* Two fields are narrowed to the caller: the uploader and the assignment
* list both name clients, and a client-scoped account may hold a file whose
* uploader or co-recipients are clients off their own roster — the file is
* theirs to read, those names are not theirs to see. ClientIdentityScope is
* the rule; a name dropped here is dropped to null or out of the list, and
* an unscoped account is unaffected.
*
* That narrowing happens here rather than in the controllers, which is the opposite of how the version counterparts are
* handled a few files over — and deliberately so. Whether a counterpart may
* be named is a set-shaped question with a query to express it, so it is
* asked once in the caller's eager load. Whether a client may be named is a
* per-row check against the viewer's roster with no query to fold it into,
* and this resource is built at eight call sites across four controllers,
* two of them re-loading `assignments.assignable` after a write. Asking at
* the point of serialisation is the only version of this rule that cannot
* be forgotten by the ninth caller.
*/
class FileResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$viewer = $request->user();
$identity = app(ClientIdentityScope::class);
// The morph class rather than ::class, matching ShareTargets: with
// a morph map registered the two disagree, and this line now
// decides which roster an entry is checked against, so getting it
// wrong would mean checking a group id against the client list.
$groupMorph = (new Group)->getMorphClass();
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'description' => $this->description,
'original_name' => $this->original_name,
'mime_type' => $this->mime_type,
'size' => $this->size,
'checksum' => $this->checksum,
'public' => $this->public,
// Only consulted while the installation's comment setting is
// "only files marked as commentable"; published anyway, since a
// caller that sets it wants to read it back.
'commentable' => $this->commentable,
'expires_at' => $this->expires_at?->toIso8601String(),
'expired' => $this->isExpired(),
// Null when the file may be downloaded any number of times.
// `download_limit_scope` says what the number counts —
// "total" across everyone, or "per_user" for each person
// separately. It is always one of those two, and is
// meaningless while the limit is null.
'download_limit' => $this->download_limit,
'download_limit_scope' => ($this->download_limit_scope ?? DownloadLimitScope::Total)->value,
// The file's total downloads. Under a per_user limit this is
// still the total, since "how much has this caller used" is a
// different number for every caller.
'downloads_used' => $this->downloads()->count(),
'created_at' => $this->created_at?->toIso8601String(),
'updated_at' => $this->updated_at?->toIso8601String(),
// True when this file is a new version of an earlier one. A
// revision is always shared with the same people as the file it
// replaces, so it has no recipients of its own: assigning it
// returns 422, and `sharing_root_id` names the file to assign
// instead.
'is_revision' => $this->isRevision(),
// The oldest file in this version chain — the one whose
// recipients govern the whole chain. Null when this file is not
// a revision.
'sharing_root_id' => $this->version_root_id,
// The file this one replaces. Null when there is none, or when
// it is one you cannot see.
'previous_version' => $this->whenLoaded('previousVersion', fn (): ?array => $this->previousVersion === null ? null : [
'id' => $this->previousVersion->id,
'name' => $this->previousVersion->name,
]),
// The file that replaced this one, on the same terms.
'next_version' => $this->whenLoaded('nextVersion', fn (): ?array => $this->nextVersion === null ? null : [
'id' => $this->nextVersion->id,
'name' => $this->nextVersion->name,
]),
'folder' => $this->whenLoaded('folder', fn (): ?array => $this->folder === null ? null : [
'id' => $this->folder->id,
'name' => $this->folder->name,
]),
// Name only. The uploader is a user record; their email address
// is not part of what "this file exists" needs to say. Null
// when the uploader is a client the token's owner is not
// scoped to; an unscoped account always gets the name.
'uploaded_by' => $this->whenLoaded(
'uploader',
fn (): ?array => $identity->permits($viewer, $this->uploader) && $this->uploader !== null ? [
'id' => $this->uploader->id,
'name' => $this->uploader->name,
] : null,
),
'categories' => $this->whenLoaded('categories', fn (): array => $this->categories
->map(fn ($category): array => [
'id' => $category->id,
'name' => $category->name,
])
->all()),
// Who the file is shared with, as far as this caller is
// concerned: a recipient the token's owner is not scoped to is
// left out rather than returned without a name.
'assignments' => $this->whenLoaded('assignments', fn (): array => $this->assignments
->filter(fn (FileAssignment $assignment): bool => $assignment->assignable_type === $groupMorph
? $identity->permitsGroupId($viewer, (int) $assignment->assignable_id)
: $identity->permitsClientId($viewer, (int) $assignment->assignable_id))
->map(fn (FileAssignment $assignment): array => [
'type' => $assignment->assignable_type === $groupMorph ? 'group' : 'client',
'id' => $assignment->assignable_id,
// getAttribute() rather than ->name: the relation is a
// MorphTo over User|Group, so the property is only
// knowable at runtime. Both targets carry a name.
'name' => $assignment->assignable?->getAttribute('name'),
])
->values()
->all()),
'links' => [
'download' => route('api.files.download', $this->resource),
],
];
}
}