bastion 0.56.0
This commit is contained in:
@@ -0,0 +1,420 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http/httputil"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ONE plain-HTTP request per egress connection, read the way the Host's egress
|
||||
// gate reads it (its strict request parse and its authority parse: the same
|
||||
// refusals, the same normalised host, the same rebuilt head).
|
||||
//
|
||||
// Everything the lane writes to a destination is what it checked, and nothing
|
||||
// more:
|
||||
// - the head is parsed strictly and REBUILT, so the destination reads the same
|
||||
// host, port and body length the lane vetted: never a second reading of the
|
||||
// Host's bytes (a Host header naming another site, a query or userinfo glued
|
||||
// to the host, a padded Transfer-Encoding);
|
||||
// - the request goes out with `Connection: close`, its body is framed by
|
||||
// Content-Length or chunked encoding, and a byte the Host sends after the
|
||||
// body closes the connection with nothing of it forwarded. Before this the
|
||||
// lane wrote the first head and then spliced the whole connection, so every
|
||||
// later request on a keep-alive connection reached the destination unread.
|
||||
//
|
||||
// An honest client sends its next request only after the response, which the
|
||||
// destination closes behind `Connection: close`, so it opens a new connection
|
||||
// and meets the vetting again.
|
||||
|
||||
// egressHostChars are the characters a host name may have here (lower-cased).
|
||||
const egressHostChars = "abcdefghijklmnopqrstuvwxyz0123456789-._"
|
||||
|
||||
// egressTokenChars are RFC 9110 token characters: methods and header names.
|
||||
const egressTokenChars = "!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
// egressHopByHop are headers that belong to one hop. The lane speaks for itself
|
||||
// on the next one, and a proxy credential never goes on to the destination.
|
||||
var egressHopByHop = map[string]bool{
|
||||
"connection": true, "proxy-connection": true, "keep-alive": true, "upgrade": true,
|
||||
"te": true, "trailer": true, "proxy-authorization": true, "proxy-authenticate": true,
|
||||
}
|
||||
|
||||
// egressTrailerLimit bounds the trailer section of a chunked body, which is
|
||||
// read to its end and dropped (the gate's limit).
|
||||
const egressTrailerLimit = 8192
|
||||
|
||||
// egressRefusal is a request the lane refuses before it dials anything.
|
||||
type egressRefusal struct{ status, body string }
|
||||
|
||||
func egressBad(body string) *egressRefusal {
|
||||
return &egressRefusal{"400 Bad Request", body}
|
||||
}
|
||||
|
||||
const egressNoHost = "The bastion could not read where this request is going (no host in the " +
|
||||
"request line or in a Host header), so it sent nothing."
|
||||
|
||||
// egressUnreachable is the body of a 502: the destination passed the vetting,
|
||||
// and no address of it answered from this machine.
|
||||
const egressUnreachable = "The bastion could not reach that destination from this machine's own " +
|
||||
"connection, so nothing was sent."
|
||||
|
||||
// egressHostPort is host and port as the vetting takes them.
|
||||
func egressHostPort(host string, port int) string {
|
||||
return net.JoinHostPort(host, strconv.Itoa(port))
|
||||
}
|
||||
|
||||
// writeEgressRefusal answers the Host with a status and a short plain-text
|
||||
// body saying why, and closes the exchange. why, when set, also goes in
|
||||
// X-Privatae-Refused, the header the Host already reads.
|
||||
func writeEgressRefusal(w io.Writer, status, body, why string) {
|
||||
body += "\n"
|
||||
var b strings.Builder
|
||||
b.WriteString("HTTP/1.1 " + status + "\r\n")
|
||||
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
|
||||
b.WriteString("Content-Length: " + strconv.Itoa(len(body)) + "\r\n")
|
||||
b.WriteString("Connection: close\r\n")
|
||||
if why != "" {
|
||||
b.WriteString("X-Privatae-Refused: " + strings.NewReplacer("\r", " ", "\n", " ").Replace(why) + "\r\n")
|
||||
}
|
||||
b.WriteString("\r\n")
|
||||
b.WriteString(body)
|
||||
_, _ = io.WriteString(w, b.String())
|
||||
}
|
||||
|
||||
// parseEgressAuthority: (host, port, true) from "host", "host:port", "[v6]" or
|
||||
// "[v6]:port", or false when the lane can't read it exactly as the destination
|
||||
// would. Refused rather than guessed: userinfo ("a@b"), an unbracketed IPv6
|
||||
// literal, characters no host name has, a non-numeric or out-of-range port.
|
||||
// The host is lower-cased and loses one trailing dot.
|
||||
func parseEgressAuthority(s string, defaultPort int) (string, int, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" || strings.Contains(s, "@") {
|
||||
return "", 0, false
|
||||
}
|
||||
var host string
|
||||
port := defaultPort
|
||||
digits := func(p string) bool {
|
||||
if len(p) == 0 || len(p) > 5 {
|
||||
return false
|
||||
}
|
||||
for _, c := range p {
|
||||
if c < '0' || c > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(s, "[") {
|
||||
end := strings.IndexByte(s, ']')
|
||||
if end < 0 {
|
||||
return "", 0, false
|
||||
}
|
||||
// An IPv6 literal, IPv4-mapped forms included (the vetting judges the
|
||||
// address they map to); a bare IPv4 address in brackets or a zone is
|
||||
// not one.
|
||||
inner := s[1:end]
|
||||
ip := net.ParseIP(inner)
|
||||
if ip == nil || !strings.Contains(inner, ":") || strings.Contains(inner, "%") {
|
||||
return "", 0, false
|
||||
}
|
||||
host = ip.String()
|
||||
rest := s[end+1:]
|
||||
switch {
|
||||
case rest == "":
|
||||
case strings.HasPrefix(rest, ":") && digits(rest[1:]):
|
||||
port, _ = strconv.Atoi(rest[1:])
|
||||
default:
|
||||
return "", 0, false
|
||||
}
|
||||
} else {
|
||||
if strings.Count(s, ":") > 1 {
|
||||
return "", 0, false
|
||||
}
|
||||
h, p, hasPort := strings.Cut(s, ":")
|
||||
if hasPort {
|
||||
if !digits(p) {
|
||||
return "", 0, false
|
||||
}
|
||||
port, _ = strconv.Atoi(p)
|
||||
}
|
||||
h = strings.ToLower(h)
|
||||
h = strings.TrimSuffix(h, ".")
|
||||
if h == "" || strings.HasPrefix(h, ".") || strings.Contains(h, "..") {
|
||||
return "", 0, false
|
||||
}
|
||||
for _, c := range h {
|
||||
if !strings.ContainsRune(egressHostChars, c) {
|
||||
return "", 0, false
|
||||
}
|
||||
}
|
||||
host = h
|
||||
}
|
||||
if port <= 0 || port >= 65536 {
|
||||
return "", 0, false
|
||||
}
|
||||
return host, port, true
|
||||
}
|
||||
|
||||
// fmtEgressAuthority writes host[:port] back, the port left out when it is the
|
||||
// scheme's default.
|
||||
func fmtEgressAuthority(host string, port, defaultPort int) string {
|
||||
h := host
|
||||
if strings.Contains(host, ":") {
|
||||
h = "[" + host + "]"
|
||||
}
|
||||
if port == defaultPort {
|
||||
return h
|
||||
}
|
||||
return h + ":" + strconv.Itoa(port)
|
||||
}
|
||||
|
||||
// egressRequest is the one plain-HTTP request the lane forwards.
|
||||
type egressRequest struct {
|
||||
host string
|
||||
port int
|
||||
head []byte // rebuilt, CRLF, ends at its blank line
|
||||
chunked bool
|
||||
length int64
|
||||
}
|
||||
|
||||
func egressTokenOK(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
if !strings.ContainsRune(egressTokenChars, c) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// parseEgressRequest reads the one request in head (the request line and
|
||||
// headers through their blank line, as handleEgressConn read them) and
|
||||
// rebuilds it, or refuses it. CONNECT never comes here.
|
||||
func parseEgressRequest(head string) (egressRequest, *egressRefusal) {
|
||||
lines := strings.Split(head, "\n")
|
||||
// The read stops at the first blank line, so the head ends "\n", blank, "\n":
|
||||
// drop the empty tail after the last "\n" and the blank line itself.
|
||||
if len(lines) < 3 {
|
||||
return egressRequest{}, egressBad("The request ended before its headers did, so the bastion sent nothing.")
|
||||
}
|
||||
lines = lines[:len(lines)-2]
|
||||
for i, ln := range lines {
|
||||
lines[i] = strings.TrimSuffix(ln, "\r")
|
||||
if strings.ContainsAny(lines[i], "\r\x00") {
|
||||
return egressRequest{}, egressBad("The request's headers carry a stray carriage return or NUL, so the bastion sent nothing.")
|
||||
}
|
||||
}
|
||||
parts := strings.Split(lines[0], " ")
|
||||
if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" || !egressTokenOK(parts[0]) {
|
||||
return egressRequest{}, egressBad("The request line is not 'METHOD target HTTP/1.x', so the bastion sent nothing.")
|
||||
}
|
||||
method, rawTarget, version := strings.ToUpper(parts[0]), parts[1], parts[2]
|
||||
if version != "HTTP/1.1" && version != "HTTP/1.0" {
|
||||
return egressRequest{}, egressBad("The bastion forwards HTTP/1.0 and HTTP/1.1 only, so it sent nothing.")
|
||||
}
|
||||
type header struct{ name, value string }
|
||||
var headers []header
|
||||
for _, ln := range lines[1:] {
|
||||
if strings.HasPrefix(ln, " ") || strings.HasPrefix(ln, "\t") {
|
||||
return egressRequest{}, egressBad("A header is folded onto a second line (obsolete line folding), so the bastion sent nothing.")
|
||||
}
|
||||
name, value, ok := strings.Cut(ln, ":")
|
||||
if !ok || !egressTokenOK(name) {
|
||||
return egressRequest{}, egressBad("A header line is not 'Name: value' (no space may come before the colon), so the bastion sent nothing.")
|
||||
}
|
||||
headers = append(headers, header{name, strings.Trim(value, " \t")})
|
||||
}
|
||||
values := func(n string) []string {
|
||||
var out []string
|
||||
for _, h := range headers {
|
||||
if strings.EqualFold(h.name, n) {
|
||||
out = append(out, h.value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
hosts := values("host")
|
||||
if len(hosts) > 1 {
|
||||
return egressRequest{}, egressBad("The request carries more than one Host header, so the bastion sent nothing.")
|
||||
}
|
||||
var host, target, authority string
|
||||
var port int
|
||||
if scheme, after, abs := strings.Cut(rawTarget, "://"); abs {
|
||||
if strings.ToLower(scheme) != "http" {
|
||||
// https:// in absolute form asks the lane to make the TLS connection,
|
||||
// which it does not do: it used to send the request in plain text to
|
||||
// port 80. Every client sends https as a CONNECT.
|
||||
return egressRequest{}, egressBad("The bastion forwards plain http:// requests only; send https as a CONNECT. Nothing was sent.")
|
||||
}
|
||||
cut := len(after)
|
||||
for _, sep := range []string{"/", "?", "#"} {
|
||||
if i := strings.Index(after, sep); i >= 0 && i < cut {
|
||||
cut = i
|
||||
}
|
||||
}
|
||||
var ok bool
|
||||
if host, port, ok = parseEgressAuthority(after[:cut], 80); !ok {
|
||||
return egressRequest{}, egressBad(egressNoHost)
|
||||
}
|
||||
path, _, _ := strings.Cut(after[cut:], "#")
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
authority = fmtEgressAuthority(host, port, 80)
|
||||
target = "http://" + authority + path
|
||||
} else {
|
||||
if !strings.HasPrefix(rawTarget, "/") && rawTarget != "*" {
|
||||
return egressRequest{}, egressBad(egressNoHost)
|
||||
}
|
||||
hostHdr := ""
|
||||
if len(hosts) == 1 {
|
||||
hostHdr = hosts[0]
|
||||
}
|
||||
var ok bool
|
||||
if host, port, ok = parseEgressAuthority(hostHdr, 80); !ok {
|
||||
return egressRequest{}, egressBad(egressNoHost)
|
||||
}
|
||||
authority = fmtEgressAuthority(host, port, 80)
|
||||
target = rawTarget
|
||||
}
|
||||
|
||||
// Body framing (RFC 9112 section 6). Every reading two parsers could disagree
|
||||
// on is refused: both length headers, an unknown or doubled coding, a coding
|
||||
// on HTTP/1.0, Content-Length values that differ.
|
||||
req := egressRequest{host: host, port: port}
|
||||
te, cl := values("transfer-encoding"), values("content-length")
|
||||
if len(te) > 0 {
|
||||
var codings []string
|
||||
for _, v := range te {
|
||||
for _, c := range strings.Split(v, ",") {
|
||||
codings = append(codings, strings.ToLower(strings.TrimSpace(c)))
|
||||
}
|
||||
}
|
||||
if len(cl) > 0 || version == "HTTP/1.0" || len(codings) != 1 || codings[0] != "chunked" {
|
||||
return egressRequest{}, egressBad("The request's body length is ambiguous (Transfer-Encoding with " +
|
||||
"Content-Length, on HTTP/1.0, or a coding other than chunked), so the bastion sent nothing.")
|
||||
}
|
||||
req.chunked = true
|
||||
} else if len(cl) > 0 {
|
||||
seen := map[string]bool{}
|
||||
for _, v := range cl {
|
||||
for _, c := range strings.Split(v, ",") {
|
||||
seen[strings.TrimSpace(c)] = true
|
||||
}
|
||||
}
|
||||
var one string
|
||||
for k := range seen {
|
||||
one = k
|
||||
}
|
||||
n, err := strconv.ParseInt(one, 10, 64)
|
||||
if len(seen) != 1 || one == "" || len(one) > 18 || err != nil || n < 0 || strings.TrimLeft(one, "0123456789") != "" {
|
||||
return egressRequest{}, egressBad("The request's Content-Length is not one number, so the bastion sent nothing.")
|
||||
}
|
||||
req.length = n
|
||||
}
|
||||
|
||||
named := map[string]bool{}
|
||||
for _, v := range values("connection") {
|
||||
for _, t := range strings.Split(v, ",") {
|
||||
named[strings.ToLower(strings.TrimSpace(t))] = true
|
||||
}
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(method + " " + target + " " + version + "\r\n")
|
||||
b.WriteString("Host: " + authority + "\r\n")
|
||||
for _, h := range headers {
|
||||
lk := strings.ToLower(h.name)
|
||||
if egressHopByHop[lk] || named[lk] || lk == "host" || lk == "content-length" || lk == "transfer-encoding" {
|
||||
continue
|
||||
}
|
||||
b.WriteString(h.name + ": " + h.value + "\r\n")
|
||||
}
|
||||
if req.chunked {
|
||||
b.WriteString("Transfer-Encoding: chunked\r\n")
|
||||
} else if len(cl) > 0 {
|
||||
b.WriteString("Content-Length: " + strconv.FormatInt(req.length, 10) + "\r\n")
|
||||
}
|
||||
b.WriteString("Connection: close\r\n\r\n")
|
||||
req.head = []byte(b.String())
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// forwardEgressBody copies the one request body from src to dst, framed as the
|
||||
// rebuilt head says. A chunked body goes out re-encoded (plain sizes, no
|
||||
// extensions, trailers read and dropped), so the destination parses exactly the
|
||||
// body this parser found.
|
||||
func forwardEgressBody(dst io.Writer, src *bufio.Reader, req egressRequest) error {
|
||||
if !req.chunked {
|
||||
if req.length == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := io.CopyN(dst, src, req.length)
|
||||
return err
|
||||
}
|
||||
cw := httputil.NewChunkedWriter(dst)
|
||||
if _, err := io.Copy(cw, httputil.NewChunkedReader(src)); err != nil {
|
||||
return err
|
||||
}
|
||||
// The trailer section, read to its blank line within a bound, and dropped.
|
||||
read := 0
|
||||
for {
|
||||
line, err := src.ReadSlice('\n')
|
||||
read += len(line)
|
||||
if err != nil || read > egressTrailerLimit {
|
||||
return errors.New("chunked body: bad trailer section")
|
||||
}
|
||||
if string(line) == "\r\n" || string(line) == "\n" {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := cw.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := io.WriteString(dst, "\r\n")
|
||||
return err
|
||||
}
|
||||
|
||||
// oneEgressExchange writes the one rebuilt request and its body to upstream,
|
||||
// and carries the destination's answer back until the destination closes.
|
||||
// Nothing the Host sends after the body is forwarded: its first byte closes
|
||||
// both sides.
|
||||
func oneEgressExchange(clientReader *bufio.Reader, client, upstream net.Conn, req egressRequest) {
|
||||
answered := make(chan struct{})
|
||||
go func() {
|
||||
_, _ = io.Copy(client, upstream)
|
||||
close(answered)
|
||||
_ = client.Close()
|
||||
}()
|
||||
defer func() {
|
||||
_ = upstream.Close()
|
||||
<-answered
|
||||
}()
|
||||
if _, err := upstream.Write(req.head); err != nil {
|
||||
return
|
||||
}
|
||||
if err := forwardEgressBody(upstream, clientReader, req); err != nil {
|
||||
return
|
||||
}
|
||||
// The request is complete. A further byte is a second request on this
|
||||
// connection: never forwarded, and the connection closes. End of stream
|
||||
// from the Host is passed on, as the splice did.
|
||||
go func() {
|
||||
var one [1]byte
|
||||
if n, _ := clientReader.Read(one[:]); n > 0 {
|
||||
_ = upstream.Close()
|
||||
_ = client.Close()
|
||||
return
|
||||
}
|
||||
if cw, ok := upstream.(interface{ CloseWrite() error }); ok {
|
||||
_ = cw.CloseWrite()
|
||||
}
|
||||
}()
|
||||
<-answered
|
||||
}
|
||||
Reference in New Issue
Block a user