diff --git a/.gitignore b/.gitignore index 0952a15..4f49310 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# https://github.com/jeromecoloma/sessionx +.sessionx.yaml + .DS_Store .idea .phpunit.result.cache @@ -11,4 +14,4 @@ phpunit.xml phpstan.neon testbench.yaml vendor -!docs \ No newline at end of file +!docs diff --git a/README.md b/README.md index 5322bb2..95e2303 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ Optionally, you can publish the views using php artisan vendor:publish --tag="mails-views" ``` -Add the routes to the PanelProvider using the `routes()` method, like this: +Add the routes to the PanelProvider using the `authenticatedRoutes()` method, like this: ```php use Backstage\Mails\Facades\Mails; @@ -68,10 +68,15 @@ use Backstage\Mails\Facades\Mails; public function panel(Panel $panel): Panel { return $panel - ->routes(fn () => Mails::routes()); + ->authenticatedRoutes(fn () => Mails::routes()); } ``` +The preview and attachment routes also enforce panel authentication and the +`canManageMails()` check themselves, so they remain protected if they are +registered with `routes()`. `authenticatedRoutes()` is still the recommended +registration method. + Then add the plugin to your `PanelProvider` ```php @@ -116,9 +121,13 @@ $panel This example demonstrates how to combine role-based and permission-based access control, providing a more robust and flexible approach to managing access to mail resources. +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. + ### Tenant middleware and route protection -If you want to protect the mail routes with your (tenant) middleware, you can do so by adding the routes to the `tenantRoutes`: +If you want to protect the mail routes with your tenant middleware, add them to `authenticatedTenantRoutes()`: ```php use Backstage\Mails\MailsPlugin; @@ -128,7 +137,7 @@ public function panel(Panel $panel): Panel { return $panel ->plugin(MailsPlugin::make()) - ->tenantRoutes(fn() => Mails::routes()); + ->authenticatedTenantRoutes(fn () => Mails::routes()); } ``` diff --git a/resources/views/mails/preview.blade.php b/resources/views/mails/preview.blade.php index 0f45184..b966354 100644 --- a/resources/views/mails/preview.blade.php +++ b/resources/views/mails/preview.blade.php @@ -1,6 +1,9 @@
secret
']); + + $this->get(previewUrl($mail)) + ->assertRedirect(route('filament.admin.auth.login')); +}); + +it('forbids an authenticated user without mail permissions', function () { + $mail = Mail::factory()->create(['html' => 'secret
']); + + MailsPlugin::get()->canManageMails(false); + + $this->actingAs(mailUser()) + ->get(previewUrl($mail)) + ->assertForbidden(); +}); + +it('allows a permitted user to preview a mail with hardening headers', function () { + $mail = Mail::factory()->create(['html' => 'secret
']); + + MailsPlugin::get()->canManageMails(true); + + $this->actingAs(mailUser()) + ->get(previewUrl($mail)) + ->assertSuccessful() + ->assertSee('secret') + ->assertHeader('Content-Type', 'text/html; charset=UTF-8') + ->assertHeader('Content-Security-Policy', "sandbox; frame-ancestors 'self'") + ->assertHeader('Referrer-Policy', 'no-referrer') + ->assertHeader('X-Content-Type-Options', 'nosniff') + ->assertHeader('X-Frame-Options', 'SAMEORIGIN'); +}); + +it('returns not found for an unknown mail preview', function () { + MailsPlugin::get()->canManageMails(true); + + $url = route('filament.admin.mails.preview', ['mail' => 99999]); + + $this->actingAs(mailUser())->get($url)->assertNotFound(); +}); + +it('redirects a guest away from an attachment download', function () { + Storage::fake('local'); + + $mail = Mail::factory()->create(); + $attachment = attachmentFor($mail); + + $this->get(downloadUrl($mail, $attachment)) + ->assertRedirect(route('filament.admin.auth.login')); +}); + +it('forbids an authenticated user without mail permissions from downloading an attachment', function () { + Storage::fake('local'); + + $mail = Mail::factory()->create(); + $attachment = attachmentFor($mail); + + MailsPlugin::get()->canManageMails(false); + + $this->actingAs(mailUser()) + ->get(downloadUrl($mail, $attachment)) + ->assertForbidden(); +}); + +it('does not serve an attachment belonging to a different mail', function () { + Storage::fake('local'); + + $mail = Mail::factory()->create(); + $otherMail = Mail::factory()->create(); + $attachmentOfOtherMail = attachmentFor($otherMail, 'secret.pdf'); + + MailsPlugin::get()->canManageMails(true); + + $this->actingAs(mailUser()) + ->get(downloadUrl($mail, $attachmentOfOtherMail)) + ->assertNotFound(); +}); + +it('serves an attachment that belongs to the mail', function () { + Storage::fake('local'); + + $mail = Mail::factory()->create(); + $attachment = attachmentFor($mail, 'invoice.pdf', 'INVOICE BODY'); + + MailsPlugin::get()->canManageMails(true); + + $response = $this->actingAs(mailUser()) + ->get(downloadUrl($mail, $attachment)) + ->assertSuccessful(); + + expect($response->streamedContent())->toBe('INVOICE BODY'); +}); + +it('returns not found for an unknown attachment', function () { + Storage::fake('local'); + + $mail = Mail::factory()->create(); + + MailsPlugin::get()->canManageMails(true); + + $url = route('filament.admin.mails.attachment.download', [ + 'mail' => $mail->getKey(), + 'attachment' => 99999, + 'filename' => 'missing.pdf', + ]); + + $this->actingAs(mailUser())->get($url)->assertNotFound(); +}); + +it('renders stored html in a sandboxed inline preview', function () { + $html = 'Preview
'; + + $preview = view('mails::mails.preview', ['html' => $html])->render(); + + expect($preview) + ->toContain('srcdoc="<script>') + ->toContain('sandbox') + ->toContain('referrerpolicy="no-referrer"') + ->not->toContain('src="'); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index 5f7b71b..65f290c 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,8 +2,10 @@ namespace Backstage\Mails\Tests; +use Backstage\Mails\Laravel\MailsServiceProvider as LaravelMailsServiceProvider; use Backstage\Mails\MailsServiceProvider; use Backstage\Mails\Tests\Fixtures\TestPanelProvider; +use Backstage\Mails\Tests\Fixtures\User; use BladeUI\Heroicons\BladeHeroiconsServiceProvider; use BladeUI\Icons\BladeIconsServiceProvider; use Filament\Actions\ActionsServiceProvider; @@ -15,6 +17,9 @@ use Filament\Tables\TablesServiceProvider; use Filament\Widgets\WidgetsServiceProvider; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Schema; use Livewire\LivewireServiceProvider; use Orchestra\Testbench\TestCase as Orchestra; use RyanChandler\BladeCaptureDirective\BladeCaptureDirectiveServiceProvider; @@ -45,6 +50,7 @@ protected function getPackageProviders($app) SupportServiceProvider::class, TablesServiceProvider::class, WidgetsServiceProvider::class, + LaravelMailsServiceProvider::class, MailsServiceProvider::class, TestPanelProvider::class, ]; @@ -53,5 +59,24 @@ protected function getPackageProviders($app) public function getEnvironmentSetUp($app) { config()->set('database.default', 'testing'); + config()->set('auth.providers.users.model', User::class); + } + + protected function defineDatabaseMigrations(): void + { + Schema::create('users', function (Blueprint $table): void { + $table->id(); + $table->string('name'); + $table->string('email')->unique(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + + $directory = __DIR__ . '/../vendor/backstage/laravel-mails/database/migrations'; + + foreach (File::files($directory) as $file) { + (include $file->getPathname())->up(); + } } }