fix(auth): prevent background AJAX/polling routes from hijacking post-login redirect

This commit is contained in:
DevSoftTech Server 2026-10-28 00:40:32 +05:30
parent 6c1c19153c
commit 565586aa6e
4 changed files with 212 additions and 14 deletions

View File

@ -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);
}

View File

@ -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;

View File

@ -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 =
`<tr><td colspan="9" class="text-danger text-center py-3 small">Failed to load: ${esc(err.message)}</td></tr>`;
const b = document.getElementById('abdmQueueBody');
if (b) {
b.innerHTML =
`<tr><td colspan="9" class="text-danger text-center py-3 small">Failed to load: ${esc(err.message)}</td></tr>`;
}
});
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;

View File

@ -0,0 +1,91 @@
<?php
namespace Tests\Unit;
use CodeIgniter\Test\CIUnitTestCase;
use Config\Auth as AuthConfig;
/**
* AuthRedirectTest
*
* Validates post-login redirection sanitization in AuthConfig::loginRedirect().
* Prevents API, AJAX, polling, and background routes (e.g. AbdmOpdQueue/fetch)
* from hijacking post-login landing pages.
*
* @internal
*/
final class AuthRedirectTest extends CIUnitTestCase
{
protected AuthConfig $authConfig;
protected function setUp(): void
{
parent::setUp();
$this->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');
}
}