diff --git a/README.md b/README.md index 95e2303..a2496c5 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,22 @@ 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. 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 +additionally authorize the `view` ability against the requested 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/DenyOddMailPolicy.php b/tests/Fixtures/DenyOddMailPolicy.php new file mode 100644 index 0000000..83531b3 --- /dev/null +++ b/tests/Fixtures/DenyOddMailPolicy.php @@ -0,0 +1,13 @@ +getKey() % 2 === 0; + } +} diff --git a/tests/MailRouteSecurityTest.php b/tests/MailRouteSecurityTest.php index 60f6c50..86f9d62 100644 --- a/tests/MailRouteSecurityTest.php +++ b/tests/MailRouteSecurityTest.php @@ -3,7 +3,9 @@ use Backstage\Mails\Laravel\Models\Mail; use Backstage\Mails\Laravel\Models\MailAttachment; use Backstage\Mails\MailsPlugin; +use Backstage\Mails\Tests\Fixtures\DenyOddMailPolicy; 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; @@ -167,3 +169,26 @@ 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, DenyOddMailPolicy::class); + MailsPlugin::get()->canManageMails(true); + + $user = mailUser(); + $denied = Mail::factory()->create(['id' => 1, 'html' => '

secret

']); + $allowed = Mail::factory()->create(['id' => 2, 'html' => '

visible

']); + + $this->actingAs($user)->get(previewUrl($denied))->assertForbidden(); + $this->actingAs($user)->get(previewUrl($allowed))->assertSuccessful()->assertSee('visible'); +}); + +it('consults the host mail policy for attachment downloads when one is registered', function () { + Storage::fake('local'); + Gate::policy(Mail::class, DenyOddMailPolicy::class); + MailsPlugin::get()->canManageMails(true); + + $denied = Mail::factory()->create(['id' => 1]); + $attachment = attachmentFor($denied); + + $this->actingAs(mailUser())->get(downloadUrl($denied, $attachment))->assertForbidden(); +});