Files
zeekayandGitHub f02db2dc95 fix(cors): anchor origin regexes + gate credentials on origin-allow (#12)
Credentialed-CORS hardening. Two independent bugs in the CORS header
middleware let a credentialed response reach a foreign origin.

header.go:
- anchorOriginRegex wraps every AccessControlAllowOriginListRegex pattern in
  ^(?:…)$ so the shipped `https://([a-z0-9-]+\.)?hanzo\.ai` can no longer
  substring-match the attacker origin `https://hanzo.ai.evil.com`.
  regexp.MatchString is unanchored, so the pattern reflected any origin that
  merely prefixed the brand. The (?:…) group keeps top-level alternation
  intact; pre-existing anchors are stripped so the result never double-anchors.
- Access-Control-Allow-Credentials is now emitted ONLY alongside a matched
  Access-Control-Allow-Origin — on both the response path
  (PostRequestModifyResponseHeaders) and the preflight path
  (processCorsHeaders). It was set unconditionally, so ACAC:true could pair
  with a reflected/foreign origin set by another layer (backend or CDN) — the
  near-account-takeover class.

zap_backend.go (INGRESS_ZAP_BACKENDS inert by default; pre-enable hardening):
- Cap the buffered request body (io.LimitReader; default 64 MiB, override
  INGRESS_ZAP_MAX_BODY_BYTES). Unbounded io.ReadAll let one oversized request
  OOM the ingress.
- Honour req.Context() cancellation before dialing.
- Correct the false "PQ-secure" doc: v0.1.0 dials plaintext TCP; note the
  absent request-write deadline.

Tests: anchored-regex + credential-gating regressions (attacker suffix, null
origin, allowlisted subdomain) on both response and preflight paths; body-cap
and parseMaxBody unit tests.
2026-07-03 16:47:31 -07:00

660 lines
19 KiB
Go

package headers
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/hanzoai/ingress/pkg/config/dynamic"
)
func TestNewHeader_customRequestHeader(t *testing.T) {
testCases := []struct {
desc string
cfg dynamic.Headers
expected http.Header
}{
{
desc: "adds a header",
cfg: dynamic.Headers{
CustomRequestHeaders: map[string]string{
"X-Custom-Request-Header": "test_request",
},
},
expected: http.Header{"Foo": []string{"bar"}, "X-Custom-Request-Header": []string{"test_request"}},
},
{
desc: "delete a header",
cfg: dynamic.Headers{
CustomRequestHeaders: map[string]string{
"X-Forwarded-For": "",
"X-Custom-Request-Header": "",
"Foo": "",
},
},
expected: http.Header{
"X-Forwarded-For": nil,
},
},
{
desc: "override a header",
cfg: dynamic.Headers{
CustomRequestHeaders: map[string]string{
"Foo": "test",
},
},
expected: http.Header{"Foo": []string{"test"}},
},
}
emptyHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
mid, err := NewHeader(emptyHandler, test.cfg)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
req.Header.Set("Foo", "bar")
rw := httptest.NewRecorder()
mid.ServeHTTP(rw, req)
assert.Equal(t, http.StatusOK, rw.Code)
assert.Equal(t, test.expected, req.Header)
})
}
}
func TestNewHeader_customRequestHeader_Host(t *testing.T) {
testCases := []struct {
desc string
customHeaders map[string]string
expectedHost string
expectedURLHost string
}{
{
desc: "standard Host header",
customHeaders: map[string]string{},
expectedHost: "example.org",
expectedURLHost: "example.org",
},
{
desc: "custom Host header",
customHeaders: map[string]string{
"Host": "example.com",
},
expectedHost: "example.com",
expectedURLHost: "example.org",
},
}
emptyHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
mid, err := NewHeader(emptyHandler, dynamic.Headers{CustomRequestHeaders: test.customHeaders})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "http://example.org/foo", nil)
rw := httptest.NewRecorder()
mid.ServeHTTP(rw, req)
assert.Equal(t, http.StatusOK, rw.Code)
assert.Equal(t, test.expectedHost, req.Host)
assert.Equal(t, test.expectedURLHost, req.URL.Host)
})
}
}
func TestNewHeader_CORSPreflights(t *testing.T) {
testCases := []struct {
desc string
cfg dynamic.Headers
requestHeaders http.Header
expected http.Header
}{
{
desc: "Test Simple Preflight",
cfg: dynamic.Headers{
AccessControlAllowMethods: []string{"GET", "OPTIONS", "PUT"},
AccessControlAllowOriginList: []string{"https://foo.bar.org"},
AccessControlMaxAge: 600,
},
requestHeaders: map[string][]string{
"Access-Control-Request-Headers": {"origin"},
"Access-Control-Request-Method": {"GET", "OPTIONS"},
"Origin": {"https://foo.bar.org"},
},
expected: map[string][]string{
"Content-Length": {"0"},
"Access-Control-Allow-Origin": {"https://foo.bar.org"},
"Access-Control-Max-Age": {"600"},
"Access-Control-Allow-Methods": {"GET,OPTIONS,PUT"},
},
},
{
desc: "Wildcard origin Preflight",
cfg: dynamic.Headers{
AccessControlAllowMethods: []string{"GET", "OPTIONS", "PUT"},
AccessControlAllowOriginList: []string{"*"},
AccessControlMaxAge: 600,
},
requestHeaders: map[string][]string{
"Access-Control-Request-Headers": {"origin"},
"Access-Control-Request-Method": {"GET", "OPTIONS"},
"Origin": {"https://foo.bar.org"},
},
expected: map[string][]string{
"Content-Length": {"0"},
"Access-Control-Allow-Origin": {"*"},
"Access-Control-Max-Age": {"600"},
"Access-Control-Allow-Methods": {"GET,OPTIONS,PUT"},
},
},
{
desc: "Allow Credentials Preflight",
cfg: dynamic.Headers{
AccessControlAllowMethods: []string{"GET", "OPTIONS", "PUT"},
AccessControlAllowOriginList: []string{"*"},
AccessControlAllowCredentials: true,
AccessControlMaxAge: 600,
},
requestHeaders: map[string][]string{
"Access-Control-Request-Headers": {"origin"},
"Access-Control-Request-Method": {"GET", "OPTIONS"},
"Origin": {"https://foo.bar.org"},
},
expected: map[string][]string{
"Content-Length": {"0"},
"Access-Control-Allow-Origin": {"*"},
"Access-Control-Max-Age": {"600"},
"Access-Control-Allow-Methods": {"GET,OPTIONS,PUT"},
"Access-Control-Allow-Credentials": {"true"},
},
},
{
desc: "Allow Headers Preflight",
cfg: dynamic.Headers{
AccessControlAllowMethods: []string{"GET", "OPTIONS", "PUT"},
AccessControlAllowOriginList: []string{"*"},
AccessControlAllowHeaders: []string{"origin", "X-Forwarded-For"},
AccessControlMaxAge: 600,
},
requestHeaders: map[string][]string{
"Access-Control-Request-Headers": {"origin"},
"Access-Control-Request-Method": {"GET", "OPTIONS"},
"Origin": {"https://foo.bar.org"},
},
expected: map[string][]string{
"Content-Length": {"0"},
"Access-Control-Allow-Origin": {"*"},
"Access-Control-Max-Age": {"600"},
"Access-Control-Allow-Methods": {"GET,OPTIONS,PUT"},
"Access-Control-Allow-Headers": {"origin,X-Forwarded-For"},
},
},
{
desc: "No Request Headers Preflight",
cfg: dynamic.Headers{
AccessControlAllowMethods: []string{"GET", "OPTIONS", "PUT"},
AccessControlAllowOriginList: []string{"*"},
AccessControlAllowHeaders: []string{"origin", "X-Forwarded-For"},
AccessControlMaxAge: 600,
},
requestHeaders: map[string][]string{
"Access-Control-Request-Method": {"GET", "OPTIONS"},
"Origin": {"https://foo.bar.org"},
},
expected: map[string][]string{
"Content-Length": {"0"},
"Access-Control-Allow-Origin": {"*"},
"Access-Control-Max-Age": {"600"},
"Access-Control-Allow-Methods": {"GET,OPTIONS,PUT"},
"Access-Control-Allow-Headers": {"origin,X-Forwarded-For"},
},
},
}
emptyHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
mid, err := NewHeader(emptyHandler, test.cfg)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodOptions, "/foo", nil)
req.Header = test.requestHeaders
rw := httptest.NewRecorder()
mid.ServeHTTP(rw, req)
assert.Equal(t, test.expected, rw.Result().Header)
})
}
}
func TestNewHeader_CORSResponses(t *testing.T) {
emptyHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
testCases := []struct {
desc string
next http.Handler
cfg dynamic.Headers
requestHeaders http.Header
expected http.Header
expectedError bool
}{
{
desc: "Test Simple Request",
next: emptyHandler,
cfg: dynamic.Headers{
AccessControlAllowOriginList: []string{"https://foo.bar.org"},
},
requestHeaders: map[string][]string{
"Origin": {"https://foo.bar.org"},
},
expected: map[string][]string{
"Access-Control-Allow-Origin": {"https://foo.bar.org"},
},
},
{
desc: "Wildcard origin Request",
next: emptyHandler,
cfg: dynamic.Headers{
AccessControlAllowOriginList: []string{"*"},
},
requestHeaders: map[string][]string{
"Origin": {"https://foo.bar.org"},
},
expected: map[string][]string{
"Access-Control-Allow-Origin": {"*"},
},
},
{
desc: "Regexp Origin Request",
next: emptyHandler,
cfg: dynamic.Headers{
AccessControlAllowOriginListRegex: []string{"^https?://([a-z]+)\\.bar\\.org$"},
},
requestHeaders: map[string][]string{
"Origin": {"https://foo.bar.org"},
},
expected: map[string][]string{
"Access-Control-Allow-Origin": {"https://foo.bar.org"},
},
},
{
// An unanchored config pattern must NOT substring-match the origin.
// Before anchorOriginRegex, "([a-z]+)\.bar" matched *inside*
// "https://foo.bar.org" and reflected it — the credentialed-CORS
// hole. Every pattern is now wrapped in ^(?:…)$, so a partial
// pattern can never reflect a foreign origin.
desc: "Partial Regexp Origin Request is not substring-matched",
next: emptyHandler,
cfg: dynamic.Headers{
AccessControlAllowOriginListRegex: []string{"([a-z]+)\\.bar"},
},
requestHeaders: map[string][]string{
"Origin": {"https://foo.bar.org"},
},
expected: map[string][]string{},
},
{
// Regression for the live near-ATO: the ingress cors-allow-all
// pattern is the UNANCHORED "https://([a-z0-9-]+\.)?hanzo\.ai".
// Unanchored, it matches the attacker origin
// "https://hanzo.ai.evil.com" as a prefix and reflects it with
// credentials. Anchoring rejects it.
desc: "Credentialed CORS: attacker suffix origin hanzo.ai.evil.com rejected",
next: emptyHandler,
cfg: dynamic.Headers{
AccessControlAllowOriginListRegex: []string{"https://([a-z0-9-]+\\.)?hanzo\\.ai"},
AccessControlAllowCredentials: true,
},
requestHeaders: map[string][]string{
"Origin": {"https://hanzo.ai.evil.com"},
},
expected: map[string][]string{},
},
{
desc: "Credentialed CORS: null origin rejected (no reflect, no credentials)",
next: emptyHandler,
cfg: dynamic.Headers{
AccessControlAllowOriginListRegex: []string{"https://([a-z0-9-]+\\.)?hanzo\\.ai"},
AccessControlAllowCredentials: true,
},
requestHeaders: map[string][]string{
"Origin": {"null"},
},
expected: map[string][]string{},
},
{
// Credentials are emitted ONLY alongside a matched origin.
desc: "Credentialed CORS: allowlisted subdomain reflects origin + credentials",
next: emptyHandler,
cfg: dynamic.Headers{
AccessControlAllowOriginListRegex: []string{"https://([a-z0-9-]+\\.)?hanzo\\.ai"},
AccessControlAllowCredentials: true,
},
requestHeaders: map[string][]string{
"Origin": {"https://console.hanzo.ai"},
},
expected: map[string][]string{
"Access-Control-Allow-Origin": {"https://console.hanzo.ai"},
"Access-Control-Allow-Credentials": {"true"},
},
},
{
desc: "Regexp Malformed Origin Request",
next: emptyHandler,
cfg: dynamic.Headers{
AccessControlAllowOriginListRegex: []string{"a(b"},
},
requestHeaders: map[string][]string{
"Origin": {"https://foo.bar.org"},
},
expectedError: true,
},
{
desc: "Regexp Origin Request without matching",
next: emptyHandler,
cfg: dynamic.Headers{
AccessControlAllowOriginListRegex: []string{"([a-z]+)\\.bar\\.org"},
},
requestHeaders: map[string][]string{
"Origin": {"https://bar.org"},
},
expected: map[string][]string{},
},
{
desc: "Empty origin Request",
next: emptyHandler,
cfg: dynamic.Headers{
AccessControlAllowOriginList: []string{"https://foo.bar.org"},
},
requestHeaders: map[string][]string{},
expected: map[string][]string{},
},
{
desc: "Not Defined origin Request",
next: emptyHandler,
requestHeaders: map[string][]string{},
expected: map[string][]string{},
},
{
desc: "Allow Credentials Request",
next: emptyHandler,
cfg: dynamic.Headers{
AccessControlAllowOriginList: []string{"*"},
AccessControlAllowCredentials: true,
},
requestHeaders: map[string][]string{
"Origin": {"https://foo.bar.org"},
},
expected: map[string][]string{
"Access-Control-Allow-Origin": {"*"},
"Access-Control-Allow-Credentials": {"true"},
},
},
{
desc: "Expose Headers Request",
next: emptyHandler,
cfg: dynamic.Headers{
AccessControlAllowOriginList: []string{"*"},
AccessControlExposeHeaders: []string{"origin", "X-Forwarded-For"},
},
requestHeaders: map[string][]string{
"Origin": {"https://foo.bar.org"},
},
expected: map[string][]string{
"Access-Control-Allow-Origin": {"*"},
"Access-Control-Expose-Headers": {"origin,X-Forwarded-For"},
},
},
{
desc: "Test Simple Request with Vary Headers",
next: emptyHandler,
cfg: dynamic.Headers{
AccessControlAllowOriginList: []string{"https://foo.bar.org"},
AddVaryHeader: true,
},
requestHeaders: map[string][]string{
"Origin": {"https://foo.bar.org"},
},
expected: map[string][]string{
"Access-Control-Allow-Origin": {"https://foo.bar.org"},
"Vary": {"Origin"},
},
},
{
desc: "Test Simple Request with Vary Headers and non-empty response",
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// nonEmptyHandler
w.Header().Set("Vary", "Testing")
w.WriteHeader(http.StatusOK)
}),
cfg: dynamic.Headers{
AccessControlAllowOriginList: []string{"https://foo.bar.org"},
AddVaryHeader: true,
},
requestHeaders: map[string][]string{
"Origin": {"https://foo.bar.org"},
},
expected: map[string][]string{
"Access-Control-Allow-Origin": {"https://foo.bar.org"},
"Vary": {"Testing,Origin"},
},
},
{
desc: "Test Simple Request with Vary Headers and existing vary:origin response",
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// existingOriginHandler
w.Header().Set("Vary", "Origin")
w.WriteHeader(http.StatusOK)
}),
cfg: dynamic.Headers{
AccessControlAllowOriginList: []string{"https://foo.bar.org"},
AddVaryHeader: true,
},
requestHeaders: map[string][]string{
"Origin": {"https://foo.bar.org"},
},
expected: map[string][]string{
"Access-Control-Allow-Origin": {"https://foo.bar.org"},
"Vary": {"Origin"},
},
},
{
desc: "Test Simple Request with non-empty response: set ACAO",
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// existingAccessControlAllowOriginHandlerSet
w.Header().Set("Access-Control-Allow-Origin", "http://foo.bar.org")
w.WriteHeader(http.StatusOK)
}),
cfg: dynamic.Headers{
AccessControlAllowOriginList: []string{"*"},
},
requestHeaders: map[string][]string{
"Origin": {"https://foo.bar.org"},
},
expected: map[string][]string{
"Access-Control-Allow-Origin": {"*"},
},
},
{
desc: "Test Simple Request with non-empty response: add ACAO",
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// existingAccessControlAllowOriginHandlerAdd
w.Header().Add("Access-Control-Allow-Origin", "http://foo.bar.org")
w.WriteHeader(http.StatusOK)
}),
cfg: dynamic.Headers{
AccessControlAllowOriginList: []string{"*"},
},
requestHeaders: map[string][]string{
"Origin": {"https://foo.bar.org"},
},
expected: map[string][]string{
"Access-Control-Allow-Origin": {"*"},
},
},
{
desc: "Test Simple CustomRequestHeaders Not Hijacked by CORS",
next: emptyHandler,
cfg: dynamic.Headers{
CustomRequestHeaders: map[string]string{"foo": "bar"},
},
requestHeaders: map[string][]string{
"Access-Control-Request-Headers": {"origin"},
"Access-Control-Request-Method": {"GET", "OPTIONS"},
"Origin": {"https://foo.bar.org"},
},
expected: map[string][]string{},
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
mid, err := NewHeader(test.next, test.cfg)
if test.expectedError {
require.Error(t, err)
return
}
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
req.Header = test.requestHeaders
rw := httptest.NewRecorder()
mid.ServeHTTP(rw, req)
assert.Equal(t, test.expected, rw.Result().Header)
})
}
}
// TestCORSPreflightCredentialsGated proves the preflight (OPTIONS) path emits
// Access-Control-Allow-Origin and Access-Control-Allow-Credentials ONLY for an
// allowlisted origin — never for an attacker origin that suffixes an
// unanchored brand pattern, and never for the "null" origin.
func TestCORSPreflightCredentialsGated(t *testing.T) {
emptyHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
cfg := dynamic.Headers{
// The exact shape shipped by the ingress cors-allow-all middleware.
AccessControlAllowOriginListRegex: []string{"https://([a-z0-9-]+\\.)?hanzo\\.ai"},
AccessControlAllowCredentials: true,
AccessControlAllowMethods: []string{"GET", "POST", "OPTIONS"},
AccessControlMaxAge: 86400,
}
cases := []struct {
desc string
origin string
wantOrigin string // "" => header must be absent
wantCredsSet bool
}{
{"allowlisted apex", "https://hanzo.ai", "https://hanzo.ai", true},
{"allowlisted subdomain", "https://console.hanzo.ai", "https://console.hanzo.ai", true},
{"attacker suffix", "https://hanzo.ai.evil.com", "", false},
{"unrelated origin", "https://evil.example.com", "", false},
{"null origin", "null", "", false},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
mid, err := NewHeader(emptyHandler, cfg)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodOptions, "/foo", nil)
req.Header.Set("Origin", tc.origin)
req.Header.Set("Access-Control-Request-Method", "GET")
rw := httptest.NewRecorder()
mid.ServeHTTP(rw, req)
h := rw.Result().Header
assert.Equal(t, tc.wantOrigin, h.Get("Access-Control-Allow-Origin"),
"Access-Control-Allow-Origin for origin %q", tc.origin)
if tc.wantCredsSet {
assert.Equal(t, "true", h.Get("Access-Control-Allow-Credentials"),
"credentials must be advertised to an allowlisted origin")
} else {
assert.Empty(t, h.Get("Access-Control-Allow-Credentials"),
"credentials MUST NOT be advertised to a non-allowlisted origin %q", tc.origin)
}
})
}
}
func TestNewHeader_customResponseHeaders(t *testing.T) {
testCases := []struct {
desc string
config map[string]string
expected http.Header
}{
{
desc: "Test Simple Response",
config: map[string]string{
"Testing": "foo",
"Testing2": "bar",
},
expected: map[string][]string{
"Foo": {"bar"},
"Testing": {"foo"},
"Testing2": {"bar"},
},
},
{
desc: "empty Custom Header",
config: map[string]string{
"Testing": "foo",
"Testing2": "",
},
expected: map[string][]string{
"Foo": {"bar"},
"Testing": {"foo"},
},
},
{
desc: "Deleting Custom Header",
config: map[string]string{
"Testing": "foo",
"Foo": "",
},
expected: map[string][]string{
"Testing": {"foo"},
},
},
}
emptyHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Foo", "bar")
w.WriteHeader(http.StatusOK)
})
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
mid, err := NewHeader(emptyHandler, dynamic.Headers{CustomResponseHeaders: test.config})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
rw := httptest.NewRecorder()
mid.ServeHTTP(rw, req)
assert.Equal(t, test.expected, rw.Result().Header)
})
}
}