#6589·3x-ui

[Bug]: Subscription links advertise the client's public IP as the server address behind a reverse proxy (X-Real-IP used as host)

Author: sy3temCreated Sep 17, 2026Updated Sep 17, 2026

Describe the bug

The server field in subscription output (raw /sub, /json, /clash) is filled with the subscriber's own public IP instead of the panel's public host. The value changes depending on which client machine fetches the subscription, so imported profiles point at the wrong host and the VPN does not route correctly.

I reproduced this on the current main (v3.8.5 era, main HEAD 1c0ce80). The panel is deployed behind an openresty/nginx reverse proxy.

Example subscription URL: https://<your-domain>/clash/<subid> Decoded response:

proxies:
  - name: "..."
    type: vless
    server: <client-public-ip>   # <-- this is the PUBLIC IP OF THE CLIENT that fetched the link
    servername: ...
    ...

<client-public-ip> is the visitor's public IP, not the panel host. Fetching the same URL from another machine yields that other machine's IP in server.

How to reproduce

  1. Deploy 3x-ui behind a reverse proxy/CDN that sets X-Real-IP to the visitor's IP but does not set X-Forwarded-Host (common default for nginx/openresty and several CDN setups).
  2. Do not configure the "Subscription domain" (subDomain) / web domain setting.
  3. Create a local inbound with no node, and with a blank / wildcard (0.0.0.0) listen.
  4. Fetch https://<your-domain>/clash/<subid> from a client.
  5. Observe server: in the YAML equals that client's public IP.

Expected behavior

The server field should be stable and reflect the panel's public host (<your-domain>), or at minimum the value configured in subDomain / webDomain, or the actual Host header the client dialed - never the client IP.

Actual behavior

server is set to the requesting client's public IP whenever no node address / routable listen address / configured public host is present.

Root cause (source analysis)

In internal/sub/service.go, SubService.ResolveRequest derives the host in this order:

if h, err := getHostFromXFH(forwarded("X-Forwarded-Host")); err == nil && h != "" {
    host = h
}
if host == "" {
    host = forwarded("X-Real-IP")   // <-- treats the client IP as a host
}
if host == "" {
    host, _, err = net.SplitHostPort(c.Request.Host)
    ...
}

When the proxy sends X-Real-IP but not X-Forwarded-Host, host becomes the visitor's public IP. (Note: nginx/openresty forwards the original Host header by default, so the correct panel domain is actually available at c.Request.Host - it is simply skipped because X-Real-IP is checked first.)

That host is then stored as s.address via ForRequest(host) ? PrepareForRequest, and resolveInboundAddress (also internal/sub/service.go) falls back to s.address when nothing else is available:

if d := s.configuredPublicHost(); d != "" {
    return d
}
return s.address   // = the leaked client public IP

So with no subDomain/webDomain configured and no node/routable listen address, the link advertises the client IP.

Suggested fix

X-Real-IP is a client identifier, not a host. Prefer the actual Host header the client dialed over it. Reorder host resolution to:

X-Forwarded-Host  ?  c.Request.Host  ?  (last resort) X-Real-IP

When X-Forwarded-Host is absent, c.Request.Host already carries the real panel domain, so no client IP leaks in.

Related (previously closed) reports - this is a remaining gap

  • #5425 - "VLESS-XHTTP-Reality inbound is adding my local ip... only in Subscription"
  • #5836 - "Subscription replaces server address with client local IP"
  • #3891 - "Invalid subscription. Client ip used as Inbound when reverse-proxy used."
  • #3456 - "The IP address in subscriptions depends on the address at which the subscription is retrieved"

The prior fix (#5208 / #5425) added the configuredPublicHost() fallback inside resolveInboundAddress, which only helps when subDomain/webDomain is set. The underlying X-Real-IP-as-host leak in ResolveRequest remains, which is why this still reproduces when the public host setting is empty.

Workaround (for other users)

Set the Subscription domain (subDomain) (or web domain) to your panel host, e.g. https://<your-domain>; resolveInboundAddress then uses it instead of the leaked IP.

Checklist

  • I searched existing issues and this was not reported (prior closed issues are linked above).
  • I am running the latest released version (or verified it on latest main).
  • This bug report is written in English.
  • I have redacted sensitive data (tokens, client UUIDs, domain and public IP are replaced with placeholders).