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:
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user