|
@@ -6,6 +6,7 @@ namespace App\Tests\Unit\Auth;
|
|
|
|
|
|
|
|
use App\Auth\SessionManager;
|
|
use App\Auth\SessionManager;
|
|
|
use App\Auth\UserContext;
|
|
use App\Auth\UserContext;
|
|
|
|
|
+use PHPUnit\Framework\Attributes\DataProvider;
|
|
|
use PHPUnit\Framework\TestCase;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
@@ -171,6 +172,82 @@ final class SessionManagerTest extends TestCase
|
|
|
self::assertSame(7, $u->userId);
|
|
self::assertSame(7, $u->userId);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * @return iterable<string, array{0: string, 1: bool}>
|
|
|
|
|
+ */
|
|
|
|
|
+ public static function isSafeRedirectPathCases(): iterable
|
|
|
|
|
+ {
|
|
|
|
|
+ // SEC_REVIEW F10 truth table for the open-redirect guard.
|
|
|
|
|
+ yield 'simple absolute path' => ['/app/dashboard', true];
|
|
|
|
|
+ yield 'absolute path with query' => ['/app/ips/1.2.3.4?tab=a', true];
|
|
|
|
|
+ yield 'just the slash' => ['/', true];
|
|
|
|
|
+ yield 'protocol-relative URL' => ['//evil.example.com/phish', false];
|
|
|
|
|
+ yield 'absolute https URL' => ['https://evil.example.com', false];
|
|
|
|
|
+ yield 'absolute http URL' => ['http://evil.example.com', false];
|
|
|
|
|
+ yield 'bare hostname' => ['evil.example.com/x', false];
|
|
|
|
|
+ yield 'relative path' => ['app/dashboard', false];
|
|
|
|
|
+ yield 'empty string' => ['', false];
|
|
|
|
|
+ yield 'backslash after slash' => ['/\\evil.example.com', false];
|
|
|
|
|
+ yield 'CR header injection' => ["/app\r\nLocation: //evil", false];
|
|
|
|
|
+ yield 'LF header injection' => ["/app\nfoo", false];
|
|
|
|
|
+ yield 'NUL character' => ["/app\x00", false];
|
|
|
|
|
+ yield 'tab character' => ["/app\t", false];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[DataProvider('isSafeRedirectPathCases')]
|
|
|
|
|
+ public function testIsSafeRedirectPathTruthTable(string $url, bool $expected): void
|
|
|
|
|
+ {
|
|
|
|
|
+ self::assertSame($expected, SessionManager::isSafeRedirectPath($url));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testSetNextDropsUnsafeValueSilently(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ // SEC_REVIEW F10: `setNext()` is called with attacker-influenced
|
|
|
|
|
+ // input from form bodies; an unsafe value MUST NOT enter the
|
|
|
|
|
+ // session at all, so a future consumeNext() can't return it.
|
|
|
|
|
+ $sm = $this->mgr();
|
|
|
|
|
+ $sm->startSession();
|
|
|
|
|
+
|
|
|
|
|
+ $sm->setNext('//evil.example.com/phish');
|
|
|
|
|
+ self::assertNull($sm->consumeNext(), 'unsafe URL was stored in next');
|
|
|
|
|
+
|
|
|
|
|
+ $sm->setNext('/app/allowlist');
|
|
|
|
|
+ self::assertSame('/app/allowlist', $sm->consumeNext());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testConsumeNextRejectsPreviouslyStoredUnsafeValue(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ // Defence-in-depth: even if something writes directly to
|
|
|
|
|
+ // $_SESSION['_next'], consumeNext() refuses to return an unsafe
|
|
|
|
|
+ // value (and clears it).
|
|
|
|
|
+ $sm = $this->mgr();
|
|
|
|
|
+ $sm->startSession();
|
|
|
|
|
+ $_SESSION['_next'] = '//evil.example.com/phish';
|
|
|
|
|
+
|
|
|
|
|
+ self::assertNull($sm->consumeNext());
|
|
|
|
|
+ self::assertArrayNotHasKey('_next', $_SESSION);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testSafeNextOrDefaultUsesDefaultOnUnsafeOrMissing(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ self::assertSame(
|
|
|
|
|
+ '/app/allowlist',
|
|
|
|
|
+ SessionManager::safeNextOrDefault(null, '/app/allowlist'),
|
|
|
|
|
+ );
|
|
|
|
|
+ self::assertSame(
|
|
|
|
|
+ '/app/allowlist',
|
|
|
|
|
+ SessionManager::safeNextOrDefault('//evil', '/app/allowlist'),
|
|
|
|
|
+ );
|
|
|
|
|
+ self::assertSame(
|
|
|
|
|
+ '/app/allowlist',
|
|
|
|
|
+ SessionManager::safeNextOrDefault(123, '/app/allowlist'),
|
|
|
|
|
+ );
|
|
|
|
|
+ self::assertSame(
|
|
|
|
|
+ '/app/manual-blocks?id=1',
|
|
|
|
|
+ SessionManager::safeNextOrDefault('/app/manual-blocks?id=1', '/app/allowlist'),
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private function mgr(int $idle = 28800): SessionManager
|
|
private function mgr(int $idle = 28800): SessionManager
|
|
|
{
|
|
{
|
|
|
return new SessionManager(secureCookie: false, idleSeconds: $idle, absoluteSeconds: 86400);
|
|
return new SessionManager(secureCookie: false, idleSeconds: $idle, absoluteSeconds: 86400);
|