package originauth import ( "crypto/subtle" "fmt" "net" "net/http" "net/netip" "os" "strings" "github.com/wangjia/pangolin/server/internal/apierr" ) // DefaultHeader is the request header the CDN injects on origin requests. const DefaultHeader = "X-Origin-Auth" // errForbidden is the desensitised 403 body (no "VPN"/"proxy" wording). var errForbidden = &apierr.Error{ Code: "FORBIDDEN", MessageZH: "访问被拒绝", MessageEn: "Access denied", } // Config configures the middleware. type Config struct { // Header is the auth header name. Empty defaults to DefaultHeader. Header string // Current is the active auth value (required). Current string // Previous is the prior auth value accepted during a rotation window // (optional). Previous string // AllowedCIDRs are the CDN egress ranges permitted to reach the origin // (required, at least one). AllowedCIDRs []string } // Middleware enforces CDN-only origin access. type Middleware struct { header string current string previous string nets []netip.Prefix } // New validates cfg and builds a Middleware. func New(cfg Config) (*Middleware, error) { header := cfg.Header if header == "" { header = DefaultHeader } if cfg.Current == "" { return nil, fmt.Errorf("originauth: Current auth value is required") } if len(cfg.AllowedCIDRs) == 0 { return nil, fmt.Errorf("originauth: at least one AllowedCIDR is required") } nets := make([]netip.Prefix, 0, len(cfg.AllowedCIDRs)) for _, c := range cfg.AllowedCIDRs { c = strings.TrimSpace(c) if c == "" { continue } p, err := netip.ParsePrefix(c) if err != nil { return nil, fmt.Errorf("originauth: invalid CIDR %q: %w", c, err) } nets = append(nets, p.Masked()) } if len(nets) == 0 { return nil, fmt.Errorf("originauth: at least one AllowedCIDR is required") } return &Middleware{ header: header, current: cfg.Current, previous: cfg.Previous, nets: nets, }, nil } // Handler is the net/http middleware. It calls next only when both the source // IP and the auth header are accepted; otherwise it writes a 403. func (m *Middleware) Handler(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !m.ipAllowed(r.RemoteAddr) || !m.headerAllowed(r.Header.Get(m.header)) { apierr.WriteJSON(w, http.StatusForbidden, errForbidden) return } next.ServeHTTP(w, r) }) } // ipAllowed reports whether the TCP peer address falls in an allowed CDN range. // It uses the real connection peer (RemoteAddr), never a client-supplied header, // so a forged X-Forwarded-For cannot bypass the check. func (m *Middleware) ipAllowed(remoteAddr string) bool { host := remoteAddr if h, _, err := net.SplitHostPort(remoteAddr); err == nil { host = h } addr, err := netip.ParseAddr(strings.TrimSpace(host)) if err != nil { return false } addr = addr.Unmap() for _, p := range m.nets { if p.Contains(addr) { return true } } return false } // headerAllowed reports whether v matches the current or previous auth value, // using constant-time comparison. func (m *Middleware) headerAllowed(v string) bool { if v == "" { return false } if subtle.ConstantTimeCompare([]byte(v), []byte(m.current)) == 1 { return true } if m.previous != "" && subtle.ConstantTimeCompare([]byte(v), []byte(m.previous)) == 1 { return true } return false } // FromEnv builds a Config from environment variables: // // ORIGIN_AUTH_HEADER (optional, default X-Origin-Auth) // ORIGIN_AUTH_CURRENT (required) // ORIGIN_AUTH_PREVIOUS (optional, rotation window) // ORIGIN_AUTH_CIDRS (required, comma-separated CDN egress ranges) // // The returned Config is still passed to New for validation. func FromEnv() Config { var cidrs []string if raw := os.Getenv("ORIGIN_AUTH_CIDRS"); raw != "" { cidrs = strings.Split(raw, ",") } return Config{ Header: os.Getenv("ORIGIN_AUTH_HEADER"), Current: os.Getenv("ORIGIN_AUTH_CURRENT"), Previous: os.Getenv("ORIGIN_AUTH_PREVIOUS"), AllowedCIDRs: cidrs, } }