diff --git a/server/2015Remote/SimpleWebSocket.h b/server/2015Remote/SimpleWebSocket.h index fe02cc5..3b402d7 100644 --- a/server/2015Remote/SimpleWebSocket.h +++ b/server/2015Remote/SimpleWebSocket.h @@ -349,6 +349,75 @@ public: void onHttp(HttpHandler handler) { m_httpHandler = handler; } 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:" 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 bool parseProxyProtocolV2(SOCKET sock, std::string& clientIP) { // PP2 signature (12 bytes) @@ -448,6 +517,13 @@ private: 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 if (request.find("Upgrade: websocket") == std::string::npos) { // Regular HTTP request - extract path diff --git a/server/web/index.html b/server/web/index.html index 11891ec..4e7fbb8 100644 --- a/server/web/index.html +++ b/server/web/index.html @@ -391,6 +391,10 @@ padding: 16px; 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 { display: flex; flex-direction: column; @@ -409,8 +413,37 @@ font-size: 13px; outline: none; min-width: 0; + width: 100%; + max-width: 100%; + box-sizing: border-box; } .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 { flex-direction: row; align-items: center; @@ -430,6 +463,22 @@ .audit-search-btn:hover { transform: translateY(-1px); } .audit-reset-btn { background: rgba(255,255,255,0.08); color: #ccc; } .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.warn { color: #e67e22; } .audit-table-wrap { @@ -470,8 +519,11 @@ .audit-level.warn { background: rgba(230, 126, 34, 0.18); color: #e67e22; } .audit-empty { padding: 40px; text-align: center; color: #666; } @media (max-width: 640px) { - .audit-filters { grid-template-columns: 1fr 1fr; } - .audit-actions { grid-column: 1 / -1; } + /* One control per row: two columns plus the intrinsic width of + 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; } } @@ -1787,15 +1839,24 @@

Audit Log 0

+
-
+