fix(auth): prevent background AJAX/polling routes from hijacking post-login redirect
This commit is contained in:
parent
6c1c19153c
commit
565586aa6e
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
91
tests/unit/AuthRedirectTest.php
Normal file
91
tests/unit/AuthRedirectTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user