Files
projectsend/app/Modules/Files/Http/Controllers/Api/FilesController.php
T
ignacionelson 50f8b578df Ask the publication question wherever content lands, not just on upload
Reported by @skeletonsec as GHSA-rxf8-wh8v-jm9j.

A file in a public folder is public: isEffectivelyPublic() is "my own flag,
or my folder's", read up the whole ancestry. GHSA-237r-jx85-j3hr settled
that three days ago, put the rule in Folder::uploadableBy(), and wired it
into the upload paths.

Content arrives in a folder four other ways. move() drags one file in,
bulkUpdate() moves a selection, update() reparents through the edit form,
and FoldersController::move() drags a whole folder — every file in its
subtree — under a public parent. Each of them asked whether the destination
was *visible* to the mover and then wrote folder_id. Visible is not the same
question as publishable, and the difference is the entire permission: a
staff member given editing rights and deliberately not given upload_public
could publish confidential files to the anonymous site by choosing where
they landed. The API twin of update() had the same gap.

Both earlier advisories named these paths in their own "suggested fix"
sections. Neither demonstrated them, so neither was followed. The fix to a
report wants the scrutiny the report got, and this one did not get it.

The predicate did not need changing — it needed calling. Four sinks now ask
it, plus the API twin. The check stays split in two deliberately: the
destination is resolved through StaffLibraryScope as before, so a folder
somebody cannot see is still a 404 and not an existence oracle, and the
publication clause is a separate 403 on top. They agree by construction —
allowsFolder() is folders()->whereKey()->exists() — so nothing that used to
resolve can now fail the first half.

On the file paths the check fires only when folder_id actually changes,
which is the convention already there: re-saving a file that sits in a
folder out of the saver's scope must keep working. bulkUpdate() checks its
destination once instead, before the loop, because there is one destination
for the batch and if it publishes then no file in the batch may go.

Folder::uploadableBy()'s docblock now says to read the name as "may place
into", with why: the name is what made this easy to miss, and the next
folder_id or parent_id write will be written by somebody reading it.

Ten tests, one per sink with a private-destination control beside it, plus
an editor who *can* publish to show the boundary is about publishing and not
about moving. The last one follows the advisory's own chain to the end and
asserts the thing actually claimed — a stranger with no session, no token
and no assignment fetching the anonymous download URL. It returns 200 on the
code before this commit and 404 after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx
2026-09-11 12:16:23 -03:00

376 lines
16 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Files\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Api\Support\PollingQuery;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Files\Access\ClientIdentityScope;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Access\ViewableFileScope;
use App\Modules\Files\DownloadLimitScope;
use App\Modules\Files\Editing\ApplyFileEdits;
use App\Modules\Files\Editing\FileExpiry;
use App\Modules\Files\Http\Resources\Api\FileResource;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Storage\ResolvingUploadDisk;
use App\Modules\Files\Uploads\StoreUploadedFile;
use App\Modules\Files\Uploads\UploadExtensionPolicy;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Support\Rules;
use Closure;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
/**
* Read access to the file library.
*
* The listing is built from ViewableFileScope, never from File::query().
* That class is FilePolicy::view() expressed as SQL, so a client-scoped
* staff member's token returns exactly the files they see in the UI and
* the token ability check stays an *additional* gate rather than the only
* one. Reimplementing the visibility rules here would create a second
* definition of "may see", and the two would drift.
*/
class FilesController extends Controller
{
public function __construct(
private readonly ViewableFileScope $viewable,
private readonly PollingQuery $polling,
private readonly Settings $settings,
private readonly StoreUploadedFile $storeFile,
private readonly UploadExtensionPolicy $extensionPolicy,
private readonly ClientStorageUsage $storageUsage,
private readonly ActivityLogger $activity,
private readonly StaffLibraryScope $scope,
private readonly ClientIdentityScope $identity,
private readonly ApplyFileEdits $fileEdits,
private readonly FileExpiry $expiry,
) {}
/**
* Eager loads for the version counterparts, constrained to what this
* token's owner may see.
*
* Constrained here rather than in FileResource so the resource stays a
* pure allowlist with no visibility logic of its own — one definition of
* who may be told about a counterpart, in ViewableFileScope, exactly as
* on the web. Two extra queries for a whole page, not two per row.
*
* @return array<string, Closure(Relation<*, *, *>): mixed>
*/
private function versionRelations(?User $user): array
{
if ($user === null) {
return [];
}
// clone: the same builder is compiled into two separate subqueries,
// and a Builder is not reusable once bound.
$visible = $this->viewable->for($user)->select('files.id');
return [
'previousVersion' => fn (Relation $query) => $query->whereIn('files.id', (clone $visible)->getQuery()),
'nextVersion' => fn (Relation $query) => $query->whereIn('files.id', (clone $visible)->getQuery()),
];
}
public function index(Request $request): AnonymousResourceCollection
{
$user = $request->user();
assert($user !== null);
$filters = $request->validate($this->polling->rules() + [
'folder_id' => ['nullable', 'integer'],
'category_id' => ['nullable', 'integer'],
'uploaded_by' => ['nullable', 'integer'],
'search' => ['nullable', 'string', 'max:255'],
'public' => ['nullable', 'boolean'],
'expired' => ['nullable', 'boolean'],
]);
$query = $this->viewable->for($user)
->with(['folder', 'uploader', 'categories'] + $this->versionRelations($user));
if (array_key_exists('folder_id', $filters) && $filters['folder_id'] !== null) {
$query->where('files.folder_id', $filters['folder_id']);
}
if (array_key_exists('uploaded_by', $filters) && $filters['uploaded_by'] !== null) {
// A filter is a question, and this one asks "did client N put
// anything into my library". Answered plainly it is an oracle:
// a client-scoped caller could walk the id space and learn
// which clients off their roster share files with clients on
// it, without ever reading a name. So an id this caller may
// not identify matches nothing — indistinguishable from a
// client who has uploaded nothing, which is the point.
if (! $this->identity->permitsClientId($user, (int) $filters['uploaded_by'])) {
$query->whereRaw('1 = 0');
}
$query->where('files.uploaded_by', $filters['uploaded_by']);
}
if (array_key_exists('category_id', $filters) && $filters['category_id'] !== null) {
$query->whereHas('categories', fn (Builder $categories) => $categories->whereKey($filters['category_id']));
}
if (($filters['search'] ?? null) !== null) {
$search = $filters['search'];
$query->where(fn (Builder $inner) => $inner
->where('files.name', 'like', "%{$search}%")
->orWhere('files.description', 'like', "%{$search}%")
->orWhere('files.original_name', 'like', "%{$search}%"));
}
if ($request->has('public') && ($filters['public'] ?? null) !== null) {
$query->where('files.public', $request->boolean('public'));
}
// Expiry is a filter, not a default: staff see expired files in the
// UI too (that is how they notice and act on them). Dropping them
// is the client branch's rule, applied inside the visibility scopes
// where it belongs — which is also why a client-scoped caller does
// not get their clients' expired files back here whatever this
// filter says: their library is built on that same branch. See
// File::isExpired.
if ($request->has('expired') && ($filters['expired'] ?? null) !== null) {
$request->boolean('expired') ? $query->expired() : $query->notExpired();
}
return FileResource::collection($this->polling->paginate($request, $query, 'files'));
}
public function show(Request $request, File $file): FileResource
{
Gate::authorize('view', $file);
$file->load(['folder', 'uploader', 'categories', 'assignments.assignable'] + $this->versionRelations($request->user()));
return new FileResource($file);
}
/**
* Upload a file in a single request.
*
* Send the file as multipart form data. The maximum accepted size is
* this installation's configured upload limit; larger or unreliable
* uploads should use the resumable `/uploads` endpoints instead.
*
* The stored content type is detected from the uploaded bytes, not from
* the declared `Content-Type`.
*/
public function store(Request $request): JsonResponse
{
// NOTE: docblocks on the methods in this namespace are published as
// the API reference (Scramble reads them), so implementation notes
// belong here rather than above.
//
// This is deliberately not a copy of the web FilesController::store():
// that one exists to seed fixtures for the test suite and hard-caps
// at 100 MB in validation instead of reading Setting::MaxFileSizeMb.
// The checks below mirror the chunked flow's, which are the real ones.
$user = $request->user();
assert($user !== null);
$validated = $request->validate([
'file' => ['required', 'file'],
'name' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:2000'],
'folder_id' => Rules::folderId(),
]);
/** @var UploadedFile $upload */
$upload = $validated['file'];
$size = (int) $upload->getSize();
$maxMb = (int) $this->settings->get(Setting::MaxFileSizeMb);
if ($maxMb > 0 && $size > $maxMb * 1024 * 1024) {
throw ValidationException::withMessages([
'file' => __('This file exceeds the maximum allowed size of :max MB.', ['max' => (string) $maxMb]),
]);
}
$folder = isset($validated['folder_id'])
? Folder::query()->whereKey($validated['folder_id'])->first()
: null;
abort_unless(Folder::uploadableBy($user, $folder), 403);
// Inert for a staff token — the quota is a client-portal concept —
// but the check belongs here rather than being added later when
// client tokens land and this path silently becomes a way around it.
if ($user->isClient()) {
$quotaBytes = $this->storageUsage->quotaBytes($user);
if ($quotaBytes > 0 && $this->storageUsage->usedBytes($user) + $size > $quotaBytes) {
throw ValidationException::withMessages([
'file' => __('This upload would exceed your storage quota of :quota MB.', [
'quota' => (string) $this->storageUsage->quotaMb($user),
]),
]);
}
}
if (! $this->extensionPolicy->isAllowed($user, $upload->getClientOriginalName())) {
throw ValidationException::withMessages([
'file' => __('This file type is not allowed for upload.'),
]);
}
$diskEvent = new ResolvingUploadDisk($user);
Event::dispatch($diskEvent);
$disk = $diskEvent->disk;
$path = $upload->storeAs(
now()->format('Y/m'),
Str::uuid()->toString().'.'.strtolower($upload->getClientOriginalExtension()),
$disk,
);
abort_unless(is_string($path), 500);
$file = $this->storeFile->create(
uploader: $user,
originalName: $upload->getClientOriginalName(),
path: $path,
// From the bytes, never from the request. A caller controls the
// Content-Type it declares, and the mime type decides how this
// file is later served and previewed.
mimeType: $upload->getMimeType() ?? 'application/octet-stream',
size: $size,
checksum: hash_file('sha256', $upload->getRealPath()) ?: '',
name: $validated['name'] ?? null,
description: $validated['description'] ?? null,
folderId: $validated['folder_id'] ?? null,
disk: $disk,
);
return (new FileResource($file->load(['folder', 'uploader', 'categories'])))
->response()
->setStatusCode(201);
}
/**
* Update a file's metadata.
*
* Only the fields present in the request are changed; omitting one
* leaves it as it was.
*
* Some fields need a permission of their own — `expires_at` needs
* `set_file_expiration_date`, `public` needs `upload_public`, and
* `categories` needs `set_file_categories`. Sending one of those
* without the matching permission leaves that field untouched rather
* than failing the whole request, which mirrors the web interface.
*
* `expires_at` accepts either a calendar day (`2026-09-12`) or a full
* timestamp. A day means the end of that day in the caller's timezone,
* which is what the same value means on the web and what the file's
* own `expires_at` reads back as; a timestamp is taken as the instant
* it names.
*
* `commentable` only has an effect while the installation's comment
* setting is "only files marked as commentable"; under any other
* setting it is ignored, again rather than failing.
*/
public function update(Request $request, File $file): FileResource
{
Gate::authorize('update', $file);
$user = $request->user();
assert($user !== null);
$validated = $request->validate([
'name' => ['sometimes', 'string', 'max:255'],
'description' => ['sometimes', 'nullable', 'string', 'max:2000'],
'folder_id' => ['sometimes', ...Rules::folderId()],
'public' => ['sometimes', 'boolean'],
'commentable' => ['sometimes', 'boolean'],
'slug' => Rules::slug('files', $file->id),
'categories' => ['sometimes', 'array'],
'categories.*' => ['integer', 'exists:categories,id'],
'expires_at' => ['sometimes', 'nullable', 'date'],
'download_limit' => ['sometimes', 'nullable', 'integer', 'min:1'],
'download_limit_scope' => ['sometimes', Rule::enum(DownloadLimitScope::class)],
]);
// Reparenting through update() must respect the same two rules as
// the web move()/bulkUpdate() paths: the destination folder must be
// one this user can see, and one they may put content into. A public
// destination publishes what lands in it, so the second question is
// the one `upload_public` exists to ask and store() above already
// asks (GHSA-rxf8-wh8v-jm9j). Only enforced when folder_id actually
// changes, so re-saving a file that already sits in an out-of-scope
// folder (reachable via a direct client share) still works. The
// integer rule admits numeric strings, so cast before the strict
// change comparison.
if (array_key_exists('folder_id', $validated) && $validated['folder_id'] !== null) {
$validated['folder_id'] = (int) $validated['folder_id'];
if ($validated['folder_id'] !== $file->folder_id) {
$destination = $this->scope->folders($user)->whereKey($validated['folder_id'])->firstOrFail();
abort_unless(Folder::uploadableBy($user, $destination), 403);
}
}
// `sometimes` throughout the rules above means $validated already
// holds exactly the fields the caller sent, which is the same
// array_key_exists contract ApplyFileEdits reads — so the payload
// passes through almost untouched. Which of them this token's user
// may actually write is that class's decision, shared with the
// staff editor and the client portal.
$changes = array_intersect_key($validated, array_flip([
'name',
'description',
'folder_id',
'commentable',
'download_limit',
'download_limit_scope',
'public',
'slug',
'categories',
]));
// The one field that needs converting rather than passing along: a
// caller may send a calendar day or a full timestamp, and a day
// means the end of that day where the caller is.
if (array_key_exists('expires_at', $validated)) {
$changes['expires_at'] = $this->expiry->instant($validated['expires_at'], $user);
}
$this->fileEdits->apply($user, $file, $changes);
return new FileResource($file->fresh()?->load(['folder', 'uploader', 'categories']) ?? $file);
}
public function destroy(File $file): JsonResponse
{
Gate::authorize('delete', $file);
$name = $file->name;
$file->delete();
$this->activity->log(Action::FileDeleted, context: ['name' => $name]);
return response()->json(status: 204);
}
}