Export the reviewed application-neutral package set through the exact public allowlist. Development history and private application evidence remain outside this canonical source root. Developed with material AI assistance under maintainer review. Signed-off-by: Cole Speelman <crspeelman@gmail.com>
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package requestmeta
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func FuzzForwardedChain(f *testing.F) {
|
||||
f.Add("203.0.113.1, 127.0.0.2")
|
||||
f.Add("not-an-address")
|
||||
f.Fuzz(func(t *testing.T, forwarded string) {
|
||||
if len(forwarded) > 8192 {
|
||||
t.Skip()
|
||||
}
|
||||
resolver, _ := New(Config{TrustedProxies: []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}, Random: zeroReader{}})
|
||||
request := httptest.NewRequest(http.MethodGet, "http://example.test/", nil)
|
||||
request.RemoteAddr = "127.0.0.1:1234"
|
||||
request.Header.Set("X-Forwarded-For", forwarded)
|
||||
metadata, err := resolver.Resolve(request)
|
||||
if err == nil && !metadata.ClientIP.IsValid() {
|
||||
t.Fatal("successful resolution returned invalid client")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type zeroReader struct{}
|
||||
|
||||
func (zeroReader) Read(body []byte) (int, error) { clear(body); return len(body), nil }
|
||||
@@ -0,0 +1,224 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
// Package requestmeta resolves bounded request identity and reverse-proxy
|
||||
// metadata once so security, logging, and application middleware agree.
|
||||
package requestmeta
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const maxForwardedHeader = 4096
|
||||
|
||||
var ErrInvalidForwarding = errors.New("requestmeta: invalid trusted forwarding metadata")
|
||||
|
||||
type contextKey struct{}
|
||||
|
||||
// Metadata is the normalized request identity shared by downstream packages.
|
||||
type Metadata struct {
|
||||
RequestID string
|
||||
ClientIP netip.Addr
|
||||
ClientIPSource string
|
||||
ProxyPeerIP netip.Addr
|
||||
ForwardedFor string
|
||||
Scheme string
|
||||
Host string
|
||||
}
|
||||
|
||||
// Config chooses the only peers whose forwarding metadata may affect identity.
|
||||
type Config struct {
|
||||
TrustedProxies []netip.Prefix
|
||||
Random io.Reader
|
||||
RequestIDBytes int
|
||||
}
|
||||
|
||||
// Resolver is immutable and safe for concurrent use when Random is.
|
||||
type Resolver struct {
|
||||
trusted []netip.Prefix
|
||||
random io.Reader
|
||||
idBytes int
|
||||
}
|
||||
|
||||
func New(config Config) (*Resolver, error) {
|
||||
if config.RequestIDBytes == 0 {
|
||||
config.RequestIDBytes = 16
|
||||
}
|
||||
if config.RequestIDBytes < 12 || config.RequestIDBytes > 32 {
|
||||
return nil, errors.New("requestmeta: request ID entropy must be 12 to 32 bytes")
|
||||
}
|
||||
if config.Random == nil {
|
||||
config.Random = rand.Reader
|
||||
}
|
||||
trusted := make([]netip.Prefix, len(config.TrustedProxies))
|
||||
for i, prefix := range config.TrustedProxies {
|
||||
if !prefix.IsValid() {
|
||||
return nil, fmt.Errorf("requestmeta: trusted proxy %d is invalid", i)
|
||||
}
|
||||
trusted[i] = prefix.Masked()
|
||||
}
|
||||
return &Resolver{trusted: trusted, random: config.Random, idBytes: config.RequestIDBytes}, nil
|
||||
}
|
||||
|
||||
// Middleware fails closed before application code when entropy or trusted
|
||||
// forwarding metadata is invalid.
|
||||
func (resolver *Resolver) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
metadata, err := resolver.Resolve(request)
|
||||
if err != nil {
|
||||
response.Header().Set("Cache-Control", "no-store")
|
||||
status := http.StatusServiceUnavailable
|
||||
message := "request metadata unavailable"
|
||||
if errors.Is(err, ErrInvalidForwarding) {
|
||||
status = http.StatusBadRequest
|
||||
message = "invalid forwarding metadata"
|
||||
}
|
||||
http.Error(response, message, status)
|
||||
return
|
||||
}
|
||||
response.Header().Set("X-Request-ID", metadata.RequestID)
|
||||
next.ServeHTTP(response, request.WithContext(context.WithValue(request.Context(), contextKey{}, metadata)))
|
||||
})
|
||||
}
|
||||
|
||||
// FromContext returns metadata installed by Middleware.
|
||||
func FromContext(ctx context.Context) (Metadata, bool) {
|
||||
metadata, ok := ctx.Value(contextKey{}).(Metadata)
|
||||
return metadata, ok
|
||||
}
|
||||
|
||||
func (resolver *Resolver) Resolve(request *http.Request) (Metadata, error) {
|
||||
requestID, err := resolver.requestID()
|
||||
if err != nil {
|
||||
return Metadata{}, fmt.Errorf("requestmeta: generate request ID: %w", err)
|
||||
}
|
||||
peer, err := peerAddress(request.RemoteAddr)
|
||||
if err != nil {
|
||||
return Metadata{}, err
|
||||
}
|
||||
if !validAuthority(request.Host) {
|
||||
return Metadata{}, ErrInvalidForwarding
|
||||
}
|
||||
metadata := Metadata{RequestID: requestID, ClientIP: peer, ClientIPSource: "peer", ProxyPeerIP: peer, Host: request.Host, Scheme: "http"}
|
||||
if request.TLS != nil {
|
||||
metadata.Scheme = "https"
|
||||
}
|
||||
if !resolver.isTrusted(peer) {
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
forwardedFor := strings.Join(request.Header.Values("X-Forwarded-For"), ",")
|
||||
forwardedProto, protoOK := uniqueHeader(request.Header, "X-Forwarded-Proto")
|
||||
forwardedHost, hostOK := uniqueHeader(request.Header, "X-Forwarded-Host")
|
||||
if !protoOK || !hostOK {
|
||||
return Metadata{}, ErrInvalidForwarding
|
||||
}
|
||||
if len(forwardedFor) > maxForwardedHeader || len(forwardedProto) > 64 || len(forwardedHost) > 1024 {
|
||||
return Metadata{}, ErrInvalidForwarding
|
||||
}
|
||||
if forwardedFor != "" {
|
||||
chain, err := parseForwardedFor(forwardedFor)
|
||||
if err != nil {
|
||||
return Metadata{}, err
|
||||
}
|
||||
candidate := peer
|
||||
for index := len(chain) - 1; index >= 0 && resolver.isTrusted(candidate); index-- {
|
||||
candidate = chain[index]
|
||||
}
|
||||
metadata.ClientIP = candidate
|
||||
metadata.ClientIPSource = "forwarded"
|
||||
metadata.ForwardedFor = forwardedFor
|
||||
}
|
||||
if forwardedProto != "" {
|
||||
value, ok := singleForwardedValue(forwardedProto)
|
||||
if !ok || (value != "http" && value != "https") {
|
||||
return Metadata{}, ErrInvalidForwarding
|
||||
}
|
||||
metadata.Scheme = value
|
||||
}
|
||||
if forwardedHost != "" {
|
||||
value, ok := singleForwardedValue(forwardedHost)
|
||||
if !ok || !validAuthority(value) {
|
||||
return Metadata{}, ErrInvalidForwarding
|
||||
}
|
||||
metadata.Host = value
|
||||
}
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func (resolver *Resolver) requestID() (string, error) {
|
||||
value := make([]byte, resolver.idBytes)
|
||||
if _, err := io.ReadFull(resolver.random, value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(value), nil
|
||||
}
|
||||
|
||||
func (resolver *Resolver) isTrusted(address netip.Addr) bool {
|
||||
for _, prefix := range resolver.trusted {
|
||||
if prefix.Contains(address) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func peerAddress(remote string) (netip.Addr, error) {
|
||||
host, _, err := net.SplitHostPort(remote)
|
||||
if err != nil {
|
||||
return netip.Addr{}, errors.New("requestmeta: remote address must contain host and port")
|
||||
}
|
||||
address, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
return netip.Addr{}, errors.New("requestmeta: remote address is invalid")
|
||||
}
|
||||
return address.Unmap(), nil
|
||||
}
|
||||
|
||||
func parseForwardedFor(value string) ([]netip.Addr, error) {
|
||||
parts := strings.Split(value, ",")
|
||||
if len(parts) == 0 || len(parts) > 32 {
|
||||
return nil, ErrInvalidForwarding
|
||||
}
|
||||
result := make([]netip.Addr, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
address, err := netip.ParseAddr(strings.TrimSpace(part))
|
||||
if err != nil {
|
||||
return nil, ErrInvalidForwarding
|
||||
}
|
||||
result = append(result, address.Unmap())
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func singleForwardedValue(value string) (string, bool) {
|
||||
parts := strings.Split(value, ",")
|
||||
if len(parts) != 1 {
|
||||
return "", false
|
||||
}
|
||||
trimmed := strings.ToLower(strings.TrimSpace(parts[0]))
|
||||
return trimmed, trimmed != ""
|
||||
}
|
||||
|
||||
func uniqueHeader(header http.Header, name string) (string, bool) {
|
||||
values := header.Values(name)
|
||||
if len(values) > 1 {
|
||||
return "", false
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return "", true
|
||||
}
|
||||
return values[0], true
|
||||
}
|
||||
|
||||
func validAuthority(value string) bool {
|
||||
return value != "" && len(value) <= 1024 && !strings.ContainsAny(value, "\\/\x00\r\n\t ,")
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package requestmeta
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolverUsesRightmostUntrustedHop(t *testing.T) {
|
||||
resolver, err := New(Config{TrustedProxies: []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8"), netip.MustParsePrefix("10.0.0.0/8")}, Random: strings.NewReader(strings.Repeat("a", 16))})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "http://example.test/", nil)
|
||||
request.RemoteAddr = "127.0.0.1:1234"
|
||||
request.Header.Set("X-Forwarded-For", "198.51.100.20, 203.0.113.9, 10.0.0.4")
|
||||
request.Header.Set("X-Forwarded-Proto", "https")
|
||||
metadata, err := resolver.Resolve(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := metadata.ClientIP.String(); got != "203.0.113.9" {
|
||||
t.Fatalf("client=%s", got)
|
||||
}
|
||||
if metadata.Scheme != "https" || metadata.ClientIPSource != "forwarded" {
|
||||
t.Fatalf("metadata=%+v", metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverIgnoresUntrustedForwarding(t *testing.T) {
|
||||
resolver, _ := New(Config{TrustedProxies: []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}, Random: strings.NewReader(strings.Repeat("b", 16))})
|
||||
request := httptest.NewRequest(http.MethodGet, "http://example.test/", nil)
|
||||
request.RemoteAddr = "198.51.100.4:1234"
|
||||
request.Header.Set("X-Forwarded-For", "203.0.113.9")
|
||||
metadata, err := resolver.Resolve(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := metadata.ClientIP.String(); got != "198.51.100.4" || metadata.ClientIPSource != "peer" {
|
||||
t.Fatalf("metadata=%+v", metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverRejectsMalformedTrustedForwarding(t *testing.T) {
|
||||
resolver, _ := New(Config{TrustedProxies: []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}, Random: strings.NewReader(strings.Repeat("c", 16))})
|
||||
request := httptest.NewRequest(http.MethodGet, "http://example.test/", nil)
|
||||
request.RemoteAddr = "127.0.0.1:1234"
|
||||
request.Header.Set("X-Forwarded-For", "not-an-address")
|
||||
if _, err := resolver.Resolve(request); !errors.Is(err, ErrInvalidForwarding) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverRejectsAmbiguousTrustedForwarding(t *testing.T) {
|
||||
resolver, _ := New(Config{TrustedProxies: []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}, Random: strings.NewReader(strings.Repeat("d", 16))})
|
||||
request := httptest.NewRequest(http.MethodGet, "http://example.test/", nil)
|
||||
request.RemoteAddr = "127.0.0.1:1234"
|
||||
request.Header["X-Forwarded-Proto"] = []string{"https", "http"}
|
||||
if _, err := resolver.Resolve(request); !errors.Is(err, ErrInvalidForwarding) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareReportsBadForwardingAsBadRequest(t *testing.T) {
|
||||
resolver, _ := New(Config{TrustedProxies: []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}, Random: strings.NewReader(strings.Repeat("e", 16))})
|
||||
handler := resolver.Middleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("handler ran") }))
|
||||
request := httptest.NewRequest(http.MethodGet, "http://example.test/", nil)
|
||||
request.RemoteAddr = "127.0.0.1:1234"
|
||||
request.Header.Set("X-Forwarded-For", "invalid")
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusBadRequest || response.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("status=%d cache=%q", response.Code, response.Header().Get("Cache-Control"))
|
||||
}
|
||||
}
|
||||
|
||||
type failingReader struct{}
|
||||
|
||||
func (failingReader) Read([]byte) (int, error) { return 0, errors.New("entropy unavailable") }
|
||||
|
||||
func TestMiddlewareFailsClosedOnEntropyError(t *testing.T) {
|
||||
resolver, _ := New(Config{Random: failingReader{}})
|
||||
called := false
|
||||
handler := resolver.Middleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }))
|
||||
response := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "http://example.test/", nil)
|
||||
request.RemoteAddr = "192.0.2.1:1234"
|
||||
handler.ServeHTTP(response, request)
|
||||
if called || response.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("called=%v status=%d", called, response.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user