Commit Graph

6 Commits

Author SHA1 Message Date
Ignacio Nelson 350a7b3073 Merge pull request #1705 from denkfabrik-li/fix/deny-membership-request-once
Deny a membership request once, as approve() already does
2026-08-26 15:43:29 -03:00
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
denkfabrik-li 5242169bb0 Deny a membership request once, as approve() already does
approve() refuses a request that is not pending:

    abort_unless($group !== null && $client !== null
        && $membershipRequest->status === MembershipRequest::STATUS_PENDING, 404);

deny(), one method below, checks nothing. Denying is not idempotent, so
repeating it is not a no-op:

  - denied_at is stamped again, and that is what the client's re-request
    cooldown counts from (MyGroupsController::inDenyCooldown). Repeating
    the request keeps one client out of one group for as long as somebody
    cares to keep asking, without a single new decision being made.
  - a second GroupMembershipDenied entry goes into the activity log, for
    a denial that did not happen.
  - a second "your request was declined" mail goes to the client.

The queue lists only pending requests, so nothing on the screen offers
this; it takes asking for the route directly. It needs
approve_groups_memberships_requests, so it is not a stranger's move.

The guard is the same one, answering the same 404, placed where deny()
can reach it. deny() keeps tolerating a vanished group or client -- that
tolerance is deliberate and separate: the denied row persists for the
cooldown even when the group it named is gone, and index() already
filters those rows out with whereHas.

Not in this change: deny() writes the status, the log entry and the
notification without a shared transaction. approve() has exactly the same
shape, so fixing one alone would replace a symmetry with a difference,
and doing both means also deciding where the mail sits relative to the
commit -- which is the question #1691 answers for file bytes, and worth
answering on its own rather than inside a state-machine fix.
2026-08-26 06:09:49 +02:00
ignacionelson 57540164fa Read a file from the disk it is actually on, everywhere
Two routes still assumed every file sits on local disk, which stopped
being true the moment external storage was switched on. A share link
answered with X-Accel-Redirect whatever the file's disk said, pointing
nginx at a path it has nothing behind; a public listing built a
thumbnail from Storage::disk('files')->path(), which for an externally
stored file is a path nobody ever wrote. Both fail only for installs
using S3, and only on those two routes, so the same file downloading
correctly from the file manager made the share link look like the
broken thing rather than where the file lives.

Neither is a new rule. FileDownloadController and
FileThumbnailController already did it right, which is the actual
finding: the knowledge was sitting in a private method on one class and
inline in another, so the next caller could not inherit it and did not.
Both are now objects with one job.

StoredFileResponse replaces InlineFileResponse and grows an
attachment() alongside inline(), since the two differ only by
disposition. LocalSourceFile takes a closure rather than returning a
path: the version that returned one also left the caller to unlink it,
and both of those are exactly the mistakes made here.

The regression tests fail against the previous controllers — checked in
both directions rather than assumed.
2026-08-24 16:24:39 -03:00
ignacionelson 88c182cf3b Preview video, audio and PDF, not only images
v1 could preview four kinds of file in a modal — images, video, audio and
PDF. v2 previewed only images, and not by decision: preview shipped as part
of the image *thumbnail* work (1c68aa1), so "previewable" quietly became a
synonym for "GD can decode it". FileThumbnailController::preview() gated on
ThumbnailGenerator::SUPPORTED_MIME_TYPES, the frontend mirrored the same
four types, and the dialog was a hardcoded <img>.

Rather than widen that list — it drives pathFor(), extensionFor(),
generate() and FileDiskCleanup, and a video reaching getimagesize() is a
500 — this separates the two questions. PreviewKind now answers "may these
bytes be served inline, and what element renders them?", while
ThumbnailGenerator keeps answering the narrower "can this app decode it
itself?", which is what renditions, the cache and the watermark hook
actually depend on. Image delegates to it so the two cannot drift.

The allowlist stays a security boundary: mime_type is sniffed from the
bytes, so text/html and image/svg+xml remain excluded, and PreviewKind is
deliberately narrower than "formats a browser might cope with" — no
quicktime, avi or matroska, because an embedded player for those shows a
black rectangle. Those still download exactly as before.

docs/security-audit-2026-08-05.md finding 1 recorded that adding
application/pdf "should be a conscious decision". This is that decision,
and three things were measured rather than assumed:

- An <iframe sandbox> cannot be used. Chrome refuses to run its PDF viewer
  in a sandboxed frame at all (ERR_BLOCKED_BY_CLIENT, with or without
  allow-same-origin) — the attribute removes the feature, it does not
  harden it.
- nginx's `Content-Security-Policy: sandbox; default-src 'none'` on
  /protected-files/ does work (a <video> frame lands in an opaque origin),
  but Chrome exempts its PDF viewer from it, so it is not what protects
  the PDF case.
- What does is the allowlist plus the browser's own PDF sandbox, where PDF
  JavaScript has no DOM and no cookies.

Range requests were verified end to end: 206 with a correct Content-Range,
a byte-perfect file reassembled from three ranges, and a real browser
seeking to 10s of a 20s clip. nginx drops the upstream Content-Length on
the X-Accel path, so there is no collision.

Two settings, both defaulting on so no installation loses what it has:
clients_can_preview_files and public_listing_preview_enabled. Staff are
never gated. The anonymous side needed a route of its own — there was no
public preview endpoint — with its own throttle bucket, since a bare
throttle: shares one counter across that whole block.

A preview now logs at most one FilePreviewed per viewer per file per five
minutes: a <video> turns one deliberate act into a long tail of Range
requests, and a row each would bury the log.

Also fixes a layout bug the tests could never catch. A portal file row was
flex justify-between with three children — name, comment trigger, download
— so the middle one settled wherever the name happened to end and the
comment icon sat at a different place on every row. The name block now
takes the slack and every action lives in one trailing group, with the
comment trigger in a fixed-width slot so the icons form a column. And
because half the previewable files have no thumbnail to click — a PDF, an
mp3 and an mp4 all render as a generic icon — every row gains an explicit
PreviewAction beside DownloadAction, matching whatever style that theme
gives its download control.
2026-08-21 14:14:23 -03:00
ignacionelson 6e47d76ba6 ProjectSend 2.0.0
Client file sharing, rebuilt from the ground up: a private area per
client, resumable uploads, folders, groups and categories, sharing with
expiry dates and download limits, comments, file versions, an activity
log, a REST API, and sixteen languages.

This repository begins here. ProjectSend 2 was developed privately, and
that development history is not published — the previous generation
remains available, with its own history, at projectsend/legacy.

Free software under the GNU General Public License v2, or (at your
option) any later version.
2026-08-14 01:38:12 -03:00