From d778c031eb28559e0b5ebfe815032d6e538f3abd Mon Sep 17 00:00:00 2001 From: ginuerzh Date: Mon, 31 Aug 2026 22:35:03 +0800 Subject: [PATCH] test(e2e): transparent whitelist bypass + sniffing (gost#899) Reproduce the regression where a domain-only whitelist bypass on a red (transparent) proxy rejected every connection: the pre-sniffing bypass check ran against the bare destination IP, which is never in a domain whitelist, before SNI sniffing could match. A privileged container redirects outbound TCP to the red service; asserts an allowlisted SNI is forwarded (hello-gost) and a non-allowlisted SNI is rejected. --- tests/e2e/red_sniffing_whitelist_test.go | 139 ++++++++++++++++++ .../e2e/testdata/red_sniffing/whitelist.yaml | 27 ++++ 2 files changed, 166 insertions(+) create mode 100644 tests/e2e/red_sniffing_whitelist_test.go create mode 100644 tests/e2e/testdata/red_sniffing/whitelist.yaml diff --git a/tests/e2e/red_sniffing_whitelist_test.go b/tests/e2e/red_sniffing_whitelist_test.go new file mode 100644 index 0000000..a4dcb0f --- /dev/null +++ b/tests/e2e/red_sniffing_whitelist_test.go @@ -0,0 +1,139 @@ +package e2e + +import ( + "context" + "fmt" + "io" + "strings" + "testing" + + "github.com/moby/moby/client" + "github.com/stretchr/testify/suite" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +// RedSniffingWhitelistSuite reproduces go-gost/gost#899: whitelist bypass + +// sniffing on a transparent (red) proxy. +// +// Inside a single privileged container, iptables redirects all outbound TCP to +// the gost red service, so the handler sees the original destination IP via +// SO_ORIGINAL_DST. The whitelist contains only a domain (the docker network +// alias "https-echo"); a bare destination IP is never in it. On a broken build +// (go-gost/x@fe394a8, x >= v0.13.12) the pre-sniffing bypass check on the +// destination IP rejects every connection before SNI sniffing runs, making the +// domain whitelist dead. The fix skips that check in whitelist mode and lets +// the sniffer match the SNI host. This suite also asserts the security +// property the skip must not weaken: a non-whitelisted SNI is still rejected. +type RedSniffingWhitelistSuite struct { + suite.Suite + ctx context.Context + httpsEchoC testcontainers.Container + gostC testcontainers.Container +} + +// redirectRules are installed in the gost container before gost starts: +// loopback traffic (incl. Docker's embedded DNS) and gost's own marked sockets +// are exempt; every other TCP packet is redirected to the red service. +const redirectRules = ` +iptables -t nat -A OUTPUT -m addrtype --dst-type LOCAL -j RETURN +iptables -t nat -A OUTPUT -m mark --mark 114514 -j RETURN +iptables -t nat -A OUTPUT -p tcp -j REDIRECT --to-ports 12345 +` + +func (s *RedSniffingWhitelistSuite) SetupSuite() { + s.ctx = context.Background() + + echoC, err := RunHTTPSEchoContainer(s.ctx, SharedNetworkName) + s.Require().NoError(err) + s.httpsEchoC = echoC + + req := testcontainers.ContainerRequest{ + FromDockerfile: testcontainers.FromDockerfile{ + Context: ".", + Dockerfile: "Dockerfile", + Repo: "gost-e2e", + Tag: "latest", + KeepImage: true, + BuildOptionsModifier: func(opts *client.ImageBuildOptions) { + opts.NetworkMode = "host" + }, + }, + Networks: []string{SharedNetworkName}, + CapAdd: []string{"NET_ADMIN"}, + Files: []testcontainers.ContainerFile{ + {HostFilePath: GostBinPath, ContainerFilePath: "/bin/gost", FileMode: 0755}, + {HostFilePath: "testdata/red_sniffing/whitelist.yaml", ContainerFilePath: "/config.yaml", FileMode: 0644}, + }, + Cmd: []string{"sh", "-c", redirectRules + "\nexec /bin/gost -C /config.yaml"}, + WaitingFor: wait.ForLog("listening on"), + } + + gostC, err := testcontainers.GenericContainer(s.ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + s.Require().NoError(err) + s.gostC = gostC +} + +func (s *RedSniffingWhitelistSuite) TearDownSuite() { + if s.gostC != nil { + s.gostC.Terminate(s.ctx) + } + if s.httpsEchoC != nil { + s.httpsEchoC.Terminate(s.ctx) + } +} + +// TestWhitelistedDomainViaSNI is the gost#899 golden path: curl to a domain in +// the whitelist should be forwarded because the sniffer matches its SNI. The +// kernel redirects the outbound TCP to the red service, which must not reject +// the bare destination IP before sniffing. Retries tolerate startup timing. +func (s *RedSniffingWhitelistSuite) TestWhitelistedDomainViaSNI() { + s.T().Helper() + cmd := []string{"sh", "-c", ` +for i in $(seq 1 30); do + body=$(curl -ks --max-time 5 https://https-echo:8443/ 2>/dev/null) + echo "$body" | grep -q hello-gost && { echo "$body"; exit 0; } + sleep 1 +done +echo "$body"`} + _, out, err := s.gostC.Exec(s.ctx, cmd) + s.Require().NoError(err) + body, _ := io.ReadAll(out) + + if !strings.Contains(string(body), "hello-gost") { + DumpLogs(s.T(), s.ctx, "gost-red logs", s.gostC) + } + s.Require().Contains(string(body), "hello-gost", + "whitelisted domain must be forwarded via SNI, not rejected on the bare IP") +} + +// TestNonWhitelistedHostRejected verifies that skipping the pre-sniffing check +// in whitelist mode does not weaken the whitelist: a connection whose sniffed +// SNI is not allowlisted must still be rejected. curl connects to the same +// echo IP but with a different SNI, so only the SNI can drive the decision. +func (s *RedSniffingWhitelistSuite) TestNonWhitelistedHostRejected() { + s.T().Helper() + ip, err := s.httpsEchoC.ContainerIP(s.ctx) + s.Require().NoError(err) + + cmd := []string{"sh", "-c", fmt.Sprintf( + "curl -ks --resolve other.test:8443:%s --max-time 5 https://other.test:8443/ 2>&1", ip)} + code, _, err := s.gostC.Exec(s.ctx, cmd) + s.Require().NoError(err) + s.Require().NotZero(code, + "curl to a non-whitelisted SNI should fail; got exit %d", code) + + logs, err := s.gostC.Logs(s.ctx) + s.Require().NoError(err) + defer logs.Close() + logBody, _ := io.ReadAll(logs) + s.Require().Contains(string(logBody), "bypass:", + "gost should log a bypass decision for the non-whitelisted SNI") +} + +func TestRedSniffingWhitelistSuite(t *testing.T) { + suite.Run(t, new(RedSniffingWhitelistSuite)) +} \ No newline at end of file diff --git a/tests/e2e/testdata/red_sniffing/whitelist.yaml b/tests/e2e/testdata/red_sniffing/whitelist.yaml new file mode 100644 index 0000000..e0449ef --- /dev/null +++ b/tests/e2e/testdata/red_sniffing/whitelist.yaml @@ -0,0 +1,27 @@ +# gost#899: whitelist bypass + sniffing on a transparent (red) proxy. +# The whitelist contains only a domain ("https-echo", the docker network alias +# of the HTTPS echo container). A bare destination IP is never in it, so on a +# broken build the pre-sniffing IP bypass check rejects every connection +# before SNI sniffing can match the host. The fix skips that check in +# whitelist mode and lets the sniffer drive the decision from the SNI. +log: + level: debug +services: + - name: transparent-egress + addr: ":12345" + bypass: allowlist + metadata: + so_mark: 114514 + handler: + type: red + metadata: + sniffing: true + sniffing.timeout: 5s + sniffing.fallback: true + listener: + type: red +bypasses: + - name: allowlist + whitelist: true + matchers: + - https-echo \ No newline at end of file