Skip to content

fix: require authentication and mail permissions on preview and attachment routes - #84

Closed
Baspa wants to merge 4 commits into
mainfrom
fix/mail-route-authorization
Closed

fix: require authentication and mail permissions on preview and attachment routes#84
Baspa wants to merge 4 commits into
mainfrom
fix/mail-route-authorization

Conversation

@Baspa

@Baspa Baspa commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Fixes #83

Problem

The stored mail preview and attachment download routes were reachable without authentication, and the attachment route never verified that the requested attachment belonged to the mail in the URL.

Anyone able to reach the panel path could read the full HTML body of any logged mail and download any stored attachment by supplying a numeric ID.

Why authentication was missing

Mails::routes() registered two bare routes with no middleware. Filament invokes $panel->getRoutes() in packages/panels/routes/web.php:30, inside the $panel->getMiddleware() group — which is only panel:{id} plus the panel's web middleware. The $panel->getAuthMiddleware() group does not open until line 58.

->tenantRoutes() has the same problem: it is invoked at line 191, in a group that applies getTenantMiddleware() but sits outside the auth group as well.

Both of these were what the README documented, so installs following the README were exposed. Only ->authenticatedRoutes() and ->authenticatedTenantRoutes() sit inside the auth group.

Why authorization was missing

MailsPlugin::userCanManageMails() was wired into every Resource, Page and Widget, but neither controller consulted it.

Why any attachment resolved under any mail

MailDownloadController destructured {mail} out of the URL and then never used it:

$attachment = $attachmentModel::find($attachment);

return $attachment->downloadFileFromStorage();

Fix

Access control is attached inside Mails::routes() itself, rather than left to the consumer's PanelProvider:

Route::middleware([
    Authenticate::class,
    EnsureUserCanManageMails::class,
])->group(function (): void {
    // ...
});

This was deliberate. A documentation-only fix would leave every existing install vulnerable until they edited their own PanelProvider. Attaching the middleware where the routes are defined means the protection travels with them, whichever registration method the consumer picked.

Reusing Filament's own Authenticate gives us the panel's configured guard, the redirect to the panel login URL, and the FilamentUser::canAccessPanel() check, with behaviour identical to every other page in the panel.

The new EnsureUserCanManageMails middleware guards on $panel->hasPlugin('mails') before calling MailsPlugin::get() — that call resolves through filament('mails') and throws when the plugin is absent from the current panel, which would otherwise be a 500 rather than a 403.

Attachments are now scoped through their mail:

$mail = $mailModel::findOrFail($request->route('mail'));

$attachment = $mail->attachments()->findOrFail($request->route('attachment'));

Mail::attachments() is a HasMany, so an unrelated attachment ID fails the query and raises a 404 — the file is never read. This also removes the ...$arguments splat and its count($arguments) === 4 tenant branch; named route parameters resolve identically with and without a {tenant} segment, and the variadic signature is what made it easy to silently drop {mail} in the first place.

The {filename} segment stays unused on purpose: downloadFileFromStorage() builds the storage path from $this->filename on the model, so the URL segment never reaches the filesystem.

Preview hardening: find()findOrFail() (an unknown ID was previously a 500 from null->html), the model is resolved from config('mails.models.mail') like the download controller, and the response now sends X-Content-Type-Options: nosniff and Content-Security-Policy: frame-ancestors 'self'. The body is still served verbatim — the preview iframe needs it, and it is same-origin so frame-ancestors 'self' permits it.

Behaviour

Case Before After
Guest 200 + mail body 302 → panel login
Authenticated, canAccessPanel() false 200 403
Authenticated, canManageMails() false 200 403
Unknown mail ID 500 404
Attachment belonging to a different mail 200 + file contents 404
Unknown attachment ID 500 404
Permitted user, matching pair 200 200

Tests

The existing suite was unit-style with no database or authentication, so this adds the scaffolding for it:

  • TestCase now registers Backstage\Mails\Laravel\MailsServiceProvider — Testbench does not load it automatically, and without it config('mails.models.mail') is null.
  • defineDatabaseMigrations() creates a users table and runs the laravel-mails migration stubs, which ship as .php.stub and are never auto-loaded.
  • tests/Fixtures/User.php is an authenticatable FilamentUser.

tests/MailRouteSecurityTest.php covers every row of the table above. Note that the test panel registers the routes with ->routes() — the vulnerable method — on purpose, so the tests prove the middleware protects that path rather than only proving authenticatedRoutes() works.

13 tests, 25 assertions, all passing.

Docs

README now documents authenticatedRoutes() / authenticatedTenantRoutes(), with a note that the routes enforce the permission themselves regardless.

Not addressed here

  • Mail enumeration. The routes are still keyed by sequential numeric IDs, so a permitted user can walk the ID space. Arguably intended for an admin mail log.
  • Tenant scoping. The Mail model has no tenant relation to scope by; {tenant} is only a URL segment. With authenticatedTenantRoutes() your own tenant middleware handles isolation. Worth a separate issue if cross-tenant access matters.
  • HTML sanitisation. Mail bodies render verbatim; sanitising would change what admins see versus what the recipient received. The response headers are the mitigation.

Unrelated issues noticed

  • composer analyse cannot run — phpstan is not in require-dev, and phpstan.neon.dist uses checkOctaneCompatibility / checkModelProperties, which were removed in PHPStan 2 / Larastan 3. The PHPStan workflow is therefore broken independently of this PR. Verified locally against an equivalent ad-hoc config: this branch introduces no new errors and removes two.
  • MailAttachmentFactory in backstage/laravel-mails is broken — its definition() returns type/ip/hostname/payload, none of which are columns on mail_attachments. The tests create attachments through the relation instead.

Reported by Ahmet Akdas (Digital Impact).

@Baspa Baspa self-assigned this Jul 30, 2026
@Baspa

Baspa commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by backstagephp/cms#299.

packages/mails is subtree-split from the backstagephp/cms monorepo, so fixes need to land there to survive the next split. Same changes, plus the CI resolution fix that was blocking this PR's checks.

@Baspa Baspa closed this Jul 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Security issues

1 participant