diff --git a/src/Http/Middleware/EnsureFrontendRequestsAreStateful.php b/src/Http/Middleware/EnsureFrontendRequestsAreStateful.php index 39394b6f..50f6723f 100644 --- a/src/Http/Middleware/EnsureFrontendRequestsAreStateful.php +++ b/src/Http/Middleware/EnsureFrontendRequestsAreStateful.php @@ -30,10 +30,10 @@ function ($request, $next) { return $next($request); }, - config('sanctum.middleware.encrypt_cookies', \Illuminate\Cookie\Middleware\EncryptCookies::class), + static::resolveMiddleware('sanctum.middleware.encrypt_cookies', \Illuminate\Cookie\Middleware\EncryptCookies::class), \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, \Illuminate\Session\Middleware\StartSession::class, - config('sanctum.middleware.verify_csrf_token', \Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class), + static::resolveMiddleware('sanctum.middleware.verify_csrf_token', \Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class), ] : [])->then(function ($request) use ($next) { return $next($request); }); @@ -72,9 +72,54 @@ public static function fromFrontend($request) $stateful = array_filter(config('log-viewer.api_stateful_domains') ?? config('sanctum.stateful') ?? self::defaultStatefulDomains()); - return Str::is(Collection::make($stateful)->map(function ($uri) { + $matchesStatefulDomains = Str::is(Collection::make($stateful)->map(function ($uri) { return trim($uri).'/*'; })->all(), $domain); + + if ($matchesStatefulDomains) { + return true; + } + + // If APP_URL is not configured, allow same-domain requests as a fallback + if (empty(config('app.url'))) { + return self::isSameDomainRequest($request, $domain); + } + + return false; + } + + /** + * Check if the referer/origin domain matches the current request's domain. + * + * @param \Illuminate\Http\Request $request + * @param string $refererDomain + * @return bool + */ + protected static function isSameDomainRequest($request, $refererDomain) + { + $currentHost = $request->getHost(); + $currentPort = $request->getPort(); + + // Build current domain with port if not default + $currentDomain = $currentHost; + if (! in_array($currentPort, [80, 443])) { + $currentDomain .= ':'.$currentPort; + } + + // Extract host:port from referer domain (strip path) + $refererHostPort = explode('/', $refererDomain)[0]; + + return $refererHostPort === $currentDomain; + } + + /** + * Resolve middleware class from config with fallback. + */ + protected static function resolveMiddleware(string $configKey, string $default): string + { + $middleware = config($configKey, $default); + + return class_exists($middleware) ? $middleware : $default; } protected static function defaultStatefulDomains(): array diff --git a/tests/Feature/Authorization/ApiAuthenticationTest.php b/tests/Feature/Authorization/ApiAuthenticationTest.php new file mode 100644 index 00000000..50af5774 --- /dev/null +++ b/tests/Feature/Authorization/ApiAuthenticationTest.php @@ -0,0 +1,204 @@ +assertOk(); + + expect($callbackInvoked)->toBeTrue(); +}); + +test('auth callback denies access to API routes when it returns false', function () { + LogViewer::auth(fn ($request) => false); + + getJson(route('log-viewer.folders'))->assertForbidden(); +}); + +test('authentication works when APP_URL is empty using same-domain fallback', function () { + config([ + 'app.url' => '', + 'log-viewer.api_stateful_domains' => [], // Override to exclude localhost + ]); + + // Auth callback that requires session to be started (proving session middleware was applied) + LogViewer::auth(function ($request) { + if (! $request->hasSession() || ! $request->session()->isStarted()) { + return false; + } + + return true; + }); + + $response = getJson('http://production.example.com/log-viewer/api/folders', [ + 'referer' => 'http://production.example.com/', + ]); + + $response->assertOk(); +}); + +test('authentication works when APP_URL matches request domain', function () { + config(['app.url' => 'http://example.com']); + + LogViewer::auth(fn ($request) => true); + + $response = getJson('http://example.com/log-viewer/api/folders', [ + 'referer' => 'http://example.com/', + ]); + + $response->assertOk(); +}); + +test('authentication fails when APP_URL is set but referer does not match', function () { + config(['app.url' => 'http://configured-domain.com']); + + // Auth callback that checks for authenticated user (simulating real-world usage) + LogViewer::auth(function ($request) { + // In real usage, Auth::user() would be null because session middleware isn't applied + // For this test, we'll simulate that by checking if session is available + if (! $request->hasSession() || ! $request->session()->isStarted()) { + return false; + } + + return true; + }); + + // Request from different domain with referer that doesn't match stateful domains + $response = getJson('http://different-domain.com/log-viewer/api/folders', [ + 'referer' => 'http://different-domain.com/', + ]); + + // Should fail because session middleware is not applied, causing auth callback to return false + $response->assertForbidden(); +}); + +test('same-domain requests work without APP_URL configured', function () { + config(['app.url' => null]); + + // Auth callback that requires session to be started (proving session middleware was applied) + LogViewer::auth(function ($request) { + if (! $request->hasSession() || ! $request->session()->isStarted()) { + return false; + } + + return true; + }); + + // Simulate request from same domain + $response = getJson('http://production.example.com/log-viewer/api/folders', [ + 'referer' => 'http://production.example.com/log-viewer', + ]); + + $response->assertOk(); +}); + +test('same-domain requests with custom port work without APP_URL', function () { + config(['app.url' => null]); + + // Auth callback that requires session to be started (proving session middleware was applied) + LogViewer::auth(function ($request) { + if (! $request->hasSession() || ! $request->session()->isStarted()) { + return false; + } + + return true; + }); + + // Simulate request from same domain with custom port + $response = getJson('http://production.example.com:8080/log-viewer/api/folders', [ + 'referer' => 'http://production.example.com:8080/log-viewer', + ]); + + $response->assertOk(); +}); + +test('cross-domain requests are rejected when APP_URL is empty', function () { + config(['app.url' => null]); + + // Auth callback that checks for session (simulating Auth::check() behavior) + LogViewer::auth(function ($request) { + if (! $request->hasSession() || ! $request->session()->isStarted()) { + return false; + } + + return true; + }); + + // Request to one domain with referer from different domain + $response = getJson('http://domain-a.com/log-viewer/api/folders', [ + 'referer' => 'http://domain-b.com/', + ]); + + // Should fail because domains don't match, session middleware not applied + $response->assertForbidden(); +}); + +test('requests without referer or origin are rejected', function () { + config(['app.url' => null]); + + // Auth callback that checks for session + LogViewer::auth(function ($request) { + if (! $request->hasSession() || ! $request->session()->isStarted()) { + return false; + } + + return true; + }); + + // Request without referer header + $response = getJson(route('log-viewer.folders')); + + // Should fail because we can't determine if it's from frontend, so session middleware not applied + $response->assertForbidden(); +}); + +test('localhost requests work by default regardless of APP_URL', function () { + config(['app.url' => 'http://production.com']); + + LogViewer::auth(fn ($request) => true); + + // Localhost is in the default stateful domains + $response = getJson('http://localhost/log-viewer/api/folders', [ + 'referer' => 'http://localhost/', + ]); + + $response->assertOk(); +}); + +test('127.0.0.1 requests work by default regardless of APP_URL', function () { + config(['app.url' => 'http://production.com']); + + LogViewer::auth(fn ($request) => true); + + // 127.0.0.1 is in the default stateful domains + $response = getJson('http://127.0.0.1/log-viewer/api/folders', [ + 'referer' => 'http://127.0.0.1/', + ]); + + $response->assertOk(); +}); + +test('custom stateful domains override APP_URL behavior', function () { + config([ + 'app.url' => null, + 'log-viewer.api_stateful_domains' => ['custom-domain.com'], + ]); + + LogViewer::auth(fn ($request) => true); + + // Custom domain should work + $response = getJson('http://custom-domain.com/log-viewer/api/folders', [ + 'referer' => 'http://custom-domain.com/', + ]); + + $response->assertOk(); +}); diff --git a/tests/Feature/Authorization/CanViewLogViewerTest.php b/tests/Feature/Authorization/CanViewLogViewerTest.php index 174adb55..27f7d064 100644 --- a/tests/Feature/Authorization/CanViewLogViewerTest.php +++ b/tests/Feature/Authorization/CanViewLogViewerTest.php @@ -4,6 +4,7 @@ use Opcodes\LogViewer\Facades\LogViewer; use function Pest\Laravel\get; +use function Pest\Laravel\getJson; test('can define an "auth" callback for authorization', function () { get(route('log-viewer.index'))->assertOk(); @@ -64,3 +65,26 @@ get(route('log-viewer.index'))->assertOk(); }); + +test('auth callback works consistently for both web and API routes', function () { + $webCalls = 0; + $apiCalls = 0; + + LogViewer::auth(function ($request) use (&$webCalls, &$apiCalls) { + if ($request->is('log-viewer/api/*')) { + $apiCalls++; + } else { + $webCalls++; + } + + return true; + }); + + // Access web route + get(route('log-viewer.index'))->assertOk(); + expect($webCalls)->toBe(1); + + // Access API route + getJson(route('log-viewer.folders'), ['referer' => 'http://localhost/'])->assertOk(); + expect($apiCalls)->toBe(1); +});