Skip to content
51 changes: 48 additions & 3 deletions src/Http/Middleware/EnsureFrontendRequestsAreStateful.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down Expand Up @@ -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
Expand Down
204 changes: 204 additions & 0 deletions tests/Feature/Authorization/ApiAuthenticationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
<?php

use Opcodes\LogViewer\Facades\LogViewer;

use function Pest\Laravel\getJson;

test('auth callback is called for API routes', function () {
$callbackInvoked = false;

LogViewer::auth(function ($request) use (&$callbackInvoked) {
$callbackInvoked = true;

return true;
});

getJson(route('log-viewer.folders'))->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();
});
24 changes: 24 additions & 0 deletions tests/Feature/Authorization/CanViewLogViewerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
});
Loading