mirror of
https://github.com/go-gost/gost.git
synced 2026-10-08 03:55:45 +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