mirror of
https://github.com/hanzoai/ingress.git
synced 2026-10-26 02:47:15 +00:00
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.
660 lines
19 KiB
Go
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)
|
|
})
|
|
}
|
|
}
|