mirror of
https://github.com/go-gost/gost.git
synced 2026-10-08 12:05:46 +00:00
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.
This commit is contained in:
@@ -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))
|
||||
}
|
||||
+27
@@ -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
|
||||
Reference in New Issue
Block a user