@marcel91200 / caddy Mirror · read-only / commits / 03e6e439
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 11, 16:00 UTC

reverseproxy: fix X-Forwarded-* headers for Unix socket requests (#7463)

When a request arrives via a Unix domain socket (RemoteAddr == "@"), net.SplitHostPort fails, causing addForwardedHeaders to strip all X-Forwarded-* headers even when the connection is trusted via trusted_proxies_unix. Handle Unix socket connections before parsing RemoteAddr: if untrusted, strip headers for security; if trusted, let clientIP remain empty (no peer IP for a Unix socket hop) and fall through to the shared header logic, preserving the existing XFF chain without appending a spurious entry. Amp-Thread-ID: https://ampcode.com/threads/T-019c4225-a0ad-7283-ac56-e2c01eae1103 Co-authored-by: Amp <amp@ampcode.com>

XYenon committed Feb 11, 2026 at 04:00 UTC 03e6e439dd07d048323cc39516146e0f04032186
2 files changed +146 -33
modules/caddyhttp/reverseproxy/headers_test.go
+93
@@ -32,3 +32,96 @@ func TestAddForwardedHeadersNonIP(t *testing.T) {
32 t.Errorf("expected no error for non-IP address, got: %v", err)
33 }
34 }
35 +
36 +func TestAddForwardedHeaders_UnixSocketTrusted(t *testing.T) {
37 + h := Handler{}
38 +
39 + req := httptest.NewRequest("GET", "http://example.com/", nil)
40 + req.RemoteAddr = "@"
41 + req.Header.Set("X-Forwarded-For", "1.2.3.4, 10.0.0.1")
42 + req.Header.Set("X-Forwarded-Proto", "https")
43 + req.Header.Set("X-Forwarded-Host", "original.example.com")
44 +
45 + vars := map[string]interface{}{
46 + caddyhttp.TrustedProxyVarKey: true,
47 + caddyhttp.ClientIPVarKey: "1.2.3.4",
48 + }
49 + ctx := context.WithValue(req.Context(), caddyhttp.VarsCtxKey, vars)
50 + req = req.WithContext(ctx)
51 +
52 + err := h.addForwardedHeaders(req)
53 + if err != nil {
54 + t.Fatalf("expected no error, got: %v", err)
55 + }
56 +
57 + if got := req.Header.Get("X-Forwarded-For"); got != "1.2.3.4, 10.0.0.1" {
58 + t.Errorf("X-Forwarded-For = %q, want %q", got, "1.2.3.4, 10.0.0.1")
59 + }
60 + if got := req.Header.Get("X-Forwarded-Proto"); got != "https" {
61 + t.Errorf("X-Forwarded-Proto = %q, want %q", got, "https")
62 + }
63 + if got := req.Header.Get("X-Forwarded-Host"); got != "original.example.com" {
64 + t.Errorf("X-Forwarded-Host = %q, want %q", got, "original.example.com")
65 + }
66 +}
67 +
68 +func TestAddForwardedHeaders_UnixSocketUntrusted(t *testing.T) {
69 + h := Handler{}
70 +
71 + req := httptest.NewRequest("GET", "http://example.com/", nil)
72 + req.RemoteAddr = "@"
73 + req.Header.Set("X-Forwarded-For", "1.2.3.4")
74 + req.Header.Set("X-Forwarded-Proto", "https")
75 + req.Header.Set("X-Forwarded-Host", "spoofed.example.com")
76 +
77 + vars := map[string]interface{}{
78 + caddyhttp.TrustedProxyVarKey: false,
79 + caddyhttp.ClientIPVarKey: "",
80 + }
81 + ctx := context.WithValue(req.Context(), caddyhttp.VarsCtxKey, vars)
82 + req = req.WithContext(ctx)
83 +
84 + err := h.addForwardedHeaders(req)
85 + if err != nil {
86 + t.Fatalf("expected no error, got: %v", err)
87 + }
88 +
89 + if got := req.Header.Get("X-Forwarded-For"); got != "" {
90 + t.Errorf("X-Forwarded-For should be deleted, got %q", got)
91 + }
92 + if got := req.Header.Get("X-Forwarded-Proto"); got != "" {
93 + t.Errorf("X-Forwarded-Proto should be deleted, got %q", got)
94 + }
95 + if got := req.Header.Get("X-Forwarded-Host"); got != "" {
96 + t.Errorf("X-Forwarded-Host should be deleted, got %q", got)
97 + }
98 +}
99 +
100 +func TestAddForwardedHeaders_UnixSocketTrustedNoExistingHeaders(t *testing.T) {
101 + h := Handler{}
102 +
103 + req := httptest.NewRequest("GET", "http://example.com/", nil)
104 + req.RemoteAddr = "@"
105 +
106 + vars := map[string]interface{}{
107 + caddyhttp.TrustedProxyVarKey: true,
108 + caddyhttp.ClientIPVarKey: "5.6.7.8",
109 + }
110 + ctx := context.WithValue(req.Context(), caddyhttp.VarsCtxKey, vars)
111 + req = req.WithContext(ctx)
112 +
113 + err := h.addForwardedHeaders(req)
114 + if err != nil {
115 + t.Fatalf("expected no error, got: %v", err)
116 + }
117 +
118 + if got := req.Header.Get("X-Forwarded-For"); got != "" {
119 + t.Errorf("X-Forwarded-For should be empty when no prior XFF exists, got %q", got)
120 + }
121 + if got := req.Header.Get("X-Forwarded-Proto"); got != "http" {
122 + t.Errorf("X-Forwarded-Proto = %q, want %q", got, "http")
123 + }
124 + if got := req.Header.Get("X-Forwarded-Host"); got != "example.com" {
125 + t.Errorf("X-Forwarded-Host = %q, want %q", got, "example.com")
126 + }
127 +}
modules/caddyhttp/reverseproxy/reverseproxy.go
+53 -33
@@ -801,37 +801,53 @@ func (h Handler) prepareRequest(req *http.Request, repl *caddy.Replacer) (*http.
801 // the headers at all, then they will be added with the values
802 // that we can glean from the request.
803 func (h Handler) addForwardedHeaders(req *http.Request) error {
804 - // Parse the remote IP, ignore the error as non-fatal,
805 - // but the remote IP is required to continue, so we
806 - // just return early. This should probably never happen
807 - // though, unless some other module manipulated the request's
808 - // remote address and used an invalid value.
809 - clientIP, _, err := net.SplitHostPort(req.RemoteAddr)
810 - if err != nil {
811 - // Remove the `X-Forwarded-*` headers to avoid upstreams
812 - // potentially trusting a header that came from the client
813 - req.Header.Del("X-Forwarded-For")
814 - req.Header.Del("X-Forwarded-Proto")
815 - req.Header.Del("X-Forwarded-Host")
816 - return nil
817 - }
818 -
819 - // Client IP may contain a zone if IPv6, so we need
820 - // to pull that out before parsing the IP
821 - clientIP, _, _ = strings.Cut(clientIP, "%")
822 - ipAddr, err := netip.ParseAddr(clientIP)
823 -
804 // Check if the client is a trusted proxy
805 trusted := caddyhttp.GetVar(req.Context(), caddyhttp.TrustedProxyVarKey).(bool)
806
827 - // If ParseAddr fails (e.g. non-IP network like SCION), we cannot check
828 - // if it is a trusted proxy by IP range. In this case, we ignore the
829 - // error and treat the connection as untrusted (or retain existing status).
830 - if err == nil {
831 - for _, ipRange := range h.trustedProxies {
832 - if ipRange.Contains(ipAddr) {
833 - trusted = true
834 - break
807 + var clientIP string
808 +
809 + if req.RemoteAddr == "@" {
810 + // For Unix socket connections, RemoteAddr is "@" which cannot
811 + // be parsed as host:port. If untrusted, strip forwarded headers
812 + // for security. If trusted, there is no peer IP to append to
813 + // X-Forwarded-For, so clientIP stays empty.
814 + if !trusted {
815 + req.Header.Del("X-Forwarded-For")
816 + req.Header.Del("X-Forwarded-Proto")
817 + req.Header.Del("X-Forwarded-Host")
818 + return nil
819 + }
820 + } else {
821 + // Parse the remote IP, ignore the error as non-fatal,
822 + // but the remote IP is required to continue, so we
823 + // just return early. This should probably never happen
824 + // though, unless some other module manipulated the request's
825 + // remote address and used an invalid value.
826 + var err error
827 + clientIP, _, err = net.SplitHostPort(req.RemoteAddr)
828 + if err != nil {
829 + // Remove the `X-Forwarded-*` headers to avoid upstreams
830 + // potentially trusting a header that came from the client
831 + req.Header.Del("X-Forwarded-For")
832 + req.Header.Del("X-Forwarded-Proto")
833 + req.Header.Del("X-Forwarded-Host")
834 + return nil
835 + }
836 +
837 + // Client IP may contain a zone if IPv6, so we need
838 + // to pull that out before parsing the IP
839 + clientIP, _, _ = strings.Cut(clientIP, "%")
840 + ipAddr, err := netip.ParseAddr(clientIP)
841 +
842 + // If ParseAddr fails (e.g. non-IP network like SCION), we cannot check
843 + // if it is a trusted proxy by IP range. In this case, we ignore the
844 + // error and treat the connection as untrusted (or retain existing status).
845 + if err == nil {
846 + for _, ipRange := range h.trustedProxies {
847 + if ipRange.Contains(ipAddr) {
848 + trusted = true
849 + break
850 + }
851 }
852 }
853 }
@@ -839,13 +855,17 @@ func (h Handler) addForwardedHeaders(req *http.Request) error {
855 // If we aren't the first proxy, and the proxy is trusted,
856 // retain prior X-Forwarded-For information as a comma+space
857 // separated list and fold multiple headers into one.
842 - clientXFF := clientIP
858 prior, ok, omit := allHeaderValues(req.Header, "X-Forwarded-For")
844 - if trusted && ok && prior != "" {
845 - clientXFF = prior + ", " + clientXFF
846 - }
859 if !omit {
848 - req.Header.Set("X-Forwarded-For", clientXFF)
860 + if trusted && ok && prior != "" {
861 + if clientIP != "" {
862 + req.Header.Set("X-Forwarded-For", prior+", "+clientIP)
863 + } else {
864 + req.Header.Set("X-Forwarded-For", prior)
865 + }
866 + } else if clientIP != "" {
867 + req.Header.Set("X-Forwarded-For", clientIP)
868 + }
869 }
870
871 // Set X-Forwarded-Proto; many backend apps expect this,