v3.3.2 9697f9d

cidr.go RawHistoryFolder

1package middleware
2
3import (
4	"encoding/json"
5	"fmt"
6	"net"
7	"net/http"
8	"strconv"
9	"strings"
10
11	"github.com/bnema/zerowrap"
12
13	"github.com/bnema/gordon/internal/adapters/dto"
14	"github.com/bnema/gordon/internal/adapters/in/http/httphelper"
15	"github.com/bnema/gordon/internal/domain"
16)
17
18// cidrAllowlist is the shared implementation for CIDR-based access control middleware.
19// ipExtractor determines how the client IP is obtained from the request.
20// logLabel is used in the deny log message (e.g. "registry", "proxy origin").
21func cidrAllowlist(allowedNets []*net.IPNet, ipExtractor func(*http.Request) string, logLabel string, log zerowrap.Logger) func(http.Handler) http.Handler {
22	return func(next http.Handler) http.Handler {
23		if len(allowedNets) == 0 {
24			return next
25		}
26		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27			clientIP := ipExtractor(r)
28
29			if httphelper.IsTrustedProxy(clientIP, httphelper.LocalhostNets) {
30				next.ServeHTTP(w, r)
31				return
32			}
33
34			if httphelper.IsTrustedProxy(clientIP, allowedNets) {
35				next.ServeHTTP(w, r)
36				return
37			}
38
39			log.Warn().
40				Str(zerowrap.FieldLayer, "adapter").
41				Str(zerowrap.FieldAdapter, "http").
42				Str(zerowrap.FieldMethod, r.Method).
43				Str(zerowrap.FieldClientIP, clientIP).
44				Msgf("%s access denied by CIDR allowlist", logLabel)
45
46			w.Header().Set("Content-Type", "application/json")
47			w.WriteHeader(http.StatusForbidden)
48			_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden"})
49		})
50	}
51}
52
53// RegistryCIDRAllowlist returns middleware that restricts access to the given CIDR ranges.
54// Localhost is always allowed so Gordon can pull from its own registry.
55// An empty allowedNets slice is a no-op (all traffic passes through).
56func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler {
57	return cidrAllowlist(allowedNets, func(r *http.Request) string {
58		return GetClientIP(r, trustedNets)
59	}, "registry", log)
60}
61
62// HTTPSRedirect redirects HTTP clients to the HTTPS port.
63//
64// When forceAll is true, all HTTP requests are redirected (for setups with no proxy).
65// Otherwise, only non-trusted clients are redirected — trusted proxy IPs and localhost
66// pass through since they deliver Cloudflare-proxied traffic.
67// When tlsPort is 0, this is always a no-op.
68//
69// httpPort is the configured HTTP listener port (cfg.Server.Port) so the redirect
70// helper can map it to tlsPort. Hosts with no explicit port omit the TLS port from
71// the public URL; hosts with an unknown explicit port preserve it as-is.
72func HTTPSRedirect(proxyNets []*net.IPNet, httpPort, tlsPort int, forceAll bool, log zerowrap.Logger, isHostAllowed func(string) bool) func(http.Handler) http.Handler {
73	return HTTPSRedirectWithEligibility(proxyNets, httpPort, tlsPort, forceAll, log, isHostAllowed, nil, nil)
74}
75
76// HTTPSRedirectWithEligibility is HTTPSRedirect with an extra opt-out and
77// an enforced-TLS predicate:
78//   - isRedirectEligible reports whether a KNOWN host should actually be
79//     redirected (e.g. app hosts declared tls=never stay plain HTTP).
80//     A known but ineligible host passes through to next instead of
81//     redirecting; unknown/invalid hosts still get 400. Nil behaves like
82//     HTTPSRedirect (every allowed host is eligible).
83//   - isTLSAlways reports whether a host requires HTTPS unconditionally
84//     (tls=always). Such a host is redirected when an HTTPS endpoint
85//     exists and otherwise refused, never served over plaintext. Nil
86//     disables the check.
87func HTTPSRedirectWithEligibility(proxyNets []*net.IPNet, httpPort, tlsPort int, forceAll bool, log zerowrap.Logger, isHostAllowed func(string) bool, isRedirectEligible func(string) bool, isTLSAlways func(string) bool) func(http.Handler) http.Handler {
88	if tlsPort == 0 && isTLSAlways == nil {
89		return func(next http.Handler) http.Handler { return next }
90	}
91	if !forceAll && len(proxyNets) == 0 && isTLSAlways == nil {
92		log.Info().
93			Str(zerowrap.FieldLayer, "adapter").
94			Str(zerowrap.FieldAdapter, "http").
95			Msg("HTTP→HTTPS redirect disabled: proxy_allowed_ips is empty and force_https_redirect is false; set either to enable redirects")
96		return func(next http.Handler) http.Handler { return next }
97	}
98	return func(next http.Handler) http.Handler {
99		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
100			// A tls=always host must never be served over plaintext. It is
101			// redirected when an HTTPS endpoint exists and refused when it
102			// does not, regardless of proxy/redirect configuration.
103			if isTLSAlways != nil && isTLSAlwaysForHost(r.Host, isTLSAlways) {
104				handleTLSAlways(w, r, httpPort, tlsPort, isHostAllowed, log)
105				return
106			}
107
108			if tlsPort == 0 {
109				next.ServeHTTP(w, r)
110				return
111			}
112			if !forceAll {
113				remoteIP := httphelper.ExtractRemoteIP(r.RemoteAddr)
114				if httphelper.IsTrustedOrLocal(remoteIP, proxyNets) {
115					next.ServeHTTP(w, r)
116					return
117				}
118			}
119
120			target, ok := httpsRedirectTarget(r.Host, r.RequestURI, httpPort, tlsPort, isHostAllowed)
121			if ok && isRedirectEligible != nil && !isRedirectEligibleForHost(r.Host, isRedirectEligible) {
122				// Known host that opted out of TLS (e.g. tls=never):
123				// serve plain HTTP through the normal chain.
124				next.ServeHTTP(w, r)
125				return
126			}
127			if !ok {
128				http.Error(w, "Bad Request", http.StatusBadRequest)
129				return
130			}
131
132			log.Debug().
133				Str("target", target).
134				Msg("redirecting HTTP client to HTTPS")
135
136			http.Redirect(w, r, target, http.StatusPermanentRedirect)
137		})
138	}
139}
140
141// handleTLSAlways redirects or refuses a request to a tls=always host. It
142// never forwards: the backend is not reached over plaintext.
143func handleTLSAlways(w http.ResponseWriter, r *http.Request, httpPort, tlsPort int, isHostAllowed func(string) bool, log zerowrap.Logger) {
144	if tlsPort == 0 {
145		log.Warn().Str("host", r.Host).Msg("plaintext refused: host requires TLS but no HTTPS endpoint is configured")
146		http.Error(w, "Misdirected Request", http.StatusMisdirectedRequest)
147		return
148	}
149	target, ok := httpsRedirectTarget(r.Host, r.RequestURI, httpPort, tlsPort, isHostAllowed)
150	if !ok {
151		http.Error(w, "Bad Request", http.StatusBadRequest)
152		return
153	}
154	http.Redirect(w, r, target, http.StatusPermanentRedirect)
155}
156
157// isTLSAlwaysForHost reports whether the canonical request Host requires
158// HTTPS. Invalid hosts are never treated as always.
159func isTLSAlwaysForHost(host string, isTLSAlways func(string) bool) bool {
160	canonical, ok := canonicalRedirectHost(host)
161	if !ok {
162		return false
163	}
164	return isTLSAlways(canonical)
165}
166
167// isRedirectEligibleForHost reports whether the canonical request Host
168// is eligible for redirect. Invalid hosts are never eligible.
169func isRedirectEligibleForHost(host string, isRedirectEligible func(string) bool) bool {
170	canonical, ok := canonicalRedirectHost(host)
171	if !ok {
172		return false
173	}
174	return isRedirectEligible(canonical)
175}
176
177// canonicalRedirectHost lowercases, strips ports/trailing dots and
178// validates the request Host, returning the canonical route domain.
179func canonicalRedirectHost(host string) (string, bool) {
180	host = strings.ToLower(strings.TrimSpace(host))
181	if host == "" {
182		return "", false
183	}
184	hostname, portStr, err := net.SplitHostPort(host)
185	if err != nil {
186		if strings.Contains(host, ":") {
187			return "", false
188		}
189		hostname = host
190	} else if port, err := strconv.Atoi(portStr); err != nil || port < 1 || port > 65535 {
191		return "", false
192	}
193	hostname = strings.TrimSuffix(hostname, ".")
194	return domain.CanonicalRouteDomain(hostname)
195}
196
197// httpsRedirectTarget derives the HTTPS redirect URL from a validated request Host.
198func httpsRedirectTarget(host, requestURI string, httpPort, tlsPort int, isHostAllowed func(string) bool) (string, bool) {
199	raw := strings.ToLower(strings.TrimSpace(host))
200	_, portStr, splitErr := net.SplitHostPort(raw)
201	hasPort := splitErr == nil
202	canonicalHost, ok := canonicalRedirectHost(host)
203	if !ok {
204		return "", false
205	}
206	if isHostAllowed == nil || !isHostAllowed(canonicalHost) {
207		return "", false
208	}
209
210	path := requestURI
211	if !strings.HasPrefix(path, "/") {
212		path = "/"
213	}
214
215	canonicalAuthority := canonicalHost
216	if hasPort {
217		canonicalAuthority = net.JoinHostPort(canonicalHost, portStr)
218	}
219	return fmt.Sprintf("https://%s%s", httphelper.HTTPSAuthority(canonicalAuthority, httpPort, tlsPort), path), true
220}
221
222// ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges.
223// This validates the direct network peer (RemoteAddr), not forwarded IPs, to ensure only
224// trusted sources (e.g. Cloudflare edge IPs) can reach the proxy server.
225// An empty allowedNets slice is a no-op (all traffic passes through).
226func ProxyCIDRAllowlist(allowedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler {
227	return cidrAllowlist(allowedNets, func(r *http.Request) string {
228		return httphelper.ExtractRemoteIP(r.RemoteAddr)
229	}, "proxy origin", log)
230}

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