diff --git a/README.md b/README.md index 95e2303..91160c0 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,23 @@ The same `canManageMails()` check protects mail previews and attachment downloads. Attachments can only be downloaded through the mail record they belong to, and previews run in a sandboxed iframe. +`canManageMails()` decides whether a user may use the mail log at all. Most +applications stop there, and nothing else changes. If you also need to decide +per mail — for example in a multi-tenant application where a user may only open +mails that belong to their own tenant — register a policy for your mail model. +When one exists, the preview and attachment routes authorize `view` for the +authenticated user and that mail: + +```php +class MailPolicy +{ + public function view(User $user, Mail $mail): bool + { + return $user->tenant_id === $mail->tenant_id; + } +} +``` + ### Tenant middleware and route protection If you want to protect the mail routes with your tenant middleware, add them to `authenticatedTenantRoutes()`: diff --git a/src/Controllers/MailDownloadController.php b/src/Controllers/MailDownloadController.php index c2e5e0f..4364626 100644 --- a/src/Controllers/MailDownloadController.php +++ b/src/Controllers/MailDownloadController.php @@ -5,6 +5,7 @@ use Illuminate\Http\Request; use Illuminate\Routing\Controller; use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\Gate; use Symfony\Component\HttpFoundation\StreamedResponse; class MailDownloadController extends Controller @@ -15,6 +16,12 @@ public function __invoke(Request $request): StreamedResponse $mail = $mailModel::findOrFail($request->route('mail')); + // canManageMails() answers "may this user use the mail log at all"; a host with a policy + // for the mail model also gets to answer "may they see this particular mail". + if (Gate::getPolicyFor($mail) !== null) { + Gate::authorize('view', $mail); + } + $attachment = $mail->attachments()->findOrFail($request->route('attachment')); return $attachment->downloadFileFromStorage(); diff --git a/src/Controllers/MailPreviewController.php b/src/Controllers/MailPreviewController.php index 833d1ab..e838e66 100644 --- a/src/Controllers/MailPreviewController.php +++ b/src/Controllers/MailPreviewController.php @@ -5,6 +5,7 @@ use Illuminate\Http\Request; use Illuminate\Routing\Controller; use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\Gate; use Symfony\Component\HttpFoundation\Response; class MailPreviewController extends Controller @@ -15,6 +16,12 @@ public function __invoke(Request $request): Response $mail = $mailModel::findOrFail($request->route('mail')); + // canManageMails() answers "may this user use the mail log at all"; a host with a policy + // for the mail model also gets to answer "may they see this particular mail". + if (Gate::getPolicyFor($mail) !== null) { + Gate::authorize('view', $mail); + } + return response($mail->html, 200, [ 'Content-Type' => 'text/html; charset=UTF-8', 'Content-Security-Policy' => "sandbox; frame-ancestors 'self'", diff --git a/tests/Fixtures/AllowSpecificUserPolicy.php b/tests/Fixtures/AllowSpecificUserPolicy.php new file mode 100644 index 0000000..a2482ab --- /dev/null +++ b/tests/Fixtures/AllowSpecificUserPolicy.php @@ -0,0 +1,13 @@ +email === 'allowed@example.com'; + } +} diff --git a/tests/MailRouteSecurityTest.php b/tests/MailRouteSecurityTest.php index 60f6c50..59d9137 100644 --- a/tests/MailRouteSecurityTest.php +++ b/tests/MailRouteSecurityTest.php @@ -3,16 +3,18 @@ use Backstage\Mails\Laravel\Models\Mail; use Backstage\Mails\Laravel\Models\MailAttachment; use Backstage\Mails\MailsPlugin; +use Backstage\Mails\Tests\Fixtures\AllowSpecificUserPolicy; use Backstage\Mails\Tests\Fixtures\User; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; -function mailUser(): User +function mailUser(string $email = 'test@example.com'): User { return User::create([ 'name' => 'Test User', - 'email' => 'test@example.com', + 'email' => $email, 'password' => Hash::make('password'), ]); } @@ -167,3 +169,38 @@ function downloadUrl(Mail $mail, MailAttachment $attachment): string ->toContain('referrerpolicy="no-referrer"') ->not->toContain('src="'); }); + +it('consults the host mail policy for the preview when one is registered', function () { + Gate::policy(Mail::class, AllowSpecificUserPolicy::class); + MailsPlugin::get()->canManageMails(true); + + $mail = Mail::factory()->create(['html' => '
visible
']); + + $this->actingAs(mailUser('denied@example.com')) + ->get(previewUrl($mail)) + ->assertForbidden(); + + $this->actingAs(mailUser('allowed@example.com')) + ->get(previewUrl($mail)) + ->assertSuccessful() + ->assertSee('visible'); +}); + +it('consults the host mail policy for attachment downloads when one is registered', function () { + Storage::fake('local'); + Gate::policy(Mail::class, AllowSpecificUserPolicy::class); + MailsPlugin::get()->canManageMails(true); + + $mail = Mail::factory()->create(); + $attachment = attachmentFor($mail); + + $this->actingAs(mailUser('denied@example.com')) + ->get(downloadUrl($mail, $attachment)) + ->assertForbidden(); + + $response = $this->actingAs(mailUser('allowed@example.com')) + ->get(downloadUrl($mail, $attachment)) + ->assertSuccessful(); + + expect($response->streamedContent())->toBe('CONFIDENTIAL'); +});