merge: maestro/tsk_NU9JuUweHWMt [tsk_NU9JuUweHWMt]

This commit is contained in:
wangjia
2026-06-13 17:04:17 +08:00
32 changed files with 2572 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
// Package originauth implements origin-hiding enforcement (doc/05 §2 源站隐藏 /
// 回源鉴权).
//
// The API only ever serves traffic through the CDN. The CDN is configured to
// 1. connect to the origin from a known set of egress IP ranges, and
// 2. inject a shared secret header (default X-Origin-Auth: <random>) on every
// origin request.
//
// This middleware rejects (403) any request that either does not originate from
// an allowed CDN egress range, or does not carry a recognised auth value — so a
// direct hit on the origin IP, bypassing the CDN, is refused.
//
// Rotation: the auth value is rotated quarterly (doc/06 §6). To make rotation
// zero-downtime the middleware accepts a Current value plus an optional Previous
// value, so both the old and new secret validate during the transition window.
// Comparison is constant-time.
//
// Wiring (not done automatically to keep /healthz reachable for CDN probes):
//
// mw, err := originauth.New(originauth.FromEnv())
// if err == nil {
// r.Group(func(pr chi.Router) {
// pr.Use(mw.Handler)
// // ... protected API routes ...
// })
// }
package originauth
+149
View File
@@ -0,0 +1,149 @@
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,
}
}
@@ -0,0 +1,135 @@
package originauth
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
)
func testMW(t *testing.T) *Middleware {
t.Helper()
mw, err := New(Config{
Current: "secret-current",
Previous: "secret-previous",
AllowedCIDRs: []string{"203.0.113.0/24", "2001:db8::/32"},
})
if err != nil {
t.Fatalf("New: %v", err)
}
return mw
}
func do(t *testing.T, mw *Middleware, remoteAddr, headerVal string) int {
t.Helper()
h := mw.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
req := httptest.NewRequest(http.MethodGet, "/v1/nodes", nil)
req.RemoteAddr = remoteAddr
if headerVal != "" {
req.Header.Set(DefaultHeader, headerVal)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec.Code
}
func TestAllowedIPCurrentValue(t *testing.T) {
mw := testMW(t)
if code := do(t, mw, "203.0.113.5:443", "secret-current"); code != http.StatusOK {
t.Fatalf("want 200, got %d", code)
}
}
func TestAllowedIPPreviousValueDuringRotation(t *testing.T) {
mw := testMW(t)
if code := do(t, mw, "203.0.113.5:443", "secret-previous"); code != http.StatusOK {
t.Fatalf("rotation window: want 200, got %d", code)
}
}
func TestAllowedIPWrongValue(t *testing.T) {
mw := testMW(t)
if code := do(t, mw, "203.0.113.5:443", "nope"); code != http.StatusForbidden {
t.Fatalf("want 403, got %d", code)
}
}
func TestAllowedIPMissingValue(t *testing.T) {
mw := testMW(t)
if code := do(t, mw, "203.0.113.5:443", ""); code != http.StatusForbidden {
t.Fatalf("want 403, got %d", code)
}
}
func TestDirectConnectBypassRejected(t *testing.T) {
mw := testMW(t)
// Correct header but a source IP outside the CDN range = someone hitting the
// origin directly. Must be 403.
if code := do(t, mw, "198.51.100.7:55000", "secret-current"); code != http.StatusForbidden {
t.Fatalf("direct connect must be 403, got %d", code)
}
}
func TestIPv6Allowed(t *testing.T) {
mw := testMW(t)
if code := do(t, mw, "[2001:db8::1]:443", "secret-current"); code != http.StatusOK {
t.Fatalf("want 200 for allowed v6, got %d", code)
}
}
func TestForgedXFFDoesNotBypass(t *testing.T) {
mw := testMW(t)
h := mw.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/v1/nodes", nil)
req.RemoteAddr = "198.51.100.7:55000" // real peer: not a CDN range
req.Header.Set("X-Forwarded-For", "203.0.113.5")
req.Header.Set(DefaultHeader, "secret-current")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("forged XFF must not bypass, got %d", rec.Code)
}
}
func TestNewValidation(t *testing.T) {
if _, err := New(Config{AllowedCIDRs: []string{"203.0.113.0/24"}}); err == nil {
t.Fatal("want error: missing Current")
}
if _, err := New(Config{Current: "x"}); err == nil {
t.Fatal("want error: missing CIDRs")
}
if _, err := New(Config{Current: "x", AllowedCIDRs: []string{"not-a-cidr"}}); err == nil {
t.Fatal("want error: bad CIDR")
}
}
func TestIntegrationWithChiRouter(t *testing.T) {
mw := testMW(t)
r := chi.NewRouter()
r.Use(mw.Handler)
r.Get("/v1/nodes", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("nodes"))
})
srv := httptest.NewServer(r)
defer srv.Close()
// Through-the-stack request with a non-CDN client IP (httptest loopback)
// must be rejected even with the correct header.
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/v1/nodes", nil)
req.Header.Set(DefaultHeader, "secret-current")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("loopback (non-CDN) should be 403, got %d", resp.StatusCode)
}
}