From 76467efc59133eb6777130177bccf2ccebff7f4b Mon Sep 17 00:00:00 2001 From: ginuerzh Date: Thu, 27 Aug 2026 20:16:34 +0800 Subject: [PATCH] test(e2e): regression for Gost-Target destination-policy bypass Adds TestGostTargetPolicyBypass: a service-level bypass blacklists the echo server; verifies the control request is 403, a checksum-valid Gost-Target header naming an allowed authority cannot reach the blocked backend, and a malformed header is fail-closed. Verified to catch the bug by reverting the fix (exploit returned the backend response). --- tests/e2e/http_policy_bypass_test.go | 108 ++++++++++++++++++ .../testdata/http/server_policy_bypass.yaml | 18 +++ 2 files changed, 126 insertions(+) create mode 100644 tests/e2e/http_policy_bypass_test.go create mode 100644 tests/e2e/testdata/http/server_policy_bypass.yaml diff --git a/tests/e2e/http_policy_bypass_test.go b/tests/e2e/http_policy_bypass_test.go new file mode 100644 index 0000000..0341f48 --- /dev/null +++ b/tests/e2e/http_policy_bypass_test.go @@ -0,0 +1,108 @@ +package e2e + +import ( + "context" + "encoding/base64" + "encoding/binary" + "fmt" + "hash/crc32" + "io" + "os" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/testcontainers/testcontainers-go" +) + +// HTTPPolicyBypassSuite reproduces the destination-policy bypass where a valid +// Gost-Target header replaces req.Host (evaluated by the bypass) but, before the +// fix, left req.URL.Host (dialed by the transport) as the absolute-URL host. +// The echo server stands in for the protected backend. +type HTTPPolicyBypassSuite struct { + suite.Suite + ctx context.Context + echoC testcontainers.Container + echoIP string +} + +func (s *HTTPPolicyBypassSuite) SetupSuite() { + s.ctx = context.Background() + + echoC, err := RunEchoContainer(s.ctx, SharedNetworkName) + s.Require().NoError(err) + s.echoC = echoC + + echoIP, err := echoC.ContainerIP(s.ctx) + s.Require().NoError(err) + s.echoIP = echoIP +} + +func (s *HTTPPolicyBypassSuite) TearDownSuite() { + if s.echoC != nil { + s.echoC.Terminate(s.ctx) + } +} + +// sendRaw sends a raw HTTP request to the proxy inside the gost container. +func (s *HTTPPolicyBypassSuite) sendRaw(gostC testcontainers.Container, data string) string { + encoded := base64.StdEncoding.EncodeToString([]byte(data)) + cmd := []string{"sh", "-c", + fmt.Sprintf("echo %s | base64 -d | nc -w 5 127.0.0.1 8080", encoded)} + _, out, _ := gostC.Exec(s.ctx, cmd) + b, _ := io.ReadAll(out) + return string(b) +} + +// encodeTarget encodes a host:port in the GOST v2 Gost-Target format: +// raw-URL-base64( big-endian-CRC32(name) + raw-URL-base64(name) ). +func encodeTarget(name string) string { + v := []byte(name) + b := make([]byte, 4) + binary.BigEndian.PutUint32(b, crc32.ChecksumIEEE(v)) + inner := base64.RawURLEncoding.EncodeToString(v) + return base64.RawURLEncoding.EncodeToString(append(b, []byte(inner)...)) +} + +// TestGostTargetPolicyBypass asserts that a Gost-Target header naming an +// allowed authority cannot route a request to a blocked absolute-URL authority. +func (s *HTTPPolicyBypassSuite) TestGostTargetPolicyBypass() { + cfg, err := RenderConfig("testdata/http/server_policy_bypass.yaml", ConfigData{ServerAddr: s.echoIP}) + s.Require().NoError(err) + defer os.Remove(cfg) + + gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName, cfg, "8080/tcp") + s.Require().NoError(err) + defer gostC.Terminate(s.ctx) + + target := fmt.Sprintf("http://%s:5678/", s.echoIP) + marker := "hello-gost" + + // Control: absolute-form request to the blocked address, no target header. + // The bypass must deny it before it reaches the echo server. + control := fmt.Sprintf("GET %s HTTP/1.1\r\nHost: %s:5678\r\nConnection: close\r\n\r\n", + target, s.echoIP) + out := s.sendRaw(gostC, control) + s.Require().Contains(out, "403", "control request to blocked address should be denied") + s.Require().NotContains(out, marker, "control request must not reach the backend") + + // Exploit: same request plus a valid Gost-Target header naming an allowed + // authority. Before the fix the transport dials the URL host (echo server) + // while the policy checks the header host, leaking the backend response. + exploit := fmt.Sprintf("GET %s HTTP/1.1\r\nHost: %s:5678\r\nGost-Target: %s\r\nConnection: close\r\n\r\n", + target, s.echoIP, encodeTarget("allowed.example.com:80")) + out = s.sendRaw(gostC, exploit) + s.Require().NotContains(out, marker, + "Gost-Target header must not bypass the destination policy to reach the blocked backend") + + // Fail-closed control: a malformed target header is ignored, so the request + // is still evaluated (and denied) against the absolute-URL authority. + malformed := fmt.Sprintf("GET %s HTTP/1.1\r\nHost: %s:5678\r\nGost-Target: %s\r\nConnection: close\r\n\r\n", + target, s.echoIP, "not-a-valid-target") + out = s.sendRaw(gostC, malformed) + s.Require().Contains(out, "403", "malformed target header must be ignored and the request denied") + s.Require().NotContains(out, marker, "malformed target header must not reach the backend") +} + +func TestHTTPPolicyBypassSuite(t *testing.T) { + suite.Run(t, new(HTTPPolicyBypassSuite)) +} diff --git a/tests/e2e/testdata/http/server_policy_bypass.yaml b/tests/e2e/testdata/http/server_policy_bypass.yaml new file mode 100644 index 0000000..cfa57c6 --- /dev/null +++ b/tests/e2e/testdata/http/server_policy_bypass.yaml @@ -0,0 +1,18 @@ +# Service-level destination bypass (blacklist) on an HTTP forward proxy. +# Blocks the echo server's address ({{.ServerAddr}}). Used by +# TestHTTPProxyGostTargetPolicyBypass to verify that a Gost-Target header +# cannot separate the authority evaluated by the policy from the authority the +# transport dials. +bypasses: + - name: bypass-0 + matchers: + - "{{.ServerAddr}}" + +services: + - name: http-policy + addr: ":8080" + handler: + type: http + listener: + type: tcp + bypass: bypass-0