Files
privatae-bastion/client-go/egress_one_request_test.go
Privatae 4012da0b75
ci / personal-data (push) Successful in 10s
ci / test (push) Successful in 5m30s
ci / fuzz (push) Successful in 3m16s
bastion 0.56.0
2026-10-28 02:24:07 +00:00

394 lines
14 KiB
Go

//go:build !windows
package main
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"strings"
"sync"
"testing"
"time"
)
// The bastion's egress lanes carry ONE plain-HTTP request per connection, read
// the way the Host's egress gate reads it, and every refusal says
// why in its body.
//
// MUTATIONS, each red:
// - handleEgressConn writes head.String() to upstream and splices, as before:
// TestEgressPlainHTTPCarriesOneRequestPerConnection, ...SendsTheHostItChecked;
// - oneEgressExchange's watcher forwards instead of closing: ...OneRequest...;
// - parseEgressAuthority accepts "@" or cuts the authority at "/" only:
// TestEgressPlainHTTPReadsTheHostAsTheGateDoes;
// - writeEgressRefusal writes Content-Length: 0: TestEgressRefusalsCarryABody.
// recordingOrigin stands in for a destination that keeps a connection open
// after answering (it ignores Connection: close, as a careless server may),
// and records every byte one connection carried to it.
type recordingOrigin struct {
ln net.Listener
conns chan string
// silent: never answer, so the lane never holds an unread answer when it
// closes (a close with unread data is a reset, which can cost the origin
// bytes it has not read yet).
silent bool
}
func newSilentOrigin(t *testing.T) *recordingOrigin {
o := newRecordingOrigin(t)
o.silent = true
return o
}
func newRecordingOrigin(t *testing.T) *recordingOrigin {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
o := &recordingOrigin{ln: ln, conns: make(chan string, 16)}
t.Cleanup(func() { _ = ln.Close() })
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go o.serve(c)
}
}()
return o
}
func (o *recordingOrigin) serve(c net.Conn) {
defer c.Close()
var mu sync.Mutex
var got bytes.Buffer
answered := false
buf := make([]byte, 4096)
for {
_ = c.SetReadDeadline(time.Now().Add(800 * time.Millisecond))
n, err := c.Read(buf)
mu.Lock()
got.Write(buf[:n])
if !o.silent && !answered && bytes.Contains(got.Bytes(), []byte("\r\n\r\n")) {
answered = true
_, _ = io.WriteString(c, "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
}
mu.Unlock()
if err != nil {
break
}
}
o.conns <- got.String()
}
// next is everything the origin's next connection carried, once it ended.
func (o *recordingOrigin) next(t *testing.T) string {
t.Helper()
s, ok := o.nextWithin(8 * time.Second)
if !ok {
t.Fatal("the destination never saw its connection end")
}
return s
}
func (o *recordingOrigin) nextWithin(d time.Duration) (string, bool) {
select {
case s := <-o.conns:
return s, true
case <-time.After(d):
return "", false
}
}
func (o *recordingOrigin) addr() string { return o.ln.Addr().String() }
// A keep-alive connection carries one request to the destination, never two:
// neither a request sent after the first answer (a client reusing the
// connection) nor one pipelined behind the first.
func TestEgressPlainHTTPCarriesOneRequestPerConnection(t *testing.T) {
first := "GET http://198.51.100.30/first HTTP/1.1\r\nHost: 198.51.100.30\r\n\r\n"
second := "GET http://198.51.100.30/second HTTP/1.1\r\nHost: 198.51.100.30\r\n\r\n"
t.Run("reused after the answer", func(t *testing.T) {
o := newRecordingOrigin(t)
var asked []string
cli, cleanup := dialHandler(t, redirectDial(o.addr(), &asked))
defer cleanup()
_, _ = io.WriteString(cli, first)
br := bufio.NewReader(cli)
resp, err := http.ReadResponse(br, nil)
if err != nil {
t.Fatalf("the first request got no answer: %v", err)
}
body, _ := io.ReadAll(resp.Body)
if string(body) != "ok" {
t.Fatalf("the first answer was %q", body)
}
_, _ = io.WriteString(cli, second)
got := o.next(t)
if strings.Contains(got, "/second") {
t.Fatalf("a second request on one connection reached the destination unvetted:\n%s", got)
}
if !strings.Contains(got, "\r\nConnection: close\r\n") {
t.Fatalf("the request went out without Connection: close:\n%s", got)
}
_ = cli.SetReadDeadline(time.Now().Add(3 * time.Second))
if rest, _ := io.ReadAll(br); len(rest) != 0 {
t.Fatalf("the lane answered the second request: %q", rest)
}
if len(asked) != 1 {
t.Fatalf("one connection, one dial: %v", asked)
}
})
t.Run("pipelined", func(t *testing.T) {
o := newRecordingOrigin(t)
var asked []string
cli, cleanup := dialHandler(t, redirectDial(o.addr(), &asked))
defer cleanup()
_, _ = io.WriteString(cli, first+second)
got := o.next(t)
if !strings.Contains(got, "GET http://198.51.100.30/first HTTP/1.1\r\n") {
t.Fatalf("the first request did not reach the destination:\n%s", got)
}
if strings.Contains(got, "/second") {
t.Fatalf("a pipelined second request reached the destination unvetted:\n%s", got)
}
})
}
// The destination reads the host the lane vetted, in the one Host header; hop
// headers, those Connection names and a proxy credential stay on this hop.
func TestEgressPlainHTTPSendsTheHostItChecked(t *testing.T) {
o := newRecordingOrigin(t)
var asked []string
cli, cleanup := dialHandler(t, redirectDial(o.addr(), &asked))
defer cleanup()
_, _ = io.WriteString(cli, "GET http://198.51.100.31/p?q=1 HTTP/1.1\r\n"+
"Host: inside.example.test\r\nProxy-Authorization: Basic eDp5\r\n"+
"Connection: keep-alive, X-Hop\r\nX-Hop: 1\r\nX-Keep: 2\r\n\r\n")
got := o.next(t)
head, _, _ := strings.Cut(got, "\r\n\r\n")
want := "GET http://198.51.100.31/p?q=1 HTTP/1.1\r\nHost: 198.51.100.31\r\nX-Keep: 2\r\nConnection: close"
if head != want {
t.Fatalf("the destination got\n%q\nwant\n%q", head, want)
}
if len(asked) != 1 || asked[0] != "198.51.100.31:80" {
t.Fatalf("the lane must dial the address it vetted: %v", asked)
}
}
// stubEgressNames answers names without DNS and records each name exactly as
// the lane asked for it.
func stubEgressNames(t *testing.T, m map[string]string) *[]string {
t.Helper()
var mu sync.Mutex
var asked []string
old := egressResolve
t.Cleanup(func() { egressResolve = old })
egressResolve = func(_ context.Context, host string) ([]net.IPAddr, error) {
mu.Lock()
asked = append(asked, host)
mu.Unlock()
ip, ok := m[host]
if !ok {
return nil, errors.New("no such host")
}
return []net.IPAddr{{IP: net.ParseIP(ip)}}, nil
}
return &asked
}
// The host is read as the gate reads it: the authority ends at the first "/",
// "?" or "#", is lower-cased and loses one trailing dot; userinfo, an
// unbracketed IPv6 literal, a bad port, https in absolute form, two Host
// headers and an ambiguous body length are refused with 400 and a body, before
// anything is resolved or dialled.
func TestEgressPlainHTTPReadsTheHostAsTheGateDoes(t *testing.T) {
for _, c := range []struct {
req, wantName, wantLine string
}{
{"GET http://public.example.test?@10.0.0.1/ HTTP/1.1\r\n\r\n", "public.example.test",
"GET http://public.example.test/?@10.0.0.1/ HTTP/1.1"},
{"GET http://public.example.test#@10.0.0.1/ HTTP/1.1\r\n\r\n", "public.example.test",
"GET http://public.example.test/ HTTP/1.1"},
{"GET http://Public.Example.Test./x HTTP/1.1\r\n\r\n", "public.example.test",
"GET http://public.example.test/x HTTP/1.1"},
{"GET /x HTTP/1.1\r\nHost: Public.Example.Test:8080\r\n\r\n", "public.example.test",
"GET /x HTTP/1.1"},
} {
o := newRecordingOrigin(t)
names := stubEgressNames(t, map[string]string{"public.example.test": "198.51.100.40"})
var asked []string
cli, cleanup := dialHandler(t, redirectDial(o.addr(), &asked))
_, _ = io.WriteString(cli, c.req)
got, _ := o.nextWithin(4 * time.Second)
cleanup()
if len(*names) != 1 || (*names)[0] != c.wantName {
t.Errorf("%q: the lane resolved %q; want only %q", c.req, *names, c.wantName)
}
if line, _, _ := strings.Cut(got, "\r\n"); line != c.wantLine {
t.Errorf("%q: the destination got the request line %q; want %q", c.req, line, c.wantLine)
}
}
for _, req := range []string{
"GET http://user:pw@public.example.test/ HTTP/1.1\r\n\r\n",
"GET http://198.51.100.41:80@public.example.test/ HTTP/1.1\r\n\r\n",
"GET http://2001:db8::1/ HTTP/1.1\r\n\r\n",
"GET http://public.example.test:80x/ HTTP/1.1\r\n\r\n",
"GET https://public.example.test/ HTTP/1.1\r\n\r\n",
"GET / HTTP/1.1\r\nHost: public.example.test\r\nHost: 198.51.100.41\r\n\r\n",
"GET / HTTP/1.1\r\n\r\n",
"POST http://public.example.test/ HTTP/1.1\r\nContent-Length: 3\r\nTransfer-Encoding: chunked\r\n\r\n",
"POST http://public.example.test/ HTTP/1.1\r\nContent-Length: 3\r\nContent-Length: 4\r\n\r\n",
"POST http://public.example.test/ HTTP/1.1\r\nTransfer-Encoding: gzip, chunked\r\n\r\n",
"GET http://public.example.test/ HTTP/1.1\r\n X-Folded: 1\r\n\r\n",
"CONNECT user@public.example.test:443 HTTP/1.1\r\n\r\n",
"CONNECT 2001:db8::1:443 HTTP/1.1\r\n\r\n",
"CONNECT [198.51.100.41]:443 HTTP/1.1\r\n\r\n",
} {
names := stubEgressNames(t, map[string]string{"public.example.test": "198.51.100.40"})
var asked []string
cli, cleanup := dialHandler(t, redirectDial("127.0.0.1:9", &asked))
_, _ = io.WriteString(cli, req)
resp, err := http.ReadResponse(bufio.NewReader(cli), nil)
if err != nil {
t.Errorf("%q: no answer: %v", req, err)
cleanup()
continue
}
body, _ := io.ReadAll(resp.Body)
cleanup()
if resp.StatusCode != 400 || len(body) == 0 || int64(len(body)) != resp.ContentLength {
t.Errorf("%q: answered %d with body %q; want 400 with a body saying why", req, resp.StatusCode, body)
}
if len(*names) != 0 || len(asked) != 0 {
t.Errorf("%q: refused requests resolve and dial nothing; resolved %q, dialled %q", req, *names, asked)
}
}
}
// A request body goes out framed as its head says, chunked bodies re-encoded,
// and nothing after it.
func TestEgressPlainHTTPBodyIsFramed(t *testing.T) {
for _, c := range []struct{ req, wantBody string }{
{"POST http://198.51.100.42/u HTTP/1.1\r\nHost: 198.51.100.42\r\nContent-Length: 5\r\n\r\nhello" +
"GET http://198.51.100.42/smuggled HTTP/1.1\r\n\r\n", "hello"},
{"POST http://198.51.100.42/u HTTP/1.1\r\nHost: 198.51.100.42\r\nTransfer-Encoding: chunked\r\n\r\n" +
"5;ext=1\r\nhello\r\n0\r\nX-Trailer: 1\r\n\r\nGET /smuggled HTTP/1.1\r\n\r\n", "5\r\nhello\r\n0\r\n\r\n"},
} {
o := newSilentOrigin(t)
var asked []string
cli, cleanup := dialHandler(t, redirectDial(o.addr(), &asked))
_, _ = io.WriteString(cli, c.req)
got := o.next(t)
cleanup()
_, body, _ := strings.Cut(got, "\r\n\r\n")
if body != c.wantBody {
t.Errorf("the destination got the body %q; want %q", body, c.wantBody)
}
}
}
// A refusal says why in its body, not only in a header: a 403 from the vetting
// and a 502 from a dial that found no answer, on both request forms, and the
// coder-run proxy's refusals too.
func TestEgressRefusalsCarryABody(t *testing.T) {
failDial := func(ctx context.Context, network, addr string) (net.Conn, error) {
return nil, fmt.Errorf("no route")
}
okDial := func(ctx context.Context, network, addr string) (net.Conn, error) {
return nil, fmt.Errorf("must not dial %s", addr)
}
check := func(name string, resp *http.Response, err error, want int) {
t.Helper()
if err != nil {
t.Errorf("%s: no answer: %v", name, err)
return
}
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != want || len(body) < 10 || int64(len(body)) != resp.ContentLength {
t.Errorf("%s: answered %d with body %q (Content-Length %d); want %d with a short body saying why",
name, resp.StatusCode, body, resp.ContentLength, want)
}
}
for _, c := range []struct {
name string
dial egressDialer
req string
want int
}{
{"plain HTTP to this machine", okDial, "GET http://127.0.0.1:9/ HTTP/1.1\r\n\r\n", 403},
{"CONNECT to this machine", okDial, "CONNECT 127.0.0.1:9 HTTP/1.1\r\n\r\n", 403},
{"plain HTTP, no answer", failDial, "GET http://198.51.100.43/ HTTP/1.1\r\n\r\n", 502},
{"CONNECT, no answer", failDial, "CONNECT 198.51.100.43:443 HTTP/1.1\r\n\r\n", 502},
} {
cli, cleanup := dialHandler(t, c.dial)
_, _ = io.WriteString(cli, c.req)
resp, err := http.ReadResponse(bufio.NewReader(cli), nil)
check(c.name, resp, err, c.want)
cleanup()
}
oldDial := coderDialOut
t.Cleanup(func() { coderDialOut = oldDial })
coderDialOut = failDial
for _, c := range []struct {
name, req string
want int
}{
{"coder run to a private address", "GET http://10.0.0.1/ HTTP/1.1\r\nHost: 10.0.0.1\r\n\r\n", 403},
{"coder run, no answer", "GET http://93.184.216.34/ HTTP/1.1\r\nHost: 93.184.216.34\r\n\r\n", 502},
} {
boxEnd, runEnd := net.Pipe()
var refused int64
go handleCoderEgressConn(boxEnd, nil, &refused)
_ = runEnd.SetDeadline(time.Now().Add(5 * time.Second))
_, _ = io.WriteString(runEnd, c.req)
resp, err := http.ReadResponse(bufio.NewReader(runEnd), nil)
check(c.name, resp, err, c.want)
_ = runEnd.Close()
}
}
// The coder-run proxy carries one request per connection too: the run's own
// processes are the sender there.
func TestCoderEgressCarriesOneRequestPerConnection(t *testing.T) {
o := newRecordingOrigin(t)
oldDial := coderDialOut
t.Cleanup(func() { coderDialOut = oldDial })
coderDialOut = func(ctx context.Context, network, addr string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, o.addr())
}
boxEnd, runEnd := net.Pipe()
var refused int64
go handleCoderEgressConn(boxEnd, nil, &refused)
_ = runEnd.SetDeadline(time.Now().Add(8 * time.Second))
_, _ = io.WriteString(runEnd, "GET http://93.184.216.34/first HTTP/1.1\r\nHost: 93.184.216.34\r\n\r\n")
br := bufio.NewReader(runEnd)
resp, err := http.ReadResponse(br, nil)
if err != nil {
t.Fatalf("the first request got no answer: %v", err)
}
_, _ = io.ReadAll(resp.Body)
_, _ = io.WriteString(runEnd, "GET http://93.184.216.34/second HTTP/1.1\r\nHost: inside.example.test\r\n\r\n")
got := o.next(t)
_ = runEnd.Close()
if strings.Contains(got, "/second") || strings.Contains(got, "inside.example.test") {
t.Fatalf("a second request on one connection reached the destination unvetted:\n%s", got)
}
if !strings.Contains(got, "\r\nConnection: close\r\n") {
t.Fatalf("the request went out without Connection: close:\n%s", got)
}
}