Fix: Recover proxied client IPs and fit audit filters on phones
The MFC web server now resolves the real client address from the WebSocket upgrade request when the TCP peer is loopback, so audit records and login rate limiting stop reporting the reverse proxy's 127.0.0.1. X-Forwarded-For is preferred over X-Real-IP because its last hop is the address the local proxy appended; a client-supplied header list cannot forge it. Forwarded headers are ignored for non-loopback peers so a client connecting directly over the internet cannot spoof its identity or rotate addresses to bypass throttling. FRP deployments can keep using the existing Proxy Protocol v2 support instead. Collapse the audit log search conditions behind a toolbar toggle that starts closed and switches to an accent-colored "Hide filters" state while the panel is open. On portrait phones the panel uses one control per row, and From/To move their border and padding onto a wrapper so the iOS WebKit datetime-local width miscalculation cannot overflow the viewport. Co-Authored-By: deepseek-flash
This commit is contained in:
1 parent
ca671d6453
commit
bbb3d8c2fa
2 files changed
+156
-5
No files matched your search
@@ -349,6 +349,75 @@ public:
|
|||||||
void onHttp(HttpHandler handler) { m_httpHandler = handler; }
|
void onHttp(HttpHandler handler) { m_httpHandler = handler; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
// ---- Real client IP behind a reverse proxy ----
|
||||||
|
// A same-host nginx/Caddy connects from 127.0.0.1 and reports the original
|
||||||
|
// peer in X-Real-IP / X-Forwarded-For. Only trust those headers when the
|
||||||
|
// TCP peer is loopback: otherwise any internet client could forge its
|
||||||
|
// address in the audit log and rotate it to evade login throttling.
|
||||||
|
static std::string trimHttpValue(const std::string& s) {
|
||||||
|
size_t b = s.find_first_not_of(" \t");
|
||||||
|
if (b == std::string::npos) return "";
|
||||||
|
size_t e = s.find_last_not_of(" \t\r\n");
|
||||||
|
return s.substr(b, e - b + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::string asciiLower(const std::string& s) {
|
||||||
|
std::string out = s;
|
||||||
|
for (char& c : out) {
|
||||||
|
if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case-insensitive lookup of one header in the raw request. The request
|
||||||
|
// line is first, so matching "\r\n<name>:" cannot hit the request target.
|
||||||
|
static std::string httpHeader(const std::string& request, const char* name) {
|
||||||
|
const std::string needle = "\r\n" + asciiLower(name) + ":";
|
||||||
|
const std::string hay = asciiLower(request);
|
||||||
|
size_t pos = hay.find(needle);
|
||||||
|
if (pos == std::string::npos) return "";
|
||||||
|
size_t vstart = pos + needle.size();
|
||||||
|
size_t vend = hay.find("\r\n", vstart);
|
||||||
|
return trimHttpValue(request.substr(vstart,
|
||||||
|
vend == std::string::npos ? std::string::npos : vend - vstart));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept only a bare IPv4/IPv6 literal; forwarded headers are attacker
|
||||||
|
// controlled even when the proxy is trusted, so no hostnames/ports.
|
||||||
|
static std::string normalizeIPLiteral(const std::string& value) {
|
||||||
|
std::string s = trimHttpValue(value);
|
||||||
|
if (s.empty() || s.size() > 63) return "";
|
||||||
|
struct in_addr v4;
|
||||||
|
if (inet_pton(AF_INET, s.c_str(), &v4) == 1) return s;
|
||||||
|
struct in6_addr v6;
|
||||||
|
if (inet_pton(AF_INET6, s.c_str(), &v6) == 1) return s;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::string forwardedClientIP(const std::string& request) {
|
||||||
|
// X-Forwarded-For is "client, proxy1, ..." with the oldest hop first.
|
||||||
|
// Our own proxy appends the peer it saw, so the last valid entry is
|
||||||
|
// the one it actually observed: a client-supplied list cannot forge
|
||||||
|
// it. Prefer this over X-Real-IP, which is only trustworthy when the
|
||||||
|
// proxy explicitly overwrites it (some configs pass it through).
|
||||||
|
std::string xff = httpHeader(request, "x-forwarded-for");
|
||||||
|
size_t end = xff.size();
|
||||||
|
while (end > 0) {
|
||||||
|
size_t comma = xff.rfind(',', end - 1);
|
||||||
|
size_t start = comma == std::string::npos ? 0 : comma + 1;
|
||||||
|
std::string ip = normalizeIPLiteral(xff.substr(start, end - start));
|
||||||
|
if (!ip.empty()) return ip;
|
||||||
|
if (comma == std::string::npos) break;
|
||||||
|
end = comma;
|
||||||
|
}
|
||||||
|
return normalizeIPLiteral(httpHeader(request, "x-real-ip"));
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool isLoopbackPeer(const std::string& ip) {
|
||||||
|
return ip.rfind("127.", 0) == 0 || ip == "::1" ||
|
||||||
|
ip == "::ffff:127.0.0.1";
|
||||||
|
}
|
||||||
|
|
||||||
// Parse Proxy Protocol v2 header and extract real client IP
|
// Parse Proxy Protocol v2 header and extract real client IP
|
||||||
bool parseProxyProtocolV2(SOCKET sock, std::string& clientIP) {
|
bool parseProxyProtocolV2(SOCKET sock, std::string& clientIP) {
|
||||||
// PP2 signature (12 bytes)
|
// PP2 signature (12 bytes)
|
||||||
@@ -448,6 +517,13 @@ private:
|
|||||||
if (request.find("\r\n\r\n") != std::string::npos) break;
|
if (request.find("\r\n\r\n") != std::string::npos) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Behind a same-host reverse proxy the TCP peer is 127.0.0.1; recover
|
||||||
|
// the real client address the proxy recorded in the handshake.
|
||||||
|
if (isLoopbackPeer(clientIP)) {
|
||||||
|
std::string forwarded = forwardedClientIP(request);
|
||||||
|
if (!forwarded.empty()) clientIP = forwarded;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if WebSocket upgrade
|
// Check if WebSocket upgrade
|
||||||
if (request.find("Upgrade: websocket") == std::string::npos) {
|
if (request.find("Upgrade: websocket") == std::string::npos) {
|
||||||
// Regular HTTP request - extract path
|
// Regular HTTP request - extract path
|
||||||
|
|||||||
+80
-5
@@ -391,6 +391,10 @@
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
|
.audit-filters.collapsed { display: none; }
|
||||||
|
/* Filter labels are grid items; without this a wide intrinsic child
|
||||||
|
(notably datetime-local on mobile) can blow out the grid track. */
|
||||||
|
.audit-filters > * { min-width: 0; }
|
||||||
.audit-filters label {
|
.audit-filters label {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -409,8 +413,37 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
outline: none;
|
outline: none;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.audit-filters input:focus, .audit-filters select:focus { border-color: #e67e22; }
|
.audit-filters input:focus, .audit-filters select:focus { border-color: #e67e22; }
|
||||||
|
/* iOS Safari mis-sizes temporal controls: with horizontal padding on
|
||||||
|
the input itself, width:100% adds the padding again (WebKit #301648),
|
||||||
|
and iOS 17 ignores width entirely on the native inline-flex control.
|
||||||
|
Move the visual box to a wrapper and keep the input padding-free so
|
||||||
|
it can never exceed its parent. */
|
||||||
|
.audit-date-wrap {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(15, 52, 96, 0.7);
|
||||||
|
}
|
||||||
|
.audit-date-wrap:focus-within { border-color: #e67e22; }
|
||||||
|
.audit-date-wrap input[type="datetime-local"] {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
.audit-filters label.audit-warn {
|
.audit-filters label.audit-warn {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -430,6 +463,22 @@
|
|||||||
.audit-search-btn:hover { transform: translateY(-1px); }
|
.audit-search-btn:hover { transform: translateY(-1px); }
|
||||||
.audit-reset-btn { background: rgba(255,255,255,0.08); color: #ccc; }
|
.audit-reset-btn { background: rgba(255,255,255,0.08); color: #ccc; }
|
||||||
.audit-reset-btn:hover { background: rgba(255,255,255,0.15); }
|
.audit-reset-btn:hover { background: rgba(255,255,255,0.15); }
|
||||||
|
/* Filters visibility toggle: collapsed by default; the same button
|
||||||
|
stays in the toolbar and flips to an "open" style (accent tint +
|
||||||
|
caret pointing up) so it reads as "click to collapse". */
|
||||||
|
.view-btn.audit-filter-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
border: 1px solid rgba(230, 126, 34, 0.45);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #e67e22;
|
||||||
|
background: rgba(230, 126, 34, 0.12);
|
||||||
|
}
|
||||||
|
.view-btn.audit-filter-toggle:hover { background: rgba(230, 126, 34, 0.22); color: #fff; }
|
||||||
|
.view-btn.audit-filter-toggle.open { background: rgba(230, 126, 34, 0.30); color: #fff; }
|
||||||
|
.audit-filter-toggle .caret { display: inline-block; font-size: 10px; transition: transform 0.2s; }
|
||||||
|
.audit-filter-toggle.open .caret { transform: rotate(180deg); }
|
||||||
.audit-status { color: #888; font-size: 12px; margin-bottom: 10px; min-height: 16px; }
|
.audit-status { color: #888; font-size: 12px; margin-bottom: 10px; min-height: 16px; }
|
||||||
.audit-status.warn { color: #e67e22; }
|
.audit-status.warn { color: #e67e22; }
|
||||||
.audit-table-wrap {
|
.audit-table-wrap {
|
||||||
@@ -470,8 +519,11 @@
|
|||||||
.audit-level.warn { background: rgba(230, 126, 34, 0.18); color: #e67e22; }
|
.audit-level.warn { background: rgba(230, 126, 34, 0.18); color: #e67e22; }
|
||||||
.audit-empty { padding: 40px; text-align: center; color: #666; }
|
.audit-empty { padding: 40px; text-align: center; color: #666; }
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.audit-filters { grid-template-columns: 1fr 1fr; }
|
/* One control per row: two columns plus the intrinsic width of
|
||||||
.audit-actions { grid-column: 1 / -1; }
|
datetime-local inputs overflows a portrait phone viewport. */
|
||||||
|
.audit-filters { grid-template-columns: minmax(0, 1fr); padding: 14px; }
|
||||||
|
.audit-actions { grid-column: 1 / -1; flex-wrap: wrap; }
|
||||||
|
.audit-actions button { flex: 1 1 auto; }
|
||||||
.audit-table td, .audit-table th { padding: 8px; }
|
.audit-table td, .audit-table th { padding: 8px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1787,15 +1839,24 @@
|
|||||||
<h2>Audit Log <span id="audit-count" class="device-count">0</span></h2>
|
<h2>Audit Log <span id="audit-count" class="device-count">0</span></h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
|
<button class="view-btn audit-filter-toggle" id="audit-filter-toggle" type="button"
|
||||||
|
aria-expanded="false" aria-controls="audit-filters"
|
||||||
|
onclick="toggleAuditFilters()">
|
||||||
|
<span class="label">Filters</span><span class="caret">▾</span>
|
||||||
|
</button>
|
||||||
<button class="view-btn" onclick="queryAudit(0)" title="Reload newest records">Refresh</button>
|
<button class="view-btn" onclick="queryAudit(0)" title="Reload newest records">Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="audit-filters">
|
<div class="audit-filters collapsed" id="audit-filters">
|
||||||
<label>From
|
<label>From
|
||||||
<input type="datetime-local" id="audit-from" onkeydown="if(event.key==='Enter')queryAudit(0)">
|
<span class="audit-date-wrap">
|
||||||
|
<input type="datetime-local" id="audit-from" onkeydown="if(event.key==='Enter')queryAudit(0)">
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<label>To
|
<label>To
|
||||||
<input type="datetime-local" id="audit-to" onkeydown="if(event.key==='Enter')queryAudit(0)">
|
<span class="audit-date-wrap">
|
||||||
|
<input type="datetime-local" id="audit-to" onkeydown="if(event.key==='Enter')queryAudit(0)">
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<label>Event (exact or prefix)
|
<label>Event (exact or prefix)
|
||||||
<input type="text" id="audit-event" list="audit-event-options" placeholder="e.g. web.* or web.login.failure"
|
<input type="text" id="audit-event" list="audit-event-options" placeholder="e.g. web.* or web.login.failure"
|
||||||
@@ -4542,6 +4603,20 @@
|
|||||||
if (auditBtn) auditBtn.classList.toggle('visible', admin && !!serverCaps.auditLog);
|
if (auditBtn) auditBtn.classList.toggle('visible', admin && !!serverCaps.auditLog);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Search conditions start collapsed (especially important on portrait
|
||||||
|
// phones); the toolbar button stays put and switches to a highlighted
|
||||||
|
// "up" state while the panel is open, meaning clicking again folds it.
|
||||||
|
function toggleAuditFilters() {
|
||||||
|
const panel = document.getElementById('audit-filters');
|
||||||
|
const btn = document.getElementById('audit-filter-toggle');
|
||||||
|
if (!panel || !btn) return;
|
||||||
|
const collapsed = panel.classList.toggle('collapsed');
|
||||||
|
btn.classList.toggle('open', !collapsed);
|
||||||
|
btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||||||
|
const label = btn.querySelector('.label');
|
||||||
|
if (label) label.textContent = collapsed ? 'Filters' : 'Hide filters';
|
||||||
|
}
|
||||||
|
|
||||||
function openAuditPage() {
|
function openAuditPage() {
|
||||||
if (currentUserRole !== 'admin' || !serverCaps.auditLog) return;
|
if (currentUserRole !== 'admin' || !serverCaps.auditLog) return;
|
||||||
fillAuditEventOptions();
|
fillAuditEventOptions();
|
||||||
|
|||||||
Reference in new issue
Block a user