diff --git a/app/Config/Auth.php b/app/Config/Auth.php index dcdad94..d90f0df 100644 --- a/app/Config/Auth.php +++ b/app/Config/Auth.php @@ -443,22 +443,88 @@ class Auth extends ShieldAuth $session = session(); $beforeLoginUrl = (string) ($session->getTempdata('beforeLoginUrl') ?? ''); - // Some temporary/internal URLs can be stored as beforeLoginUrl in production - // and then hijack post-login landing. Skip those and use configured default. + // Always consume and clear beforeLoginUrl so it is never reused across logins + if ($beforeLoginUrl !== '') { + $session->removeTempdata('beforeLoginUrl'); + } + + // Validate beforeLoginUrl: strictly prevent redirecting to API, AJAX, polling, + // background workers, static assets, or non-HTML endpoints that hijack post-login landing. if ($beforeLoginUrl !== '') { $path = (string) parse_url($beforeLoginUrl, PHP_URL_PATH); $path = '/' . ltrim($path, '/'); - $blockedPaths = [ - '/doctor_work/document_workspace', - '/opd/appointment', + $pathLower = strtolower($path); + + $isInvalidLanding = false; + + // 1. Root, login, or logout endpoints + if ($pathLower === '' || $pathLower === '/' || str_starts_with($pathLower, '/login') || str_starts_with($pathLower, '/logout')) { + $isInvalidLanding = true; + } + + // 2. Action keywords associated with AJAX, JSON, or background requests + $invalidKeywords = [ + '/fetch', + '/ajax', + '/api', + '/poll', + '/status', + '/sync', + '/token', + '/search', + '/lookup', + '/callback', + '/webhook', + '/check', + '/captcha', + '/verify', ]; - if (in_array(strtolower($path), $blockedPaths, true)) { + foreach ($invalidKeywords as $kw) { + if (str_contains($pathLower, $kw)) { + $isInvalidLanding = true; + break; + } + } + + // 3. Controllers or routes that perform background processing or modular sub-views + if (! $isInvalidLanding) { + $blockedPrefixes = [ + '/abdmopdqueue', + '/abdmgateway', + '/abdmhiu', + '/abdmsync', + '/abdmcodingpanel', + '/abdmfhirinspector', + '/abdmbridgelog', + '/auth/', + '/doctor_work/document_workspace', + '/opd/appointment', + ]; + foreach ($blockedPrefixes as $prefix) { + if (str_starts_with($pathLower, $prefix)) { + $isInvalidLanding = true; + break; + } + } + } + + // 4. File extensions (assets, documents, JSON) + if (! $isInvalidLanding && preg_match('/\.(json|xml|csv|pdf|png|jpg|jpeg|gif|ico|css|js|woff|woff2|ttf|map)$/i', $pathLower)) { + $isInvalidLanding = true; + } + + if ($isInvalidLanding) { $beforeLoginUrl = ''; - $session->removeTempdata('beforeLoginUrl'); } } - $url = $beforeLoginUrl !== '' ? $beforeLoginUrl : (string) setting('Auth.redirects')['login']; + try { + $defaultLogin = (string) (setting('Auth.redirects')['login'] ?? '/'); + } catch (\Throwable $e) { + $defaultLogin = (string) ($this->redirects['login'] ?? '/'); + } + + $url = $beforeLoginUrl !== '' ? $beforeLoginUrl : $defaultLogin; return $this->getUrl($url); } diff --git a/app/Filters/LoginThrottleFilter.php b/app/Filters/LoginThrottleFilter.php index c572d15..8bc96fe 100644 --- a/app/Filters/LoginThrottleFilter.php +++ b/app/Filters/LoginThrottleFilter.php @@ -51,6 +51,27 @@ class LoginThrottleFilter implements FilterInterface return; } + // Cleanse any stale or invalid beforeLoginUrl (e.g. from background AJAX polling) + $session = session(); + $staleUrl = (string) ($session->getTempdata('beforeLoginUrl') ?? ''); + if ($staleUrl !== '') { + $stalePath = strtolower((string) parse_url($staleUrl, PHP_URL_PATH)); + if ( + str_contains($stalePath, '/fetch') + || str_contains($stalePath, '/ajax') + || str_contains($stalePath, '/api') + || str_contains($stalePath, 'abdmopdqueue') + || str_contains($stalePath, 'abdmgateway') + || str_contains($stalePath, 'abdmhiu') + || str_contains($stalePath, 'abdmsync') + || str_contains($stalePath, 'auth/') + || str_contains($stalePath, 'captcha') + || str_contains($stalePath, 'doctor_work') + ) { + $session->removeTempdata('beforeLoginUrl'); + } + } + // Only throttle state-changing requests (POST) if (strtoupper($request->getMethod()) !== 'POST') { return; diff --git a/app/Views/abdm/opd_queue.php b/app/Views/abdm/opd_queue.php index a1025e1..64d1bf3 100644 --- a/app/Views/abdm/opd_queue.php +++ b/app/Views/abdm/opd_queue.php @@ -391,17 +391,33 @@ } function loadQueue() { - const date = document.getElementById('queueDate').value; - const status = document.getElementById('queueStatus').value; + const bodyEl = document.getElementById('abdmQueueBody'); + const dateEl = document.getElementById('queueDate'); + if (!bodyEl || !dateEl) { + clearTimeout(autoTimer); + return; + } + + const date = dateEl.value; + const status = document.getElementById('queueStatus')?.value || ''; const url = BASE + 'AbdmOpdQueue/fetch?date=' + encodeURIComponent(date) + '&status=' + encodeURIComponent(status); - fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } }) - .then(r => r.json()) + fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' } }) + .then(r => { + if (r.status === 401 || r.status === 403) { + clearTimeout(autoTimer); + throw new Error('Session unauthenticated. Polling stopped.'); + } + return r.json(); + }) .then(renderQueue) .catch(err => { - document.getElementById('abdmQueueBody').innerHTML = - `Failed to load: ${esc(err.message)}`; + const b = document.getElementById('abdmQueueBody'); + if (b) { + b.innerHTML = + `Failed to load: ${esc(err.message)}`; + } }); resetBar(); } @@ -831,6 +847,10 @@ function resetBar() { const bar = document.getElementById('abdmQRefreshBar'); + if (!bar || !document.getElementById('abdmQueueBody')) { + clearTimeout(autoTimer); + return; + } bar.style.transition = 'none'; bar.style.width = '100%'; void bar.offsetWidth; diff --git a/tests/unit/AuthRedirectTest.php b/tests/unit/AuthRedirectTest.php new file mode 100644 index 0000000..e62070d --- /dev/null +++ b/tests/unit/AuthRedirectTest.php @@ -0,0 +1,91 @@ +authConfig = config('Auth'); + } + + protected function tearDown(): void + { + session()->removeTempdata('beforeLoginUrl'); + parent::tearDown(); + } + + public function testDefaultLoginRedirectWhenNoBeforeUrl(): void + { + session()->removeTempdata('beforeLoginUrl'); + $redirectUrl = $this->authConfig->loginRedirect(); + + // Default redirect should point to '/' or home/dashboard + $this->assertNotEmpty($redirectUrl); + $this->assertStringNotContainsString('AbdmOpdQueue', $redirectUrl); + } + + public function testAbdmOpdQueueFetchIsRejected(): void + { + $pollUrl = 'https://demo.e-atria.net/AbdmOpdQueue/fetch?date=2026-10-28&status='; + session()->setTempdata('beforeLoginUrl', $pollUrl, 300); + + $redirectUrl = $this->authConfig->loginRedirect(); + + $this->assertStringNotContainsString('AbdmOpdQueue/fetch', $redirectUrl); + $this->assertNull(session()->getTempdata('beforeLoginUrl'), 'beforeLoginUrl must be consumed and cleared'); + } + + public function testAbdmGatewayEndpointsAreRejected(): void + { + $urls = [ + 'https://demo.e-atria.net/AbdmGateway/health_records_list', + 'https://demo.e-atria.net/AbdmGateway/opd_running_token_status', + 'https://demo.e-atria.net/AbdmHiu/status', + 'https://demo.e-atria.net/AbdmSync/summary', + 'https://demo.e-atria.net/api/v1/opd/queue', + 'https://demo.e-atria.net/auth/session-status', + 'https://demo.e-atria.net/captcha', + 'https://demo.e-atria.net/doctor_work/document_workspace', + 'https://demo.e-atria.net/opd/appointment', + ]; + + foreach ($urls as $url) { + session()->setTempdata('beforeLoginUrl', $url, 300); + $redirectUrl = $this->authConfig->loginRedirect(); + + $path = parse_url($url, PHP_URL_PATH); + $this->assertStringNotContainsString( + $path, + $redirectUrl, + "URL {$url} should have been rejected by loginRedirect()" + ); + } + } + + public function testLegitimatePageIsPreserved(): void + { + $legitUrl = base_url('billing/patient'); + session()->setTempdata('beforeLoginUrl', $legitUrl, 300); + + $redirectUrl = $this->authConfig->loginRedirect(); + + $this->assertStringContainsString('billing/patient', $redirectUrl); + $this->assertNull(session()->getTempdata('beforeLoginUrl'), 'beforeLoginUrl must be consumed after redirect'); + } +}