bastion 0.56.0
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
//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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user