Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/Concerns/HasRolesAndPermissions.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,10 @@ public function canImpersonate(): bool
return true;
}

// Check if user has impersonate permission
if ($this->hasPermissionTo('impersonate users')) {
// Check if user has impersonate permission. checkPermissionTo() returns false instead of
// throwing PermissionDoesNotExist when the permission has not been seeded yet, which would
// otherwise crash every users table render (ImpersonateAction::visible calls this per row).
if ($this->checkPermissionTo('impersonate users')) {
return true;
}

Expand Down
16 changes: 10 additions & 6 deletions src/Http/Middleware/ImpersonationBanner.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
use Closure;
use Illuminate\Http\Request;
use Laravilt\Users\Services\ImpersonationService;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;

class ImpersonationBanner
{
Expand All @@ -20,8 +22,8 @@ public function handle(Request $request, Closure $next): Response
{
$response = $next($request);

// Only inject banner for HTML responses
if (! $this->isHtmlResponse($response)) {
// Only inject banner for HTML responses (streamed/file responses cannot have their content replaced)
if ($response instanceof StreamedResponse || $response instanceof BinaryFileResponse || ! $this->isHtmlResponse($response)) {
return $response;
}

Expand Down Expand Up @@ -61,8 +63,10 @@ protected function isHtmlResponse(Response $response): bool
*/
protected function renderBanner(): string
{
$impersonator = $this->impersonationService->getImpersonator();
$stopUrl = route('laravilt.users.stop-impersonation');
// The name is user-controlled: escape it (and the rest) before injecting into the page
$impersonatorName = e($this->impersonationService->getImpersonator()?->name ?? '');
$stopUrl = e(route('laravilt.users.stop-impersonation'));
$csrfToken = e($this->getCsrfToken());

return <<<HTML
<div id="impersonation-banner" style="
Expand All @@ -81,9 +85,9 @@ protected function renderBanner(): string
font-family: system-ui, -apple-system, sans-serif;
font-size: 14px;
">
<span>You are impersonating as <strong>{$impersonator?->name}</strong></span>
<span>You are impersonating as <strong>{$impersonatorName}</strong></span>
<form action="{$stopUrl}" method="POST" style="margin: 0;">
<input type="hidden" name="_token" value="{$this->getCsrfToken()}">
<input type="hidden" name="_token" value="{$csrfToken}">
<button type="submit" style="
background: #ef4444;
color: white;
Expand Down
74 changes: 74 additions & 0 deletions tests/Feature/ImpersonationHardeningTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

use Illuminate\Http\Request;
use Laravilt\Users\Http\Middleware\ImpersonationBanner;
use Laravilt\Users\Services\ImpersonationService;
use Laravilt\Users\Tests\Models\User;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;

it('escapes the impersonator name in the injected banner', function () {
$impersonator = User::factory()->create(['name' => '<script>alert(1)</script>']);
$target = User::factory()->create();
$this->actingAs($impersonator);

app(ImpersonationService::class)->impersonate($impersonator, $target);

$response = app(ImpersonationBanner::class)->handle(
Request::create('/admin'),
fn () => response('<html><body><p>page</p></body></html>')
);

expect($response->getContent())
->toContain('id="impersonation-banner"')
->toContain('&lt;script&gt;alert(1)&lt;/script&gt;')
->not->toContain('<script>alert(1)</script>');
});

it('passes streamed responses through untouched while impersonating', function () {
$impersonator = User::factory()->create();
$target = User::factory()->create();
$this->actingAs($impersonator);

app(ImpersonationService::class)->impersonate($impersonator, $target);

$streamed = new StreamedResponse(fn () => print ('chunk'));

$response = app(ImpersonationBanner::class)->handle(Request::create('/export'), fn () => $streamed);

expect($response)->toBe($streamed);
});

it('passes file download responses through untouched while impersonating', function () {
$impersonator = User::factory()->create();
$target = User::factory()->create();
$this->actingAs($impersonator);

app(ImpersonationService::class)->impersonate($impersonator, $target);

// HTML-looking body: if the middleware treated it as HTML it would try to inject after <body>.
$body = '<html><body><p>report</p></body></html>';
$path = tempnam(sys_get_temp_dir(), 'laravilt-download-');
file_put_contents($path, $body);

$download = new BinaryFileResponse($path, 200, [], true, 'attachment');
$download->setContentDisposition('attachment', 'report.csv');

// getContent() is false for file responses; no Content-Type until prepare().
expect($download->getContent())->toBeFalse();

$response = app(ImpersonationBanner::class)->handle(Request::create('/export'), fn () => $download);

expect($response)->toBe($download)
->and($response->getContent())->toBeFalse()
->and($response->headers->get('Content-Disposition'))->toBe('attachment; filename=report.csv')
->and(file_get_contents($response->getFile()->getPathname()))->toBe($body);

@unlink($path);
});

it('does not throw from canImpersonate when the impersonate permission is not seeded', function () {
$user = User::factory()->create();

expect($user->canImpersonate())->toBeFalse();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.