fix(http): trust private-range proxies so c.ClientIP returns real IP

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 19:45:53 +08:00
co-authored by Claude Opus 4.7
parent b615f3dc67
commit 407dbb7200
2 changed files with 50 additions and 2 deletions
+29
View File
@@ -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{
+21 -2
View File
@@ -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<Device[]>([])
@@ -139,7 +158,7 @@ export function Devices() {
return (
<TableRow key={d.id} className={revoked ? 'opacity-60' : ''}>
<TableCell className='font-medium'>
{d.device_name || t('Unnamed device')}
{deviceDisplayName(d, t)}
</TableCell>
<TableCell>{platformLabel(d.device_platform)}</TableCell>
<TableCell>{d.device_app_version || '—'}</TableCell>
@@ -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.',
)}{' '}
<span className='font-semibold'>
{device?.device_name || t('Unnamed device')}
{device ? deviceDisplayName(device, t) : t('Unnamed device')}
</span>
</AlertDialogDescription>
</AlertDialogHeader>