Files
Alphaeus Mote 1f77473d8f feat(server): trust local proxies by default, seed built-in schedules
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>
2026-09-02 17:14:49 -04:00

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 ""
}