merge: maestro/tsk_NU9JuUweHWMt [tsk_NU9JuUweHWMt]
This commit is contained in:
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user