feat(security): restrict /app/queue and live OPD token feed to authorized hospital IPs (WASA CWE-200 remediation)

This commit is contained in:
DevSoftTech Server 2026-10-22 15:19:28 +05:30
parent 7547d00bb4
commit 0020d0676c
4 changed files with 372 additions and 1 deletions

View File

@ -1467,6 +1467,7 @@ $routes->group('setting', static function($routes) {
$routes->post('admin/opd-queue-tv/save', 'Setting\\OpdQueueSetting::save'); $routes->post('admin/opd-queue-tv/save', 'Setting\\OpdQueueSetting::save');
$routes->post('admin/opd-queue-tv/upload-banner', 'Setting\\OpdQueueSetting::uploadBanner'); $routes->post('admin/opd-queue-tv/upload-banner', 'Setting\\OpdQueueSetting::uploadBanner');
$routes->post('admin/opd-queue-tv/delete-banner', 'Setting\\OpdQueueSetting::deleteBanner'); $routes->post('admin/opd-queue-tv/delete-banner', 'Setting\\OpdQueueSetting::deleteBanner');
$routes->post('admin/opd-queue-tv/save-ip-security', 'Setting\\OpdQueueSetting::saveIpSecurity');
$routes->get('admin/medical-store', 'Setting\\MedicalStoreAdmin::index'); $routes->get('admin/medical-store', 'Setting\\MedicalStoreAdmin::index');
$routes->post('admin/medical-store/save', 'Setting\\MedicalStoreAdmin::save'); $routes->post('admin/medical-store/save', 'Setting\\MedicalStoreAdmin::save');
$routes->post('admin/medical-store/delete', 'Setting\\MedicalStoreAdmin::delete'); $routes->post('admin/medical-store/delete', 'Setting\\MedicalStoreAdmin::delete');

View File

@ -872,6 +872,12 @@ class DoctorApi extends BaseController
*/ */
public function queueIndex() public function queueIndex()
{ {
if (! $this->isQueueAccessAllowed()) {
return $this->response
->setStatusCode(403)
->setBody($this->renderQueueAccessDeniedHtml());
}
$pwaPath = FCPATH . 'App/Queue/index.html'; $pwaPath = FCPATH . 'App/Queue/index.html';
if (file_exists($pwaPath)) { if (file_exists($pwaPath)) {
return $this->response->setBody(file_get_contents($pwaPath)); return $this->response->setBody(file_get_contents($pwaPath));
@ -907,12 +913,246 @@ class DoctorApi extends BaseController
return $this->response->setStatusCode(404); return $this->response->setStatusCode(404);
} }
/**
* Verifies if the incoming request is authorized to access the OPD TV Queue Display / API.
* Access is allowed if:
* 1. User has an active authenticated HMS session (CodeIgniter Shield / user_id)
* 2. TV_QUEUE_IP_RESTRICTION_ENABLED setting is set to '0' (disabled)
* 3. Request origin matches allowed IPs/subnets (TV_QUEUE_ALLOWED_IPS)
*/
private function isQueueAccessAllowed(): bool
{
// 1. Logged in staff / administrator can always view
if ((function_exists('auth') && auth()->loggedIn()) || (session()->has('user_id') && (int) session()->get('user_id') > 0)) {
return true;
}
$db = db_connect();
$enabledRow = $db->table('hospital_setting')
->select('s_value')
->where('s_name', 'TV_QUEUE_IP_RESTRICTION_ENABLED')
->get(1)
->getRowArray();
$restrictionEnabled = ($enabledRow['s_value'] ?? '1') === '1';
if (! $restrictionEnabled) {
return true;
}
// 2. Resolve client IP
$clientIp = trim((string) $this->request->getIPAddress());
if ($clientIp === '' || $clientIp === '0.0.0.0') {
$clientIp = trim((string) ($_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'));
}
// 3. Fetch allowed IP rules
$allowedRow = $db->table('hospital_setting')
->select('s_value')
->where('s_name', 'TV_QUEUE_ALLOWED_IPS')
->get(1)
->getRowArray();
$defaultAllowed = "127.0.0.1\n::1\n10.*\n192.168.*\n172.16.*\n103.251.140.182";
$allowedIpsRaw = !empty($allowedRow['s_value']) ? (string) $allowedRow['s_value'] : $defaultAllowed;
$rules = preg_split('/[\r\n,]+/', $allowedIpsRaw, -1, PREG_SPLIT_NO_EMPTY);
foreach ($rules as $rule) {
$rule = trim($rule);
if ($rule === '') continue;
if ($this->matchIpRule($clientIp, $rule)) {
return true;
}
}
return false;
}
/**
* Checks if a client IP matches an individual IP rule (exact, wildcard, or CIDR).
*/
private function matchIpRule(string $clientIp, string $rule): bool
{
if ($clientIp === $rule) {
return true;
}
if ($rule === 'localhost' && in_array($clientIp, ['127.0.0.1', '::1'], true)) {
return true;
}
if (in_array(strtolower($rule), ['intranet', 'private', 'lan'], true)) {
return $this->isHospitalIntranet($clientIp);
}
// Check wildcard (e.g. 192.168.*, 10.*, 103.251.*)
if (strpos($rule, '*') !== false) {
if (fnmatch($rule, $clientIp)) {
return true;
}
}
// Check CIDR (e.g. 192.168.0.0/16 or 10.0.0.0/8)
if (strpos($rule, '/') !== false) {
[$subnet, $bits] = explode('/', $rule, 2);
$bits = (int) $bits;
$ipLong = ip2long($clientIp);
$subnetLong = ip2long(trim($subnet));
if ($ipLong !== false && $subnetLong !== false && $bits >= 0 && $bits <= 32) {
$mask = $bits === 0 ? 0 : (~0 << (32 - $bits));
if (($ipLong & $mask) === ($subnetLong & $mask)) {
return true;
}
}
}
return false;
}
/**
* Renders a branded 403 Forbidden screen when /app/queue is accessed externally.
*/
private function renderQueueAccessDeniedHtml(): string
{
$clientIp = esc((string) $this->request->getIPAddress());
$loginUrl = base_url('login');
return <<<HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>403 Forbidden • OPD Queue TV Display</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<style>
body {
background: linear-gradient(135deg, #0b132b 0%, #1c2541 50%, #1e293b 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: #f8fafc;
padding: 1.5rem;
}
.security-card {
max-width: 580px;
width: 100%;
background: rgba(15, 23, 42, 0.85);
backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 1.25rem;
padding: 2.5rem 2rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
text-align: center;
}
.badge-terminal {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: rgba(239, 68, 68, 0.15);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 9999px;
padding: 0.35rem 1rem;
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 1.5rem;
}
.icon-circle {
width: 72px;
height: 72px;
border-radius: 50%;
background: rgba(239, 68, 68, 0.15);
border: 2px solid rgba(239, 68, 68, 0.4);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 1.5rem;
color: #ef4444;
font-size: 2rem;
}
.ip-box {
background: rgba(0, 0, 0, 0.4);
border: 1px dashed rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
font-family: monospace;
font-size: 0.95rem;
color: #cbd5e1;
margin: 1.5rem 0;
display: inline-block;
}
.btn-login {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
border: none;
color: white;
font-weight: 600;
padding: 0.7rem 1.75rem;
border-radius: 0.5rem;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
transition: transform 0.15s ease;
}
.btn-login:hover {
transform: translateY(-2px);
color: white;
}
</style>
</head>
<body>
<div class="security-card">
<div class="badge-terminal">
<i class="bi bi-shield-lock-fill"></i> Restricted Hospital Terminal (CWE-200)
</div>
<div class="icon-circle">
<i class="bi bi-display"></i>
</div>
<h3 class="fw-bold mb-2">OPD TV Display Access Restricted</h3>
<p class="text-secondary small mb-3">
This live queue &amp; token tracker display is restricted to authorized hospital waiting rooms, local intranet (LAN), and secure VPN terminals.
</p>
<div class="ip-box">
Client IP: <span class="text-warning">{$clientIp}</span> &bull; Status: <span class="text-danger fw-bold">Unauthorized</span>
</div>
<p class="text-muted small" style="font-size: 13px;">
If you are hospital staff setting up a waiting room display, please add this IP to the whitelist in <strong>Admin Panel &gt; OPD Queue TV Settings</strong> or log in using your staff credentials.
</p>
<div class="d-flex justify-content-center gap-2 mt-4">
<a href="{$loginUrl}" class="btn-login">
<i class="bi bi-box-arrow-in-right"></i> Staff HMS Login
</a>
</div>
</div>
</body>
</html>
HTML;
}
/** /**
* GET api/v1/opd/queue/live * GET api/v1/opd/queue/live
* Returns real-time OPD Tokens & Hospital Showcase Ads for TV Queue Display * Returns real-time OPD Tokens & Hospital Showcase Ads for TV Queue Display
*/ */
public function liveQueueDisplay() public function liveQueueDisplay()
{ {
if (! $this->isQueueAccessAllowed()) {
return $this->response
->setStatusCode(403)
->setJSON([
'status' => 0,
'error' => 'Forbidden',
'message' => 'Access Restricted: OPD Live Queue feed is only accessible from authorized hospital terminals and networks (WASA CWE-200 Remediation).',
'client_ip' => (string) $this->request->getIPAddress(),
]);
}
$db = db_connect(); $db = db_connect();
$todayStart = date('Y-m-d') . ' 00:00:00'; $todayStart = date('Y-m-d') . ' 00:00:00';
$tomorrowStart = date('Y-m-d', strtotime('+1 day')) . ' 00:00:00'; $tomorrowStart = date('Y-m-d', strtotime('+1 day')) . ' 00:00:00';

View File

@ -28,12 +28,20 @@ class OpdQueueSetting extends BaseController
$customAds = json_decode($adsJson, true) ?: []; $customAds = json_decode($adsJson, true) ?: [];
} }
$ipRestrictionEnabled = $this->getSetting('TV_QUEUE_IP_RESTRICTION_ENABLED', '1');
$defaultAllowedIps = "127.0.0.1\n::1\n10.*\n192.168.*\n172.16.*\n103.251.140.182";
$allowedIps = $this->getSetting('TV_QUEUE_ALLOWED_IPS', $defaultAllowedIps);
$currentClientIp = (string) $this->request->getIPAddress();
return view('Setting/Admin/opd_queue_setting', [ return view('Setting/Admin/opd_queue_setting', [
'footer_ticker' => $tickerText, 'footer_ticker' => $tickerText,
'left_ad_text' => $leftAdText, 'left_ad_text' => $leftAdText,
'left_ad_image' => $leftAdImage, 'left_ad_image' => $leftAdImage,
'left_ad_enabled' => $leftAdEnabled, 'left_ad_enabled' => $leftAdEnabled,
'custom_ads' => $customAds 'custom_ads' => $customAds,
'ip_restriction_enabled' => $ipRestrictionEnabled,
'allowed_ips' => $allowedIps,
'current_client_ip' => $currentClientIp,
]); ]);
} }
@ -148,6 +156,21 @@ class OpdQueueSetting extends BaseController
]); ]);
} }
public function saveIpSecurity()
{
$post = $this->request->getPost();
$enabled = isset($post['ip_restriction_enabled']) && $post['ip_restriction_enabled'] === '1' ? '1' : '0';
$allowedIps = trim((string) ($post['allowed_ips'] ?? ''));
$this->setSetting('TV_QUEUE_IP_RESTRICTION_ENABLED', $enabled);
$this->setSetting('TV_QUEUE_ALLOWED_IPS', $allowedIps);
return $this->response->setJSON([
'status' => 1,
'message' => 'OPD Queue TV IP restriction settings updated successfully!'
]);
}
private function getSetting(string $name, string $default = ''): string private function getSetting(string $name, string $default = ''): string
{ {
$row = $this->db->table('hospital_setting') $row = $this->db->table('hospital_setting')

View File

@ -57,6 +57,64 @@
</form> </form>
</div> </div>
</div> </div>
<!-- TV Display Network & IP Security (WASA Compliance) -->
<div class="card shadow-sm border-0 mb-3">
<div class="card-header bg-dark text-white fw-bold d-flex justify-content-between align-items-center">
<span><i class="bi bi-shield-lock-fill me-2 text-warning"></i> TV Display Network &amp; IP Security</span>
<span class="badge bg-warning text-dark"><i class="bi bi-shield-check me-1"></i>WASA Security</span>
</div>
<div class="card-body mt-3">
<form id="form_ip_security_settings" action="javascript:void(0)" onsubmit="saveIpSecuritySettings(event)">
<div class="alert alert-light border d-flex align-items-start gap-2 py-2 mb-3">
<i class="bi bi-info-circle-fill text-primary mt-1 fs-5"></i>
<div class="small text-muted">
<strong>Protection for <code>/app/queue</code> &amp; live token feeds:</strong> Only whitelisted hospital local IPs (LAN), VPN addresses, or authorized static IPs can open the TV Display. All unauthorized public internet requests receive <strong>HTTP 403 Forbidden</strong> with zero doctor/queue data exposed.
</div>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" name="ip_restriction_enabled" id="ip_restriction_enabled" value="1" <?= $ip_restriction_enabled === '1' ? 'checked' : '' ?>>
<label class="form-check-label fw-bold" for="ip_restriction_enabled">
Enforce IP / VPN Restriction for TV Display
</label>
<small class="d-block text-muted">When enabled, only allowed IPs or logged-in staff can access <code>/app/queue</code> and live OPD data.</small>
</div>
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<label class="form-label fw-bold mb-0">Allowed IP Addresses &amp; Subnets</label>
<button type="button" class="btn btn-outline-primary btn-sm py-0 px-2" style="font-size: 12px;" onclick="addCurrentClientIp()">
<i class="bi bi-plus-circle me-1"></i> Add My IP (<?= esc($current_client_ip) ?>)
</button>
</div>
<textarea class="form-control font-monospace" name="allowed_ips" id="allowed_ips_textarea" rows="4" placeholder="127.0.0.1&#10;192.168.*&#10;10.*&#10;103.251.140.182" style="font-size: 13px;"><?= esc($allowed_ips) ?></textarea>
<div class="form-text small mt-1">
Enter one IP or subnet per line or separated by commas.<br>
• Exact: <code>103.251.140.182</code>, <code>127.0.0.1</code><br>
• Wildcards: <code>192.168.*</code>, <code>10.*</code>, <code>172.16.*</code><br>
• Subnet CIDR: <code>10.8.0.0/24</code>, <code>192.168.1.0/24</code>
</div>
</div>
<div class="d-flex flex-wrap gap-2 mb-3">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="insertIpRule('192.168.*')">
+ Hospital LAN (192.168.*)
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="insertIpRule('10.*')">
+ Hospital VPN / Intranet (10.*)
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="insertIpRule('127.0.0.1\n::1')">
+ Localhost Only
</button>
</div>
<button type="button" onclick="saveIpSecuritySettings(event)" class="btn btn-dark fw-bold px-4">
<i class="bi bi-shield-check text-warning me-1"></i> Save IP Security Settings
</button>
</form>
</div>
</div>
</div> </div>
<!-- Right Side: Showcase & Ad Banners Management --> <!-- Right Side: Showcase & Ad Banners Management -->
@ -232,4 +290,53 @@
} }
}); });
} }
var myCurrentIp = '<?= esc($current_client_ip) ?>';
function addCurrentClientIp() {
if (!myCurrentIp) return;
insertIpRule(myCurrentIp);
}
function insertIpRule(rule) {
var el = document.getElementById('allowed_ips_textarea');
if (!el) return;
var cur = el.value.trim();
var rules = rule.split('\n');
rules.forEach(function(r) {
r = r.trim();
if (r !== '' && cur.indexOf(r) === -1) {
cur = cur === '' ? r : cur + '\n' + r;
}
});
el.value = cur;
}
function saveIpSecuritySettings(e) {
if (e && e.preventDefault) e.preventDefault();
var form = document.getElementById('form_ip_security_settings');
if (!form) return false;
var formData = new FormData(form);
$.ajax({
url: '<?= base_url('setting/admin/opd-queue-tv/save-ip-security') ?>',
type: 'POST',
data: formData,
contentType: false,
processData: false,
dataType: 'json',
success: function(res) {
if (res.status === 1) {
alert(res.message);
refreshAdminSubPage();
} else {
alert(res.message || 'Error saving IP security settings');
}
},
error: function() {
alert('Error saving IP security settings');
}
});
return false;
}
</script> </script>