detached f9f7402

fix(proxy): preserve X-Forwarded-Proto from trusted upstream proxies

When Gordon sits behind a TLS-terminating reverse proxy (e.g., Cloudflare,
nginx), the upstream sends X-Forwarded-Proto: https. Previously, Gordon's
reverse proxy unconditionally overwrote this with 'http' because the
connection between proxies is plain HTTP.

Now, if the request arrives from a source in trusted_proxies and already
carries X-Forwarded-Proto, the original value is preserved. Untrusted
sources cannot spoof the header — it is still overwritten for them.

Fixes #158
commit
f9f74026e075683f72c33d8e207d71b7d604831e
author
brice · · 2026-04-03 18:08 UTC
parent
2bb8b58
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +101 -2
M internal/adapters/in/http/proxy/forwarder.go +26 -2
@@ -15,6 +15,7 @@ import (
1515
1616 "github.com/bnema/zerowrap"
1717
18+ "github.com/bnema/gordon/internal/adapters/in/http/middleware"
1819 "github.com/bnema/gordon/internal/domain"
1920 )
2021
@@ -95,7 +96,7 @@ func (h *Handler) forwardToTarget(w http.ResponseWriter, r *http.Request, target
9596
9697 transport := h.transportForTarget(target)
9798
98- proxy := newReverseProxy(targetURL, originalHost, transport,
99+ proxy := newReverseProxy(targetURL, originalHost, transport, r, h.trustedNets,
99100 func(w http.ResponseWriter, _ *http.Request, proxyErr error) {
100101 var maxBytesErr *http.MaxBytesError
101102 if errors.As(proxyErr, &maxBytesErr) {
@@ -183,9 +184,13 @@ func (h *Handler) forwardToRegistry(w http.ResponseWriter, r *http.Request, regi
183184 // originalHost, when non-empty, overrides the Host header sent to the backend.
184185 // This is used for DNS-pinned targets where the proxy dials an IP but needs to
185186 // send the original hostname for virtual-hosted upstreams.
186-func newReverseProxy(targetURL *url.URL, originalHost string, transport http.RoundTripper, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResp func(*http.Response) error) *httputil.ReverseProxy {
187+//
188+// incomingReq and trustedNets are used to preserve X-Forwarded-Proto when the
189+// request originates from a trusted upstream proxy.
190+func newReverseProxy(targetURL *url.URL, originalHost string, transport http.RoundTripper, incomingReq *http.Request, trustedNets []*net.IPNet, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResp func(*http.Response) error) *httputil.ReverseProxy {
187191 return &httputil.ReverseProxy{
188192 Rewrite: func(pr *httputil.ProxyRequest) {
193+ existingProto := pr.In.Header.Get("X-Forwarded-Proto")
189194 pr.SetURL(targetURL)
190195 pr.SetXForwarded()
191196 if originalHost != "" {
@@ -193,6 +198,13 @@ func newReverseProxy(targetURL *url.URL, originalHost string, transport http.Rou
193198 } else {
194199 pr.Out.Host = targetURL.Host
195200 }
201+ // Preserve X-Forwarded-Proto from trusted upstream proxies.
202+ // SetXForwarded() unconditionally sets it based on the incoming scheme
203+ // (HTTP between proxies), but when a trusted proxy already sent the
204+ // real client proto, we must honor it.
205+ if existingProto != "" && isTrustedSource(incomingReq, trustedNets) {
206+ pr.Out.Header.Set("X-Forwarded-Proto", existingProto)
207+ }
196208 },
197209 Transport: transport,
198210 ErrorHandler: errorHandler,
@@ -200,6 +212,18 @@ func newReverseProxy(targetURL *url.URL, originalHost string, transport http.Rou
200212 }
201213 }
202214
215+// isTrustedSource reports whether the request's remote address is in trustedNets.
216+func isTrustedSource(r *http.Request, trustedNets []*net.IPNet) bool {
217+ if len(trustedNets) == 0 {
218+ return false
219+ }
220+ host, _, err := net.SplitHostPort(r.RemoteAddr)
221+ if err != nil {
222+ return false
223+ }
224+ return middleware.IsTrustedProxy(host, trustedNets)
225+}
226+
203227 // modifyResponse returns a function that adds proxy headers and enforces response size limits.
204228 func modifyResponse(maxResponseSize int64) func(*http.Response) error {
205229 return func(resp *http.Response) error {
@@ -15,6 +15,7 @@ import (
15 15
16 "github.com/bnema/zerowrap"16 "github.com/bnema/zerowrap"
17 17
18+ "github.com/bnema/gordon/internal/adapters/in/http/middleware"
18 "github.com/bnema/gordon/internal/domain"19 "github.com/bnema/gordon/internal/domain"
19 )20 )
20 21
@@ -95,7 +96,7 @@ func (h *Handler) forwardToTarget(w http.ResponseWriter, r *http.Request, target
95 96
96 transport := h.transportForTarget(target)97 transport := h.transportForTarget(target)
97 98
98- proxy := newReverseProxy(targetURL, originalHost, transport,99+ proxy := newReverseProxy(targetURL, originalHost, transport, r, h.trustedNets,
99 func(w http.ResponseWriter, _ *http.Request, proxyErr error) {100 func(w http.ResponseWriter, _ *http.Request, proxyErr error) {
100 var maxBytesErr *http.MaxBytesError101 var maxBytesErr *http.MaxBytesError
101 if errors.As(proxyErr, &maxBytesErr) {102 if errors.As(proxyErr, &maxBytesErr) {
@@ -183,9 +184,13 @@ func (h *Handler) forwardToRegistry(w http.ResponseWriter, r *http.Request, regi
183 // originalHost, when non-empty, overrides the Host header sent to the backend.184 // originalHost, when non-empty, overrides the Host header sent to the backend.
184 // This is used for DNS-pinned targets where the proxy dials an IP but needs to185 // This is used for DNS-pinned targets where the proxy dials an IP but needs to
185 // send the original hostname for virtual-hosted upstreams.186 // send the original hostname for virtual-hosted upstreams.
186-func newReverseProxy(targetURL *url.URL, originalHost string, transport http.RoundTripper, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResp func(*http.Response) error) *httputil.ReverseProxy {187+//
188+// incomingReq and trustedNets are used to preserve X-Forwarded-Proto when the
189+// request originates from a trusted upstream proxy.
190+func newReverseProxy(targetURL *url.URL, originalHost string, transport http.RoundTripper, incomingReq *http.Request, trustedNets []*net.IPNet, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResp func(*http.Response) error) *httputil.ReverseProxy {
187 return &httputil.ReverseProxy{191 return &httputil.ReverseProxy{
188 Rewrite: func(pr *httputil.ProxyRequest) {192 Rewrite: func(pr *httputil.ProxyRequest) {
193+ existingProto := pr.In.Header.Get("X-Forwarded-Proto")
189 pr.SetURL(targetURL)194 pr.SetURL(targetURL)
190 pr.SetXForwarded()195 pr.SetXForwarded()
191 if originalHost != "" {196 if originalHost != "" {
@@ -193,6 +198,13 @@ func newReverseProxy(targetURL *url.URL, originalHost string, transport http.Rou
193 } else {198 } else {
194 pr.Out.Host = targetURL.Host199 pr.Out.Host = targetURL.Host
195 }200 }
201+ // Preserve X-Forwarded-Proto from trusted upstream proxies.
202+ // SetXForwarded() unconditionally sets it based on the incoming scheme
203+ // (HTTP between proxies), but when a trusted proxy already sent the
204+ // real client proto, we must honor it.
205+ if existingProto != "" && isTrustedSource(incomingReq, trustedNets) {
206+ pr.Out.Header.Set("X-Forwarded-Proto", existingProto)
207+ }
196 },208 },
197 Transport: transport,209 Transport: transport,
198 ErrorHandler: errorHandler,210 ErrorHandler: errorHandler,
@@ -200,6 +212,18 @@ func newReverseProxy(targetURL *url.URL, originalHost string, transport http.Rou
200 }212 }
201 }213 }
202 214
215+// isTrustedSource reports whether the request's remote address is in trustedNets.
216+func isTrustedSource(r *http.Request, trustedNets []*net.IPNet) bool {
217+ if len(trustedNets) == 0 {
218+ return false
219+ }
220+ host, _, err := net.SplitHostPort(r.RemoteAddr)
221+ if err != nil {
222+ return false
223+ }
224+ return middleware.IsTrustedProxy(host, trustedNets)
225+}
226+
203 // modifyResponse returns a function that adds proxy headers and enforces response size limits.227 // modifyResponse returns a function that adds proxy headers and enforces response size limits.
204 func modifyResponse(maxResponseSize int64) func(*http.Response) error {228 func modifyResponse(maxResponseSize int64) func(*http.Response) error {
205 return func(resp *http.Response) error {229 return func(resp *http.Response) error {
M internal/adapters/in/http/proxy/forwarder_test.go +75 -0
@@ -5,6 +5,7 @@ import (
55 "net"
66 "net/http"
77 "net/http/httptest"
8+ "net/url"
89 "strconv"
910 "testing"
1011
@@ -13,6 +14,7 @@ import (
1314 "github.com/stretchr/testify/mock"
1415 "github.com/stretchr/testify/require"
1516
17+ "github.com/bnema/gordon/internal/adapters/in/http/middleware"
1618 "github.com/bnema/gordon/internal/boundaries/in"
1719 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
1820 "github.com/bnema/gordon/internal/domain"
@@ -198,3 +200,76 @@ func TestForwardToTarget_ProxyHeaderSet(t *testing.T) {
198200
199201 assert.Equal(t, http.StatusOK, w.Code)
200202 }
203+func TestNewReverseProxy_XForwardedProto(t *testing.T) {
204+ trustedNets := middleware.ParseTrustedProxies([]string{"10.0.0.0/8"})
205+
206+ tests := []struct {
207+ name string
208+ remoteAddr string
209+ incomingProto string // X-Forwarded-Proto on incoming request, empty = not set
210+ wantProto string // expected X-Forwarded-Proto at backend
211+ nets []*net.IPNet
212+ }{
213+ {
214+ name: "trusted proxy with https preserved",
215+ remoteAddr: "10.0.0.1:12345",
216+ incomingProto: "https",
217+ wantProto: "https",
218+ nets: trustedNets,
219+ },
220+ {
221+ name: "untrusted source proto overwritten",
222+ remoteAddr: "1.2.3.4:12345",
223+ incomingProto: "https",
224+ wantProto: "http",
225+ nets: trustedNets,
226+ },
227+ {
228+ name: "trusted proxy without proto set normally",
229+ remoteAddr: "10.0.0.1:12345",
230+ incomingProto: "",
231+ wantProto: "http",
232+ nets: trustedNets,
233+ },
234+ {
235+ name: "no trusted nets proto not preserved",
236+ remoteAddr: "10.0.0.1:12345",
237+ incomingProto: "https",
238+ wantProto: "http",
239+ nets: nil,
240+ },
241+ }
242+
243+ for _, tt := range tests {
244+ t.Run(tt.name, func(t *testing.T) {
245+ var gotProto string
246+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
247+ gotProto = r.Header.Get("X-Forwarded-Proto")
248+ w.WriteHeader(http.StatusOK)
249+ }))
250+ defer backend.Close()
251+
252+ backendURL, err := url.Parse(backend.URL)
253+ require.NoError(t, err)
254+
255+ // Build incoming request (http scheme, no TLS)
256+ incomingReq := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
257+ incomingReq.RemoteAddr = tt.remoteAddr
258+ if tt.incomingProto != "" {
259+ incomingReq.Header.Set("X-Forwarded-Proto", tt.incomingProto)
260+ }
261+
262+ proxy := newReverseProxy(backendURL, "", http.DefaultTransport, incomingReq, tt.nets,
263+ func(w http.ResponseWriter, _ *http.Request, err error) {
264+ http.Error(w, err.Error(), http.StatusBadGateway)
265+ },
266+ nil,
267+ )
268+
269+ rec := httptest.NewRecorder()
270+ proxy.ServeHTTP(rec, incomingReq)
271+
272+ assert.Equal(t, tt.wantProto, gotProto)
273+ })
274+ }
275+}
@@ -5,6 +5,7 @@ import (
5 "net"5 "net"
6 "net/http"6 "net/http"
7 "net/http/httptest"7 "net/http/httptest"
8+ "net/url"
8 "strconv"9 "strconv"
9 "testing"10 "testing"
10 11
@@ -13,6 +14,7 @@ import (
13 "github.com/stretchr/testify/mock"14 "github.com/stretchr/testify/mock"
14 "github.com/stretchr/testify/require"15 "github.com/stretchr/testify/require"
15 16
17+ "github.com/bnema/gordon/internal/adapters/in/http/middleware"
16 "github.com/bnema/gordon/internal/boundaries/in"18 "github.com/bnema/gordon/internal/boundaries/in"
17 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"19 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
18 "github.com/bnema/gordon/internal/domain"20 "github.com/bnema/gordon/internal/domain"
@@ -198,3 +200,76 @@ func TestForwardToTarget_ProxyHeaderSet(t *testing.T) {
198 200
199 assert.Equal(t, http.StatusOK, w.Code)201 assert.Equal(t, http.StatusOK, w.Code)
200 }202 }
203+func TestNewReverseProxy_XForwardedProto(t *testing.T) {
204+ trustedNets := middleware.ParseTrustedProxies([]string{"10.0.0.0/8"})
205+
206+ tests := []struct {
207+ name string
208+ remoteAddr string
209+ incomingProto string // X-Forwarded-Proto on incoming request, empty = not set
210+ wantProto string // expected X-Forwarded-Proto at backend
211+ nets []*net.IPNet
212+ }{
213+ {
214+ name: "trusted proxy with https preserved",
215+ remoteAddr: "10.0.0.1:12345",
216+ incomingProto: "https",
217+ wantProto: "https",
218+ nets: trustedNets,
219+ },
220+ {
221+ name: "untrusted source proto overwritten",
222+ remoteAddr: "1.2.3.4:12345",
223+ incomingProto: "https",
224+ wantProto: "http",
225+ nets: trustedNets,
226+ },
227+ {
228+ name: "trusted proxy without proto set normally",
229+ remoteAddr: "10.0.0.1:12345",
230+ incomingProto: "",
231+ wantProto: "http",
232+ nets: trustedNets,
233+ },
234+ {
235+ name: "no trusted nets proto not preserved",
236+ remoteAddr: "10.0.0.1:12345",
237+ incomingProto: "https",
238+ wantProto: "http",
239+ nets: nil,
240+ },
241+ }
242+
243+ for _, tt := range tests {
244+ t.Run(tt.name, func(t *testing.T) {
245+ var gotProto string
246+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
247+ gotProto = r.Header.Get("X-Forwarded-Proto")
248+ w.WriteHeader(http.StatusOK)
249+ }))
250+ defer backend.Close()
251+
252+ backendURL, err := url.Parse(backend.URL)
253+ require.NoError(t, err)
254+
255+ // Build incoming request (http scheme, no TLS)
256+ incomingReq := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
257+ incomingReq.RemoteAddr = tt.remoteAddr
258+ if tt.incomingProto != "" {
259+ incomingReq.Header.Set("X-Forwarded-Proto", tt.incomingProto)
260+ }
261+
262+ proxy := newReverseProxy(backendURL, "", http.DefaultTransport, incomingReq, tt.nets,
263+ func(w http.ResponseWriter, _ *http.Request, err error) {
264+ http.Error(w, err.Error(), http.StatusBadGateway)
265+ },
266+ nil,
267+ )
268+
269+ rec := httptest.NewRecorder()
270+ proxy.ServeHTTP(rec, incomingReq)
271+
272+ assert.Equal(t, tt.wantProto, gotProto)
273+ })
274+ }
275+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close