From 407dbb72000bcd67f9e42bb1897e8b3180a82636 Mon Sep 17 00:00:00 2001 From: chenchen Date: Thu, 21 May 2026 19:45:53 +0800 Subject: [PATCH] fix(http): trust private-range proxies so c.ClientIP returns real IP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manager was created via gin.New() without calling SetTrustedProxies, which in Gin v1.7+ defaults to trusting NOTHING — c.ClientIP() returned the docker bridge peer (e.g. 10.2.3.4) instead of the real client IP populated in X-Forwarded-For by the front reverse proxy. Symptoms observed in production: - Devices page showed every user's "Last IP" as 10.2.3.4 / 10.2.3.5 - tokens.device_last_seen_ip audit field useless for security review - Token IP allowlists effectively bypassed (always saw docker IP) - Rate-limit buckets keyed on docker IP — all users share a bucket Fix: SetTrustedProxies with the standard RFC1918 + loopback ranges. Covers every realistic Manager topology (docker compose, k8s ClusterIP, reverse proxy on same VM). Cloudflare-direct topologies still need the CF published ranges added; document that inline rather than auto-fetch since we currently always front with Caddy/nginx. UI cosmetic: When device_name is empty (pre-0.3.3 desktop clients didn't always send it), Devices page now synthesises a label like "Windows · 4f3a" from platform + last 4 chars of device_id instead of the generic "Unnamed device", so users can tell their devices apart at a glance. Co-Authored-By: Claude Opus 4.7 (1M context) --- heicode/main.go | 29 +++++++++++++++++++ .../default/src/features/devices/index.tsx | 23 +++++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/heicode/main.go b/heicode/main.go index a5f4ee0..aa82f42 100644 --- a/heicode/main.go +++ b/heicode/main.go @@ -159,6 +159,35 @@ func main() { // Initialize HTTP server server := gin.New() + + // Configure trusted proxy CIDRs so `c.ClientIP()` honours + // X-Forwarded-For / X-Real-IP set by the reverse proxy (Caddy / + // nginx / Cloudflare) that sits in front of the Manager. From + // Gin v1.7 the default is to NOT trust any header, which makes + // `c.ClientIP()` return the docker bridge peer (e.g. 10.2.3.4) + // — useless for audit trail, token IP allowlists, and rate + // limiting. Trusting the standard RFC1918 private ranges + + // loopback covers every realistic Manager deployment topology: + // - docker compose (host-network or bridge) + // - k8s ClusterIP service mesh + // - reverse proxy on same VM + // Public-IP proxies (e.g. Cloudflare edge IPs) are NOT in this + // list. If you front Manager directly with Cloudflare without + // a local reverse proxy, add the published CF ranges here. + // SetTrustedProxies returns an error only when the strings are + // not valid CIDRs — we panic because that's a config bug, not + // a runtime condition. + if err := server.SetTrustedProxies([]string{ + "10.0.0.0/8", // RFC1918 + Azure VNet + docker bridge + "172.16.0.0/12", // docker bridge default + "192.168.0.0/16", // RFC1918 LAN + "127.0.0.1/32", // loopback IPv4 + "::1/128", // loopback IPv6 + "fd00::/8", // RFC4193 unique-local IPv6 + }); err != nil { + common.FatalLog(fmt.Sprintf("SetTrustedProxies: %v", err)) + } + server.Use(gin.CustomRecovery(func(c *gin.Context, err any) { common.SysLog(fmt.Sprintf("panic detected: %v", err)) c.JSON(http.StatusInternalServerError, gin.H{ diff --git a/heicode/web/default/src/features/devices/index.tsx b/heicode/web/default/src/features/devices/index.tsx index 0a0c9dd..ec80b49 100644 --- a/heicode/web/default/src/features/devices/index.tsx +++ b/heicode/web/default/src/features/devices/index.tsx @@ -64,6 +64,25 @@ function platformLabel(p: string): string { } } +// Render the friendly device name. The plain "Unnamed device" string +// is shown when the row genuinely has no info to display, but the more +// common case in production is that older clients (<= 0.3.2) didn't +// send device_name and we stored '' — for those rows we synthesise a +// label like "Windows · 4f3a" using platform + last 4 chars of the +// UUID so the user can at least tell their devices apart at a glance. +function deviceDisplayName(d: Device, t: (k: string) => string): string { + const trimmed = (d.device_name || '').trim() + if (trimmed) return trimmed + const platform = platformLabel(d.device_platform) + const idTail = (d.device_id || '').slice(-4) + if (platform !== '—' && idTail) { + return `${platform} · ${idTail}` + } + if (platform !== '—') return platform + if (idTail) return `device · ${idTail}` + return t('Unnamed device') +} + export function Devices() { const { t } = useTranslation() const [devices, setDevices] = useState([]) @@ -139,7 +158,7 @@ export function Devices() { return ( - {d.device_name || t('Unnamed device')} + {deviceDisplayName(d, t)} {platformLabel(d.device_platform)} {d.device_app_version || '—'} @@ -241,7 +260,7 @@ function RevokeDialog({ 'After revoke, the desktop app on this device will be signed out on its next request. You can re-pair by signing in again.', )}{' '} - {device?.device_name || t('Unnamed device')} + {device ? deviceDisplayName(device, t) : t('Unnamed device')}