1f77473d8f
Proxy / base-URL: - ORCHESTRAD_TRUSTED_PROXIES now defaults to "local", trusting reverse proxies in loopback + RFC1918 + link-local/ULA ranges out of the box, so X-Forwarded-* (client IP, scheme, host) is honored behind an edge proxy without extra config. New keywords: local/private, all/any, none. - OIDC redirect URI derivation now uses the trust-gated request base URL instead of reading X-Forwarded-Proto directly, and audit client IP now trusts the middleware-rewritten RemoteAddr rather than the raw (spoofable) X-Forwarded-For header. Both honor forwarded values only from trusted peers. Schedules: - Seed eight built-in schedules on startup (every 5/15/30 min, hourly, every 6/12h, daily, weekly), idempotent by name, so operators have ready-made cadences in the Schedules page and the rule editor's schedule dropdown without hand-building one. Test covers the trusted-proxy keyword expansion. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
162 lines
4.3 KiB
Go
162 lines
4.3 KiB
Go
package server
|
|
|
|
import (
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
// trustedProxies is a parsed list of CIDR ranges whose X-Forwarded-* headers
|
|
// we are willing to honor. A zero-length list disables proxy-header handling
|
|
// entirely, which is the safe default for direct exposure.
|
|
type trustedProxies []*net.IPNet
|
|
|
|
// localProxyCIDRs are the loopback and private/link-local ranges trusted when
|
|
// the configuration uses the "local"/"private" keyword — the common case for
|
|
// an edge proxy running on the same host or LAN.
|
|
var localProxyCIDRs = []string{
|
|
"127.0.0.0/8", "::1/128",
|
|
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
|
|
"169.254.0.0/16", "fc00::/7", "fe80::/10",
|
|
}
|
|
|
|
func parseTrustedProxies(cidrs []string) (trustedProxies, error) {
|
|
// Expand keywords first: "local"/"private" -> local ranges; "all"/"*" ->
|
|
// everything; "none" -> nothing.
|
|
var expanded []string
|
|
for _, raw := range cidrs {
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
case "":
|
|
continue
|
|
case "none", "off", "false":
|
|
return nil, nil
|
|
case "local", "private":
|
|
expanded = append(expanded, localProxyCIDRs...)
|
|
case "all", "any", "*":
|
|
expanded = append(expanded, "0.0.0.0/0", "::/0")
|
|
default:
|
|
expanded = append(expanded, raw)
|
|
}
|
|
}
|
|
|
|
var nets trustedProxies
|
|
for _, raw := range expanded {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
continue
|
|
}
|
|
// Bare IPs are accepted as /32 or /128.
|
|
if !strings.Contains(raw, "/") {
|
|
if ip := net.ParseIP(raw); ip != nil {
|
|
if ip.To4() != nil {
|
|
raw += "/32"
|
|
} else {
|
|
raw += "/128"
|
|
}
|
|
}
|
|
}
|
|
_, ipnet, err := net.ParseCIDR(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
nets = append(nets, ipnet)
|
|
}
|
|
return nets, nil
|
|
}
|
|
|
|
func (tp trustedProxies) contains(ip net.IP) bool {
|
|
if ip == nil {
|
|
return false
|
|
}
|
|
for _, n := range tp {
|
|
if n.Contains(ip) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// proxyHeadersMiddleware rewrites r.RemoteAddr, r.URL.Scheme, and r.Host from
|
|
// X-Forwarded-* headers only when the immediate TCP peer sits inside one of
|
|
// the configured trusted CIDR ranges. Requests arriving from untrusted peers
|
|
// carry their headers through untouched but are never used for identity or
|
|
// link-building decisions. If no proxies are configured the middleware is a
|
|
// no-op and raw RemoteAddr wins, which is the correct behavior for a binary
|
|
// exposed directly on the network.
|
|
func proxyHeadersMiddleware(tp trustedProxies) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if len(tp) > 0 {
|
|
peerIP := remoteIPFromAddr(r.RemoteAddr)
|
|
if tp.contains(peerIP) {
|
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
|
if client := firstNonEmpty(splitAndTrim(xff, ",")); client != "" {
|
|
r.RemoteAddr = client
|
|
}
|
|
} else if xrip := strings.TrimSpace(r.Header.Get("X-Real-IP")); xrip != "" {
|
|
r.RemoteAddr = xrip
|
|
}
|
|
if proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); proto != "" {
|
|
r.URL.Scheme = strings.ToLower(proto)
|
|
}
|
|
if host := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")); host != "" {
|
|
r.Host = host
|
|
}
|
|
}
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// PublicBaseURL returns the externally-visible base URL (scheme://host) for the
|
|
// current request. It honors X-Forwarded-* headers only when they were already
|
|
// applied by proxyHeadersMiddleware upstream, so callers do not need to know
|
|
// whether a proxy is in front. Useful for building OIDC redirect URIs, email
|
|
// links, or any absolute URL that must round-trip back to the user.
|
|
func PublicBaseURL(r *http.Request) string {
|
|
scheme := r.URL.Scheme
|
|
if scheme == "" {
|
|
if r.TLS != nil {
|
|
scheme = "https"
|
|
} else {
|
|
scheme = "http"
|
|
}
|
|
}
|
|
host := r.Host
|
|
if host == "" {
|
|
host = r.URL.Host
|
|
}
|
|
return scheme + "://" + host
|
|
}
|
|
|
|
func remoteIPFromAddr(addr string) net.IP {
|
|
if addr == "" {
|
|
return nil
|
|
}
|
|
if host, _, err := net.SplitHostPort(addr); err == nil {
|
|
return net.ParseIP(host)
|
|
}
|
|
return net.ParseIP(addr)
|
|
}
|
|
|
|
func splitAndTrim(s, sep string) []string {
|
|
parts := strings.Split(s, sep)
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
if t := strings.TrimSpace(p); t != "" {
|
|
out = append(out, t)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func firstNonEmpty(parts []string) string {
|
|
for _, p := range parts {
|
|
if p != "" {
|
|
return p
|
|
}
|
|
}
|
|
return ""
|
|
}
|