mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 09:05:08 +00:00
50f8b578df
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
323 lines
10 KiB
PHP
323 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Files\Models;
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Files\Access\StaffLibraryScope;
|
|
use App\Modules\Groups\Models\Group;
|
|
use App\Support\Concerns\HasUniqueSlug;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
|
|
/**
|
|
* A library folder. Staff see one shared tree; a folder becomes visible
|
|
* to a client either when it — or an ancestor — is explicitly shared
|
|
* (granting live access to the whole subtree), or when the client created
|
|
* it themselves.
|
|
*
|
|
* @property int $id
|
|
* @property string $name
|
|
* @property int|null $parent_id
|
|
* @property int|null $created_by
|
|
* @property string $path
|
|
* @property string $slug
|
|
* @property bool $public
|
|
* @property bool $allow_client_uploads
|
|
*/
|
|
class Folder extends Model
|
|
{
|
|
use HasUniqueSlug;
|
|
use SoftDeletes;
|
|
|
|
public const MAX_DEPTH = 10;
|
|
|
|
protected $guarded = [];
|
|
|
|
protected function casts(): array
|
|
{
|
|
return [
|
|
'public' => 'boolean',
|
|
'allow_client_uploads' => 'boolean',
|
|
];
|
|
}
|
|
|
|
protected static function slugFallback(): string
|
|
{
|
|
return 'folder';
|
|
}
|
|
|
|
/**
|
|
* @return BelongsTo<Folder, $this>
|
|
*/
|
|
public function parent(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Folder::class, 'parent_id');
|
|
}
|
|
|
|
/**
|
|
* @return HasMany<Folder, $this>
|
|
*/
|
|
public function children(): HasMany
|
|
{
|
|
return $this->hasMany(Folder::class, 'parent_id');
|
|
}
|
|
|
|
/**
|
|
* @return HasMany<File, $this>
|
|
*/
|
|
public function files(): HasMany
|
|
{
|
|
return $this->hasMany(File::class);
|
|
}
|
|
|
|
/**
|
|
* @return HasMany<FolderAssignment, $this>
|
|
*/
|
|
public function assignments(): HasMany
|
|
{
|
|
return $this->hasMany(FolderAssignment::class);
|
|
}
|
|
|
|
/**
|
|
* @return BelongsTo<User, $this>
|
|
*/
|
|
public function creator(): BelongsTo
|
|
{
|
|
return $this->belongsTo(User::class, 'created_by');
|
|
}
|
|
|
|
/**
|
|
* Ancestor ids parsed from the materialized path (nearest first is
|
|
* not guaranteed; order is root→self).
|
|
*
|
|
* @return list<int>
|
|
*/
|
|
public function ancestorIds(): array
|
|
{
|
|
return array_values(array_filter(array_map('intval', explode('/', trim($this->path, '/')))));
|
|
}
|
|
|
|
public function depth(): int
|
|
{
|
|
return count($this->ancestorIds());
|
|
}
|
|
|
|
public function isOwnedBy(User $user): bool
|
|
{
|
|
return $this->created_by === $user->id;
|
|
}
|
|
|
|
/**
|
|
* Self or any ancestor is public — the inheritance every file in this
|
|
* folder's subtree relies on (File::isEffectivelyPublic()), and what
|
|
* the file editor shows the user when a file's own public checkbox is
|
|
* grayed out.
|
|
*/
|
|
public function isEffectivelyPublic(): bool
|
|
{
|
|
return $this->publicSourceName() !== null;
|
|
}
|
|
|
|
/**
|
|
* Self's name if public, else the name of the nearest public ancestor
|
|
* (not necessarily the topmost one), else null. What the file editor
|
|
* names in the message under a grayed-out, inherited-public checkbox.
|
|
*/
|
|
public function publicSourceName(): ?string
|
|
{
|
|
if ($this->public) {
|
|
return $this->name;
|
|
}
|
|
|
|
$ancestorIds = $this->ancestorIds();
|
|
|
|
if ($ancestorIds === []) {
|
|
return null;
|
|
}
|
|
|
|
$publicAncestorNames = self::query()->whereIn('id', $ancestorIds)->where('public', true)->pluck('name', 'id');
|
|
|
|
foreach (array_reverse($ancestorIds) as $id) {
|
|
if (isset($publicAncestorNames[$id])) {
|
|
return $publicAncestorNames[$id];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Whether $user may put content into $folder (null = loose at the
|
|
* root, always allowed).
|
|
*
|
|
* **Read the name as "may place into", not "may upload into".** Every
|
|
* way a file arrives in a folder has to come through here, and the
|
|
* name cost us one advisory already: the publication rule below was
|
|
* written for GHSA-237r-jx85-j3hr and wired into the upload paths
|
|
* alone, because those are what the name suggested. Moving a file in,
|
|
* bulk-moving a selection in, reparenting one through the edit form,
|
|
* and dragging a whole folder into a public parent all put content
|
|
* somewhere too, and none of them asked (GHSA-rxf8-wh8v-jm9j). They
|
|
* ask now. Anything new that writes a `folder_id` or a `parent_id`
|
|
* belongs on this list.
|
|
*
|
|
* Staff are held to the library boundary they are held to everywhere
|
|
* else: an unscoped staff member may use any folder, a client-scoped
|
|
* one only the folders StaffLibraryScope already shows them. Callers
|
|
* that have already resolved the destination through
|
|
* StaffLibraryScope::folders() have answered that half — the two are
|
|
* the same query — and call this for the publication half.
|
|
*
|
|
* For a client this is unchanged, and is still the whole of the
|
|
* check: they own the folder, or it is a public folder that opts into
|
|
* client uploads and their role permits uploading into public folders
|
|
* at all.
|
|
*/
|
|
public static function uploadableBy(User $user, ?self $folder): bool
|
|
{
|
|
if ($folder === null) {
|
|
return true;
|
|
}
|
|
|
|
if ($user->isStaff()) {
|
|
if (! app(StaffLibraryScope::class)->allowsFolder($user, $folder)) {
|
|
return false;
|
|
}
|
|
|
|
// Being allowed to reach the folder is not the same as being
|
|
// allowed to publish, and putting a file in a public folder
|
|
// publishes it: isEffectivelyPublic() is "my own flag, or my
|
|
// folder's". So the destination reaches the property that
|
|
// `upload_public` guards, without ever touching the switch
|
|
// (GHSA-237r-jx85-j3hr).
|
|
//
|
|
// The keys already say this. The client branch below has always
|
|
// asked for `upload_to_public_folders` here, and
|
|
// MyFilesController's picker calls that the established meaning
|
|
// of the two — it was simply never asked on a staff role, which
|
|
// left that permission doing nothing at all for staff.
|
|
//
|
|
// Effectively public, not `public`: the flag is inherited down
|
|
// a subtree, so a private folder inside a public one publishes
|
|
// just the same and a check on the folder's own flag would walk
|
|
// straight past it.
|
|
return ! $folder->isEffectivelyPublic()
|
|
|| $user->can('upload_public')
|
|
|| $user->can('upload_to_public_folders');
|
|
}
|
|
|
|
return $folder->isOwnedBy($user)
|
|
|| ($folder->public && $folder->allow_client_uploads && $user->can('upload_to_public_folders'));
|
|
}
|
|
|
|
/**
|
|
* The path prefix matching this folder's whole subtree (self + all
|
|
* descendants share this prefix in their path).
|
|
*/
|
|
public function subtreePathPrefix(): string
|
|
{
|
|
return $this->path.$this->id.'/';
|
|
}
|
|
|
|
/**
|
|
* This folder's id plus every descendant's — the live subtree, used
|
|
* anywhere "every file inside this folder, recursively" is needed
|
|
* (e.g. zipping a folder).
|
|
*
|
|
* @return list<int>
|
|
*/
|
|
public function subtreeFolderIds(): array
|
|
{
|
|
$descendantIds = self::query()
|
|
->where('path', 'like', $this->subtreePathPrefix().'%')
|
|
->pluck('id')
|
|
->map(fn ($id): int => (int) $id)
|
|
->all();
|
|
|
|
return array_values([$this->id, ...$descendantIds]);
|
|
}
|
|
|
|
/**
|
|
* Folders visible to a client: any folder shared with them or their
|
|
* groups (plus every descendant of such a folder, live subtree), or
|
|
* any folder they created themselves, anywhere in that visible tree
|
|
* (see MyFoldersController::store's parent_id validation, which only
|
|
* ever lets a client nest a new folder inside this same set).
|
|
*
|
|
* @param Builder<Folder> $query
|
|
*/
|
|
public function scopeVisibleToClient(Builder $query, User $client): void
|
|
{
|
|
$sharedIds = self::sharedFolderIds($client);
|
|
|
|
$query->where(function (Builder $inner) use ($sharedIds, $client): void {
|
|
$inner->where('created_by', $client->id);
|
|
|
|
if ($sharedIds !== []) {
|
|
$inner->orWhereIn('id', $sharedIds);
|
|
|
|
foreach (self::query()->whereIn('id', $sharedIds)->get() as $shared) {
|
|
$inner->orWhere('path', 'like', $shared->subtreePathPrefix().'%');
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Folders publicly reachable on the public listing site: any folder
|
|
* marked public, plus every descendant in its live subtree (mirrors
|
|
* scopeVisibleToClient's shared-subtree shape). No Gate/auth involved
|
|
* — same reasoning as File::scopeStandalonePublic.
|
|
*
|
|
* @param Builder<Folder> $query
|
|
*/
|
|
public function scopePubliclyVisible(Builder $query): void
|
|
{
|
|
$publicIds = self::query()->where('public', true)->pluck('id')->map(fn ($id): int => (int) $id)->all();
|
|
|
|
if ($publicIds === []) {
|
|
$query->whereRaw('1 = 0');
|
|
|
|
return;
|
|
}
|
|
|
|
$query->where(function (Builder $inner) use ($publicIds): void {
|
|
$inner->whereIn('id', $publicIds);
|
|
|
|
foreach (self::query()->whereIn('id', $publicIds)->get() as $public) {
|
|
$inner->orWhere('path', 'like', $public->subtreePathPrefix().'%');
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Ids of folders shared directly with the client or via a group.
|
|
*
|
|
* @return list<int>
|
|
*/
|
|
public static function sharedFolderIds(User $client): array
|
|
{
|
|
$groupIds = $client->memberOfGroups()->pluck('groups.id')->all();
|
|
|
|
$ids = FolderAssignment::query()
|
|
->where(function (Builder $query) use ($client, $groupIds): void {
|
|
$query->where(function (Builder $direct) use ($client): void {
|
|
$direct->where('assignable_type', (new User)->getMorphClass())
|
|
->where('assignable_id', $client->id);
|
|
})->orWhere(function (Builder $viaGroup) use ($groupIds): void {
|
|
$viaGroup->where('assignable_type', (new Group)->getMorphClass())
|
|
->whereIn('assignable_id', $groupIds);
|
|
});
|
|
})
|
|
->pluck('folder_id')
|
|
->all();
|
|
|
|
return array_values(array_map('intval', $ids));
|
|
}
|
|
}
|