1 Commits
Author SHA1 Message Date
sneak fbb5158377 rproxy, the proxy in front of each app, asks servewaf and fails open (closes #171)
check / check (push) Failing after 14m17s
`smallwebwaf rproxy` reads the first 64 KiB of each body, asks servewaf, and
passes what servewaf allows to the app within its own size and time limits.
It reports each request servewaf answered. SWRP_DEFAULT_DISPOSITION decides
when servewaf gives no answer.

Duplication: internal/rproxy copies passing-on code from internal/proxy until
#172 removes the originals.
Rule suppressed: gosec G704 on servewaf's URL, a setting.
Rule suppressed: gosec G705 on the answer written to the visitor.
Judgement call: the SWRP_ size and time limits take off; SWRP_TIMEOUT_MS does not.

Model: opus-5-5
2026-11-05 00:08:53 +00:00
12 changed files with 120 additions and 852 deletions

No files matched your search

+3 -4
View File
@@ -10,10 +10,9 @@ refuses the ones that look like abuse. `smallwebwaf` is also a reverse proxy: it
accepts requests on the app's behalf, passes them to the app, and passes the accepts requests on the app's behalf, passes them to the app, and passes the
app's answers back. app's answers back.
It needs no setting. Every `SWWAF_` setting has a default chosen for a service It needs no setting. Every setting has a default chosen for a service on the
on the open internet. [`SPEC.md`](SPEC.md) says how `smallwebwaf` behaves and open internet. [`SPEC.md`](SPEC.md) says how `smallwebwaf` behaves and why. The
why. The reference documents under [`docs/`](docs/) are listed under "Documents" reference documents under [`docs/`](docs/) are listed under "Documents" below.
below.
## What it protects against ## What it protects against
+26 -56
View File
@@ -12,9 +12,10 @@ to them.
- It protects one self-hosted app on the open internet from scrapers, scanners - It protects one self-hosted app on the open internet from scrapers, scanners
and other abusers. It does so from the app's first request, with no setting at and other abusers. It does so from the app's first request, with no setting at
all. Every `SWWAF_` setting has a default chosen for a service facing the all. Every setting but `SWRP_WAF_BACKEND`, which only `smallwebwaf rproxy`
internet in 2026. A setting that needs an alert destination, an account key or reads, has a default chosen for a service facing the internet in 2026. A
a token stays off until the operator supplies it. setting that needs an alert destination, an account key or a token stays off
until the operator supplies it.
- It limits each client's requests and bytes per minute, hour and day. It bounds - It limits each client's requests and bytes per minute, hour and day. It bounds
the size and duration of every request and answer. It detects attacks. the size and duration of every request and answer. It detects attacks.
- It bans abusers: briefly at first, for seven days on a clear sign of attack, - It bans abusers: briefly at first, for seven days on a clear sign of attack,
@@ -22,10 +23,10 @@ to them.
- It looks up each client's AS number and country. It can refuse whole countries - It looks up each client's AS number and country. It can refuse whole countries
and netblocks, lower its limits for listed AS numbers and countries, consult and netblocks, lower its limits for listed AS numbers and countries, consult
reputation sources, and send alerts. reputation sources, and send alerts.
- Everything is set by environment variables whose names start with `SWWAF_`, - Everything is set by environment variables whose names start with `SWWAF_`, or
since `smallwebwaf` shares its container, and so its environment, with the with `SWRP_` for `smallwebwaf rproxy`, since `smallwebwaf` shares its
app. `smallwebwaf rproxy` uses `SWRP_` in their place. The only other inputs container, and so its environment, with the app. The only other inputs are
are files: the rule files, the state files, a lookup database, and a `SWWAF_` files: the rule files, the state files, a lookup database, and a `SWWAF_`
setting given as a file. setting given as a file.
## Non-goals ## Non-goals
@@ -1059,17 +1060,14 @@ request. It is the visitor's request as the visitor sent it:
- every header the visitor sent, including its own `X-Forwarded-For`; - every header the visitor sent, including its own `X-Forwarded-For`;
- the first 64 KiB of the body, or all of it when shorter. - the first 64 KiB of the body, or all of it when shorter.
The path always starts with `/`, and `Host` is never empty. `rproxy` refuses any A body whose length the visitor did not announce comes chunked. Any other body
other request itself (see "Requests `rproxy` refuses itself"). comes with a `Content-Length` of the part it carries.
A body whose length the visitor did not announce comes chunked, whatever the
method. Any other body comes with a `Content-Length` of the part it carries.
`rproxy` sets these headers, replacing any the visitor sent, and adds no others: `rproxy` sets these headers, replacing any the visitor sent, and adds no others:
- `X-Smallwebwaf-Client`: the visitor's address, which `rproxy` has worked out, - `X-Smallwebwaf-Client`: the visitor's address, which `rproxy` has worked out.
without any IPv6 zone. `servewaf` takes it as the client, and groups an IPv6 `servewaf` takes it as the client, and groups an IPv6 address as "What happens
address as "What happens to a request" says. to a request" says.
- `X-Smallwebwaf-Peer`: the address of `rproxy`'s own TCP peer, for the log - `X-Smallwebwaf-Peer`: the address of `rproxy`'s own TCP peer, for the log
line's `peer_ip`. line's `peer_ip`.
- `X-Smallwebwaf-Content-Length`: the body length the visitor announced. It is - `X-Smallwebwaf-Content-Length`: the body length the visitor announced. It is
@@ -1255,48 +1253,23 @@ and health check.
`servewaf`. `servewaf`.
2. `rproxy` works out the visitor's address. It believes `X-Forwarded-For` only 2. `rproxy` works out the visitor's address. It believes `X-Forwarded-For` only
from `SWRP_TRUSTED_PROXIES`, and reads it as step 2 of "What happens to a from `SWRP_TRUSTED_PROXIES`, and reads it as step 2 of "What happens to a
request" says. It removes any IPv6 zone. request" says.
3. It makes a fresh request id, whatever id the request came with. `servewaf` 3. It makes a fresh request id, whatever id the request came with. `servewaf`
and the app get it as `X-Request-ID`, and `rproxy`'s log line gives it. and the app get it as `X-Request-ID`, and `rproxy`'s log line gives it.
4. It refuses a request it cannot put to `servewaf` as the visitor sent it (see 4. It reads up to the first 64 KiB of the body into memory.
"Requests `rproxy` refuses itself"). 5. It sends `servewaf` the question (see "The question"). The question never
5. It reads up to the first 64 KiB of the body into memory. carries the visitor's `Transfer-Encoding` or `Trailer`, which Go's HTTP
6. It sends `servewaf` the question (see "The question"). It speaks HTTP/1.1 to server and client handle themselves.
`servewaf`, whatever `servewaf` offers. Go's HTTP/2 client would refuse to 6. It does what the answer says (see "The answer"):
send a visitor's `Upgrade` header, and most `Connection` headers. The
question never carries the visitor's `Transfer-Encoding` or `Trailer`, which
Go's HTTP server and client handle themselves.
7. It does what the answer says (see "The answer"):
- `allow`: it passes the request to the app. - `allow`: it passes the request to the app.
- `deny`: the visitor gets the answer's status, headers and body, less - `deny`: the visitor gets the answer's status, headers and body, less
`X-Smallwebwaf-Disposition`. `X-Smallwebwaf-Disposition`.
- `close`: it closes the visitor's connection without an answer. - `close`: it closes the visitor's connection without an answer.
8. Once the answer has ended, it writes its log line. `docs/request-log.md` 7. Once the answer has ended, it writes its log line. `docs/request-log.md`
gives the fields. gives the fields.
9. It then sends the report, if `servewaf` answered `allow`, `deny` or `close` 8. It then sends the report, if `servewaf` answered `allow`, `deny` or `close`
(see "The report"). The visitor does not wait for it. A report that fails is (see "The report"). The visitor does not wait for it. A report that fails is
dropped, with a process warning. A status above `599` from the app goes to dropped, with a process warning.
the visitor as it is, and into the report as `599`, the highest `servewaf`
takes.
### Requests `rproxy` refuses itself
How a visitor sends a request never lets it past `servewaf`.
`SWRP_DEFAULT_DISPOSITION` applies only when `servewaf` itself is down, slow or
answers badly.
`rproxy` answers `400`, with no question and no report, to a request with:
- a target that does not start with `/`, such as `x:admin`,
`http://site.example/` or `site.example:443`;
- no `Host`, or an empty one.
Go's HTTP server answers `OPTIONS *` itself, before `rproxy` sees it.
`rproxy` closes the connection without an answer when it cannot read the first
64 KiB of the body. That happens for a malformed chunk, and for a visitor that
goes away or stops its sending side. There is no question and no report, and the
log line has status `0`.
### Without an answer from `servewaf` ### Without an answer from `servewaf`
@@ -1317,10 +1290,6 @@ Either way `rproxy` writes a process warning, its log line has
A path under `/_smallwebwaf/` that `allow` would pass on is answered `404` A path under `/_smallwebwaf/` that `allow` would pass on is answered `404`
instead, so that the app never sees that prefix. instead, so that the app never sees that prefix.
A visitor that goes away while `servewaf` is being asked is no failure of
`servewaf`. `rproxy` closes the connection, with no warning, no default and no
report. Its log line has status `0` and no `disposition`.
### Passing a request to the app ### Passing a request to the app
`rproxy` sends the app at `SWRP_UPSTREAM_URL` the visitor's request: the part of `rproxy` sends the app at `SWRP_UPSTREAM_URL` the visitor's request: the part of
@@ -1358,9 +1327,10 @@ The largest request line and headers is 32 KiB. A kept-open connection is closed
after 120 seconds idle. Neither has a setting. after 120 seconds idle. Neither has a setting.
`SWRP_MAX_REQUEST_TIME_MS` bounds the request line and headers. From their end, `SWRP_MAX_REQUEST_TIME_MS` bounds the request line and headers. From their end,
it then bounds the time until the app has been sent the whole request. The wait it then bounds everything until the app has been sent the whole request,
for `servewaf` counts toward it. A visitor too slow to send the first 64 KiB is including the wait for `servewaf`. A visitor too slow to send the first 64 KiB
answered `408`, with no question and no report. is answered `408`, with no question and no report. A visitor that goes away
before then is not answered, with no question and no report either.
`SWRP_MAX_RESPONSE_TIME_MS` runs from the end of the request to the last byte of `SWRP_MAX_RESPONSE_TIME_MS` runs from the end of the request to the last byte of
the answer reaching the visitor. The request ends when the app has been sent all the answer reaching the visitor. The request ends when the app has been sent all
+1 -2
View File
@@ -204,8 +204,7 @@ it to `servewaf`'s two. It has these fields and no others:
- `duration_response`: milliseconds from then until the end of the answer. - `duration_response`: milliseconds from then until the end of the answer.
- `disposition`: what was done with the request: `allow`, `deny` or `close`. It - `disposition`: what was done with the request: `allow`, `deny` or `close`. It
is `servewaf`'s answer, or the value of `SWRP_DEFAULT_DISPOSITION`. It is left is `servewaf`'s answer, or the value of `SWRP_DEFAULT_DISPOSITION`. It is left
out when `servewaf` was not asked, as for the health check, and when the out when `servewaf` was not asked, as for the health check.
visitor went away while it was being asked.
- `default_disposition`: `true` when `SWRP_DEFAULT_DISPOSITION` gave - `default_disposition`: `true` when `SWRP_DEFAULT_DISPOSITION` gave
`disposition`, and otherwise left out. `disposition`, and otherwise left out.
+2 -3
View File
@@ -347,7 +347,7 @@ A few limits are fixed rather than settings:
## `servewaf` ## `servewaf`
`smallwebwaf servewaf` uses every `SWWAF_` setting but these, which it ignores: `smallwebwaf servewaf` uses every setting here but these, which it ignores:
- `SWWAF_UPSTREAM_URL` and `SWWAF_TRUSTED_PROXIES`; - `SWWAF_UPSTREAM_URL` and `SWWAF_TRUSTED_PROXIES`;
- the five timeouts under "Size and time limits"; - the five timeouts under "Size and time limits";
@@ -372,8 +372,7 @@ None can be given as a file, since `rproxy` never touches disk. "`rproxy`" in
A time here is a whole number of milliseconds above zero, such as `2000`. A time here is a whole number of milliseconds above zero, such as `2000`.
- `SWRP_WAF_BACKEND` (required, no default): `servewaf`'s URL. An `http` or - `SWRP_WAF_BACKEND` (required, no default): `servewaf`'s URL. An `http` or
`https` URL with a host and an optional port, and nothing more. `rproxy` `https` URL with a host and an optional port, and nothing more.
speaks HTTP/1.1 to it.
- `SWRP_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for `servewaf`'s - `SWRP_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for `servewaf`'s
answer, to a question and to a report. A time; never `off`. answer, to a question and to a report. A time; never `off`.
- `SWRP_DEFAULT_DISPOSITION` (default `allow`): what is done with a request - `SWRP_DEFAULT_DISPOSITION` (default `allow`): what is done with a request
+4 -6
View File
@@ -17,15 +17,14 @@ const (
countryHeader = "X-Client-Country" countryHeader = "X-Client-Country"
) )
// peerAddress is the address of the request's TCP peer, normally traefik, without // peerAddress is the address of the request's TCP peer, normally traefik.
// any IPv6 zone, which servewaf refuses.
func peerAddress(r *http.Request) netip.Addr { func peerAddress(r *http.Request) netip.Addr {
addrPort, err := netip.ParseAddrPort(r.RemoteAddr) addrPort, err := netip.ParseAddrPort(r.RemoteAddr)
if err != nil { if err != nil {
return netip.Addr{} return netip.Addr{}
} }
return addrPort.Addr().Unmap().WithZone("") return addrPort.Addr().Unmap()
} }
// clientAddress works out who the visitor is. A peer outside the trusted // clientAddress works out who the visitor is. A peer outside the trusted
@@ -33,8 +32,7 @@ func peerAddress(r *http.Request) netip.Addr {
// peer inside them, X-Forwarded-For is read from the right, and the first // peer inside them, X-Forwarded-For is read from the right, and the first
// address outside them is the visitor; if every address in it is inside, the // address outside them is the visitor; if every address in it is inside, the
// leftmost is, and with no header, the peer. An entry that is not an address // leftmost is, and with no header, the peer. An entry that is not an address
// ends the reading, since nothing to its left can be believed. The visitor's // ends the reading, since nothing to its left can be believed.
// address never has an IPv6 zone, which servewaf refuses.
func clientAddress( func clientAddress(
peer netip.Addr, forwardedFor []string, trusted []netip.Prefix, peer netip.Addr, forwardedFor []string, trusted []netip.Prefix,
) netip.Addr { ) netip.Addr {
@@ -50,7 +48,7 @@ func clientAddress(
break break
} }
client = addr.Unmap().WithZone("") client = addr.Unmap()
if !isInside(client, trusted) { if !isInside(client, trusted) {
break break
} }
+5 -116
View File
@@ -6,7 +6,6 @@ import (
"log/slog" "log/slog"
"net" "net"
"net/http" "net/http"
"net/http/httptest"
"strconv" "strconv"
"strings" "strings"
"sync/atomic" "sync/atomic"
@@ -116,10 +115,9 @@ func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
name, limit string name, limit string
size int size int
// mayReachApp is true when the part of the body the limit lets through // reachesApp is true when the part of the body the limit lets through
// goes on to the app before the limit is met. The app may still not have // goes on to the app before the limit is met.
// started on the request when it is cut off. reachesApp bool
mayReachApp bool
}{ }{
{"within the first part", "1K", 2 << 10, false}, {"within the first part", "1K", 2 << 10, false},
{"past the first part", "128K", 129 << 10, true}, {"past the first part", "128K", 129 << 10, true},
@@ -147,8 +145,8 @@ func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) {
t.Errorf("status %d, want 413", status) t.Errorf("status %d, want 413", status)
} }
if asked.Load() && !tc.mayReachApp { if asked.Load() != tc.reachesApp {
t.Error("the app was asked") t.Errorf("the app was asked: %v, want %v", asked.Load(), tc.reachesApp)
} }
}) })
} }
@@ -269,115 +267,6 @@ func TestAVisitorThatGoesAwayBeforeTheFirstPartIsNotAsked(t *testing.T) {
} }
} }
func TestABodyThatCannotBeReadClosesTheConnectionWithoutAnAnswer(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name, request string
stopsSending bool
}{
{
"a malformed chunk", post + "Transfer-Encoding: chunked\r\n\r\nnot a chunk\r\n",
false,
},
{"a visitor that stops sending", post + announcedLength(100) + "some of it", true},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
servewaf := startServeWAF(t, allow)
app := startApp(t, greet)
addr, out := startRProxy(t, servewaf.URL, app.URL, nil)
conn := dial(t, addr)
send(t, conn, tc.request)
if tc.stopsSending {
tcpConn, ok := conn.(*net.TCPConn)
if !ok {
t.Fatalf("the connection is a %T", conn)
}
err := tcpConn.CloseWrite()
if err != nil {
t.Fatalf("stop sending: %v", err)
}
}
text, err := io.ReadAll(conn)
if err != nil || len(text) != 0 {
t.Errorf("read %q (%v), want the connection closed without an answer",
text, err)
}
line := out.requestLine(t)
if line["status"] != 0.0 || line["disposition"] != nil {
t.Errorf("line %v, want no answer and no disposition", line)
}
if len(servewaf.questions) != 0 {
t.Error("servewaf was asked")
}
})
}
}
// deadlineRecorder notes the time limit rproxy sets on sending its answer.
type deadlineRecorder struct {
*httptest.ResponseRecorder
writeDeadline time.Time
}
// SetWriteDeadline notes deadline.
func (w *deadlineRecorder) SetWriteDeadline(deadline time.Time) error {
w.writeDeadline = deadline
return nil
}
func TestAnAnswerRProxyGivesItselfHasTheResponseTimeToBeSent(t *testing.T) {
t.Parallel()
const limit = 5 * time.Minute
for _, tc := range []struct {
name, target string
status int
}{
{"servewaf's deny", "/", http.StatusForbidden},
{"rproxy's own 400", "x:admin", http.StatusBadRequest},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
servewaf := startServeWAF(t, deny)
app := startApp(t, greet)
_, _, server := startServer(t, servewaf.URL, app.URL, map[string]string{
maxResponseTime: strconv.FormatInt(limit.Milliseconds(), 10),
})
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, tc.target,
http.NoBody)
w := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder()}
before := time.Now()
server.Handler.ServeHTTP(w, req)
if w.Code != tc.status {
t.Errorf("status %d, want %d", w.Code, tc.status)
}
if w.writeDeadline.Before(before.Add(limit)) ||
w.writeDeadline.After(time.Now().Add(limit)) {
t.Errorf("the time limit on sending ends at %s, want %s from the answer",
w.writeDeadline, limit)
}
})
}
}
func TestAVisitorTooSlowToSendTheRestIsAnswered408(t *testing.T) { func TestAVisitorTooSlowToSendTheRestIsAnswered408(t *testing.T) {
t.Parallel() t.Parallel()
+2 -139
View File
@@ -6,7 +6,6 @@ import (
"io" "io"
"maps" "maps"
"net/http" "net/http"
"net/http/httptest"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
@@ -18,14 +17,6 @@ import (
// questionBodyMaxBytes is the most of a body a question carries. // questionBodyMaxBytes is the most of a body a question carries.
const questionBodyMaxBytes = proxy.QuestionBodyMaxBytes const questionBodyMaxBytes = proxy.QuestionBodyMaxBytes
// chunked is the encoding of a body whose length the visitor did not announce.
const chunked = "chunked"
// pathGoReencodes is a path Go's client would decode and escape again, %2F turned
// into / and | into %7C, unless told to send it as it is. A test sets it as the
// visitor's URL.Opaque, so that the visitor sends it unchanged too.
const pathGoReencodes = "/some%2Fpath/with%20space|pipe"
func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) { func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) {
t.Parallel() t.Parallel()
@@ -33,10 +24,9 @@ func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) {
app := startApp(t, greet) app := startApp(t, greet)
addr, _ := startRProxy(t, servewaf.URL, app.URL, nil) addr, _ := startRProxy(t, servewaf.URL, app.URL, nil)
const target = pathGoReencodes + "?b=2&a=1&bad=%zz;x" const target = "/some%2Fpath/with%20space?b=2&a=1&bad=%zz;x"
req := newRequest(t, http.MethodDelete, addr, target, http.NoBody) req := newRequest(t, http.MethodDelete, addr, target, http.NoBody)
req.URL.Opaque = pathGoReencodes
req.Host = site req.Host = site
req.Header.Set("X-Test", "from the visitor") req.Header.Set("X-Test", "from the visitor")
req.Header.Set(forwardedForHeader, "198.51.100.1") req.Header.Set(forwardedForHeader, "198.51.100.1")
@@ -209,7 +199,7 @@ func wantQuestionBody(
return return
} }
if len(values) != 0 || !slices.Equal(asked.transferEncoding, []string{chunked}) { if len(values) != 0 || !slices.Equal(asked.transferEncoding, []string{"chunked"}) {
t.Errorf("announced %v and encoded %v, want none and chunked", values, t.Errorf("announced %v and encoded %v, want none and chunked", values,
asked.transferEncoding) asked.transferEncoding)
} }
@@ -236,130 +226,3 @@ func TestDenyNeedsOnlyTheFirstPartOfALargeBody(t *testing.T) {
t.Errorf("report %+v, want the first part's bytes", report) t.Errorf("report %+v, want the first part's bytes", report)
} }
} }
func TestAQuestionCarriesItsBodyWhateverTheMethod(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name, framing, body string
encoding []string
}{
{
chunked, "Transfer-Encoding: chunked\r\n\r\n4\r\nbody\r\n0\r\n\r\n", "body",
[]string{chunked},
},
{
"chunked and empty", "Transfer-Encoding: chunked\r\n\r\n0\r\n\r\n", "",
[]string{chunked},
},
{"with its length", announcedLength(4) + "body", "body", nil},
} {
for _, method := range []string{
http.MethodConnect, http.MethodGet, http.MethodDelete, http.MethodPost,
} {
t.Run(method+" "+tc.name, func(t *testing.T) {
t.Parallel()
servewaf := startServeWAF(t, deny)
addr, _ := startRProxy(t, servewaf.URL, nowhere, nil)
conn := dial(t, addr)
send(t, conn, method+" /x HTTP/1.1\r\nHost: site.example\r\n"+tc.framing)
if status := readStatus(t, conn); status != http.StatusForbidden {
t.Errorf("status %d, want servewaf's 403", status)
}
asked := servewaf.question(t)
if string(asked.body) != tc.body ||
!slices.Equal(asked.transferEncoding, tc.encoding) {
t.Errorf("the question carries %q, encoded %v, want %q, encoded %v",
asked.body, asked.transferEncoding, tc.body, tc.encoding)
}
})
}
}
}
func TestARequestThatCannotGoToServeWAFAsSentIsRefused(t *testing.T) {
t.Parallel()
const host = "Host: site.example\r\n\r\n"
for name, request := range map[string]string{
"a target of a scheme": "GET x:admin HTTP/1.1\r\n" + host,
"a target of a URL": "GET http://site.example/ HTTP/1.1\r\n" + host,
"a host Go will not send": "GET http://a<b/ HTTP/1.1\r\n" + host,
"a target of a host": "CONNECT site.example:443 HTTP/1.1\r\n" + host,
"a target of *": "GET * HTTP/1.1\r\n" + host,
"an empty Host": "GET / HTTP/1.1\r\nHost:\r\n\r\n",
"HTTP/1.0 with no Host": "GET / HTTP/1.0\r\n\r\n",
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
servewaf := startServeWAF(t, allow)
app := startApp(t, greet)
addr, out := startRProxy(t, servewaf.URL, app.URL, nil)
conn := dial(t, addr)
send(t, conn, request)
if status := readStatus(t, conn); status != http.StatusBadRequest {
t.Errorf("status %d, want 400", status)
}
line := out.requestLine(t)
if line["status"] != float64(http.StatusBadRequest) ||
line["disposition"] != nil || line["default_disposition"] != nil {
t.Errorf("line %v, want status 400 and no disposition", line)
}
if len(servewaf.questions) != 0 {
t.Error("servewaf was asked")
}
servewaf.wantNoReport(t)
})
}
}
func TestAQuestionNamesNoZone(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name, remoteAddr, forwardedFor, client, peer string
}{
{"from a link-local peer", "[fe80::1%eth0]:1234", "", "fe80::1", "fe80::1"},
{
"from X-Forwarded-For through a trusted peer", localhost + ":1234",
"2001:db8::1%eth0", "2001:db8::1", localhost,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
servewaf := startServeWAF(t, deny)
app := startApp(t, greet)
_, out, server := startServer(t, servewaf.URL, app.URL, map[string]string{
trustedProxies: localhost + "/32",
})
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
req.RemoteAddr = tc.remoteAddr
req.Header.Set(forwardedForHeader, tc.forwardedFor)
server.Handler.ServeHTTP(httptest.NewRecorder(), req)
asked := servewaf.question(t)
if asked.header.Get(proxy.ClientHeader) != tc.client ||
asked.header.Get(proxy.PeerHeader) != tc.peer {
t.Errorf("the question's headers are %v", asked.header)
}
if line := out.requestLine(t); line["client_ip"] != tc.client {
t.Errorf("line %v, want client_ip %s", line, tc.client)
}
})
}
}
+9 -15
View File
@@ -25,10 +25,6 @@ import (
// soon as it arrives. // soon as it arrives.
const flushAfterEachWrite time.Duration = -1 const flushAfterEachWrite time.Duration = -1
// reportStatusMax is the highest status servewaf takes in a report. An app can
// send a higher one, up to 999, and the visitor gets that as it is.
const reportStatusMax = 599
// request is one request on its way through rproxy, from the moment its headers // request is one request on its way through rproxy, from the moment its headers
// have been read to its log line and report. // have been read to its log line and report.
type request struct { type request struct {
@@ -115,10 +111,9 @@ func (rq *request) answerHealth() {
// readFirstPartOfBody reads up to the first 64 KiB of the body into memory, // readFirstPartOfBody reads up to the first 64 KiB of the body into memory,
// within SWRP_MAX_REQUEST_TIME_MS, and reports whether the request goes on. A // within SWRP_MAX_REQUEST_TIME_MS, and reports whether the request goes on. A
// visitor too slow to send it is answered 408. For a malformed chunk, or a // visitor too slow to send it is answered 408, and one that went away is not
// visitor that went away or stopped its sending side, the connection is closed // answered. A body over SWRP_MAX_REQUEST_SIZE goes on with the part read, so
// without an answer. A body over SWRP_MAX_REQUEST_SIZE goes on with the part // that servewaf is asked first; passToApp then refuses it.
// read, so that servewaf is asked first; passToApp then refuses it.
func (rq *request) readFirstPartOfBody() bool { func (rq *request) readFirstPartOfBody() bool {
if rq.body == nil { if rq.body == nil {
return true return true
@@ -140,20 +135,19 @@ func (rq *request) readFirstPartOfBody() bool {
return false return false
default: default:
// This panic has Go's server close the connection without an answer, and return false
// log nothing; the deferred finish still runs.
panic(http.ErrAbortHandler)
} }
} }
// setPathAsSent has out, the URL of a request made from in, send the path as the // setPathAsSent has out, the URL of a request made from in, send the path as the
// visitor sent it. Go sends the path URL.EscapedPath gives, which, for a path // visitor sent it. Go sends the path URL.EscapedPath gives, which, for a path
// holding a character Go escapes, such as |, is the whole path decoded and // holding a character Go escapes, such as |, is the whole path decoded and
// escaped again, %2F turned into /. Opaque is sent as it is. Left out is a path // escaped again, %2F turned into /. Opaque is sent as it is. Left out are a
// starting with //, which Go would send in Opaque as a scheme and host. // target that is no path, such as *, and a path starting with //, which Go would
// send in Opaque as a scheme and host.
func setPathAsSent(out *url.URL, in *http.Request) { func setPathAsSent(out *url.URL, in *http.Request) {
path := rules.Path(in) path := rules.Path(in)
if !strings.HasPrefix(path, "//") { if strings.HasPrefix(path, "/") && !strings.HasPrefix(path, "//") {
out.Opaque = path out.Opaque = path
} }
} }
@@ -363,7 +357,7 @@ func (rq *request) finish(ctx context.Context) {
report := &proxy.Report{ report := &proxy.Report{
RequestID: rq.id, RequestID: rq.id,
Status: min(rq.out.status, reportStatusMax), Status: rq.out.status,
RequestBytes: requestBytes, RequestBytes: requestBytes,
ResponseBytes: responseBytes, ResponseBytes: responseBytes,
DurationTotal: requestlog.Milliseconds(end.Sub(rq.start)), DurationTotal: requestlog.Milliseconds(end.Sub(rq.start)),
+9 -35
View File
@@ -1,8 +1,7 @@
// Package rproxy is the reverse proxy in front of one app that asks servewaf // Package rproxy is the reverse proxy in front of one app that asks servewaf
// about each request. It passes a request servewaf allows to the app and the // about each request. It passes a request servewaf allows to the app and the
// app's answer back, within the size and time limits. When servewaf gives no // app's answer back, within the size and time limits. When servewaf gives no
// answer, SWRP_DEFAULT_DISPOSITION decides. A request it cannot put to servewaf // answer, SWRP_DEFAULT_DISPOSITION decides. It keeps nothing between requests
// as the visitor sent it, it refuses itself. It keeps nothing between requests
// and never touches disk. "rproxy" in SPEC.md says what it does. // and never touches disk. "rproxy" in SPEC.md says what it does.
package rproxy package rproxy
@@ -75,7 +74,6 @@ func New(params Params) *Server {
processLog: params.ProcessLog, processLog: params.ProcessLog,
errorLog: errorLog, errorLog: errorLog,
transport: newTransport(), transport: newTransport(),
serveWAFTransport: newServeWAFTransport(),
reportURL: (&url.URL{ reportURL: (&url.URL{
Scheme: backend.Scheme, Host: backend.Host, Path: proxy.ReportPath, Scheme: backend.Scheme, Host: backend.Host, Path: proxy.ReportPath,
}).String(), }).String(),
@@ -112,10 +110,7 @@ type handler struct {
requestLog io.Writer requestLog io.Writer
processLog *slog.Logger processLog *slog.Logger
errorLog *log.Logger errorLog *log.Logger
// transport carries requests to the app, and serveWAFTransport questions and
// reports to servewaf.
transport http.RoundTripper transport http.RoundTripper
serveWAFTransport http.RoundTripper
// reportURL is where the reports go. // reportURL is where the reports go.
reportURL string reportURL string
// reports waits for the reports being sent. reportsMu guards starting one // reports waits for the reports being sent. reportsMu guards starting one
@@ -125,9 +120,9 @@ type handler struct {
reportsStopped bool reportsStopped bool
} }
// newTransport returns what carries requests to the app. It never goes through // newTransport returns what carries requests to the app and to servewaf. It
// a proxy named in the environment, and leaves answers compressed or not as they // never goes through a proxy named in the environment, and leaves answers
// were sent. // compressed or not as they were sent.
func newTransport() *http.Transport { func newTransport() *http.Transport {
return &http.Transport{ return &http.Transport{
MaxIdleConns: idleConns, MaxIdleConns: idleConns,
@@ -137,23 +132,10 @@ func newTransport() *http.Transport {
} }
} }
// newServeWAFTransport returns what carries questions and reports to servewaf. // ServeHTTP handles one request: it answers the health check itself, and for
// It speaks HTTP/1.1 only. Go's HTTP/2 client refuses to send a visitor's Upgrade // any other request reads the first part of the body, asks servewaf, and does
// header, or a Connection header other than close or keep-alive, and that would // what the answer says. Once the answer has ended, it writes the request's log
// read as servewaf giving no answer. // line and sends its report.
func newServeWAFTransport() *http.Transport {
transport := newTransport()
transport.Protocols = new(http.Protocols)
transport.Protocols.SetHTTP1(true)
return transport
}
// ServeHTTP handles one request: it answers the health check itself, refuses a
// request it cannot put to servewaf as the visitor sent it, and for any other
// request reads the first part of the body, asks servewaf, and does what the
// answer says. Once the answer has ended, it writes the request's log line and
// sends its report.
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
rq := h.newRequest(w, r) rq := h.newRequest(w, r)
defer rq.finish(r.Context()) defer rq.finish(r.Context())
@@ -164,14 +146,6 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return return
} }
// Go's client sends only a target that starts with / as it came. For an empty
// Host, it sends servewaf's own address.
if r.Host == "" || !strings.HasPrefix(r.RequestURI, "/") {
rq.answer(http.StatusBadRequest)
return
}
if !rq.readFirstPartOfBody() { if !rq.readFirstPartOfBody() {
return return
} }
@@ -189,7 +163,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
rq.answerWith(answer) rq.answerWith(answer)
default: // proxy.DispositionClose, or none when the visitor has gone default: // proxy.DispositionClose
// This panic has Go's server close the connection without an answer, // This panic has Go's server close the connection without an answer,
// and log nothing; the deferred finish still runs. // and log nothing; the deferred finish still runs.
panic(http.ErrAbortHandler) panic(http.ErrAbortHandler)
+19 -215
View File
@@ -117,14 +117,6 @@ func (o *output) requestLine(t *testing.T) map[string]any {
return nil return nil
} }
// has reports whether what rproxy has written so far holds text.
func (o *output) has(text string) bool {
o.mu.Lock()
defer o.mu.Unlock()
return strings.Contains(o.buf.String(), text)
}
// question is what the stand-in for servewaf received as a question. // question is what the stand-in for servewaf received as a question.
type question struct { type question struct {
method, target, host string method, target, host string
@@ -446,10 +438,9 @@ func TestPassesAnAllowedRequestToTheAppAndItsAnswerBack(t *testing.T) {
}) })
addr, _ := startRProxy(t, servewaf.URL, app.URL, nil) addr, _ := startRProxy(t, servewaf.URL, app.URL, nil)
const target = pathGoReencodes + "?b=2&a=1&bad=%zz;x" const target = "/some%2Fpath/with%20space?b=2&a=1&bad=%zz;x"
req := newRequest(t, http.MethodGet, addr, target, http.NoBody) req := newRequest(t, http.MethodGet, addr, target, http.NoBody)
req.URL.Opaque = pathGoReencodes
req.Host = site req.Host = site
req.Header.Set(asnHeader, "AS1") req.Header.Set(asnHeader, "AS1")
req.Header.Set(countryHeader, "XX") req.Header.Set(countryHeader, "XX")
@@ -480,82 +471,6 @@ func TestPassesAnAllowedRequestToTheAppAndItsAnswerBack(t *testing.T) {
} }
} }
func TestAPeerNotTrustedHasItsForwardedHeadersReplaced(t *testing.T) {
t.Parallel()
removed := []string{
"X-Forwarded-Port", "X-Forwarded-Server", "X-Forwarded-Uri",
"X-Forwarded-Method", "X-Forwarded-Prefix", "X-Forwarded-Tls-Client-Cert",
"X-Forwarded-Tls-Client-Cert-Info", "X-Real-Ip",
}
servewaf := startServeWAF(t, allow)
seen := make(chan http.Header, 1)
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
seen <- r.Header.Clone()
greet(w, r)
})
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
trustedProxies: "10.0.0.0/8",
})
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
req.Host = site
req.Header.Set(forwardedForHeader, "198.51.100.1")
req.Header.Set("X-Forwarded-Host", "claimed.example")
req.Header.Set(forwardedProtoHeader, "https")
for _, name := range removed {
req.Header.Set(name, "claimed")
}
wantGreeting(t, visit(t, req))
header := <-seen
for name, want := range map[string]string{
forwardedForHeader: localhost,
"X-Forwarded-Host": site,
forwardedProtoHeader: "http",
} {
if got := header.Values(name); !slices.Equal(got, []string{want}) {
t.Errorf("the app saw %s %q, want %q", name, got, want)
}
}
for _, name := range removed {
if got := header.Values(name); len(got) != 0 {
t.Errorf("the app saw %s %q, want none", name, got)
}
}
}
func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) {
t.Parallel()
servewaf := startServeWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header()["Content-Type"] = nil
_, _ = io.WriteString(w, "<html>")
})
_, _, server := startServer(t, servewaf.URL, app.URL, nil)
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
w := httptest.NewRecorder()
server.Handler.ServeHTTP(w, req)
// Go's server guesses a type when the first part of the body goes before the
// headers are flushed, which is a matter of timing here. So this looks for what
// stops it: the key with no value.
values, there := w.Header()["Content-Type"]
if !there || len(values) != 0 {
t.Errorf("the answer's Content-Type is %q, there: %v, want the key with no value",
values, there)
}
}
func TestRemovesTheVisitorsLookupHeadersWhenServeWAFGivesNone(t *testing.T) { func TestRemovesTheVisitorsLookupHeadersWhenServeWAFGivesNone(t *testing.T) {
t.Parallel() t.Parallel()
@@ -673,14 +588,6 @@ func noAnswers() map[string]func(t *testing.T) string {
"too slow": answeringWith(func(_ http.ResponseWriter, r *http.Request) { "too slow": answeringWith(func(_ http.ResponseWriter, r *http.Request) {
<-r.Context().Done() <-r.Context().Done()
}), }),
"too slow after its headers": answeringWith(
func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(dispositionHeader, dispositionDeny)
w.WriteHeader(http.StatusForbidden)
_ = http.NewResponseController(w).Flush()
<-r.Context().Done()
}),
"no disposition": answeringWith(func(w http.ResponseWriter, _ *http.Request) { "no disposition": answeringWith(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
}), }),
@@ -755,45 +662,6 @@ func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
} }
} }
func TestAVisitorThatGoesAwayWhileServeWAFIsAskedGetsNoDefault(t *testing.T) {
t.Parallel()
for _, disposition := range []string{dispositionAllow, dispositionDeny} {
t.Run("with the default "+disposition, func(t *testing.T) {
t.Parallel()
servewaf := startServeWAF(t, func(_ http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
})
app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked")
})
addr, out := startRProxy(t, servewaf.URL, app.URL, map[string]string{
timeoutSetting: "60000",
defaultDisposition: disposition,
})
conn := dial(t, addr)
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
servewaf.question(t)
_ = conn.Close()
line := out.requestLine(t)
if line["status"] != 0.0 || line["disposition"] != nil ||
line["default_disposition"] != nil {
t.Errorf("line %v, want status 0 and no disposition", line)
}
if out.has("servewaf gave no answer") {
t.Error("rproxy warned that servewaf gave no answer")
}
servewaf.wantNoReport(t)
})
}
}
func TestSendsNoReportWithoutAnAnswer(t *testing.T) { func TestSendsNoReportWithoutAnAnswer(t *testing.T) {
t.Parallel() t.Parallel()
@@ -848,18 +716,24 @@ func TestHealthCheckIsAnsweredWhileServeWAFIsDown(t *testing.T) {
} }
} }
func TestTheVisitorDoesNotWaitForTheReport(t *testing.T) { func TestReportComesAfterTheAnswerWithoutTheVisitorWaiting(t *testing.T) {
t.Parallel() t.Parallel()
release := make(chan struct{}) release := make(chan struct{})
reports := make(chan proxy.Report, 1)
backend := httptest.NewServer(http.HandlerFunc( backend := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(proxy.ClientHeader) != "" { if r.Header.Get(proxy.ClientHeader) != "" {
deny(w, r) allow(w, r)
return return
} }
var report proxy.Report
_ = json.NewDecoder(r.Body).Decode(&report)
reports <- report
select { select {
case <-release: case <-release:
case <-r.Context().Done(): case <-r.Context().Done():
@@ -869,37 +743,23 @@ func TestTheVisitorDoesNotWaitForTheReport(t *testing.T) {
})) }))
t.Cleanup(backend.Close) t.Cleanup(backend.Close)
app := startApp(t, greet)
addr, _ := startRProxy(t, backend.URL, app.URL, map[string]string{
timeoutSetting: "60000",
})
t.Cleanup(func() { close(release) })
// Go's server sends a deny's answer only once the handler has returned, so
// this answer comes only if the handler does not wait for the report, which
// the stand-in leaves unanswered.
conn := dial(t, addr)
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
if status := readStatus(t, conn); status != http.StatusForbidden {
t.Errorf("status %d, want servewaf's 403", status)
}
}
func TestReportGivesWhatHappened(t *testing.T) {
t.Parallel()
const body = "the visitor's body" const body = "the visitor's body"
servewaf := startServeWAF(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, out := startRProxy(t, servewaf.URL, app.URL, nil) addr, out := startRProxy(t, backend.URL, app.URL, nil)
// The stand-in does not answer the report until the visitor has its answer.
wantGreeting(t, visit(t, newRequest(t, http.MethodPost, addr, "/", wantGreeting(t, visit(t, newRequest(t, http.MethodPost, addr, "/",
strings.NewReader(body)))) strings.NewReader(body))))
close(release)
report := servewaf.report(t) var report proxy.Report
select {
case report = <-reports:
case <-time.After(waitLimit):
t.Fatal("no report")
}
if report.RequestID == "" || report.Status != http.StatusOK || if report.RequestID == "" || report.Status != http.StatusOK ||
report.RequestBytes != int64(len(body)) || report.RequestBytes != int64(len(body)) ||
@@ -1027,62 +887,6 @@ func TestReportOfAWebSocketCountsWhatItCarried(t *testing.T) {
} }
} }
func TestAWebSocketPassesOnThatTheVisitorStoppedSending(t *testing.T) {
t.Parallel()
const goodbye = "sent after the visitor stopped sending\n"
servewaf := startServeWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
conn, buffered, err := http.NewResponseController(w).Hijack()
if err != nil {
return
}
defer func() {
_ = conn.Close()
}()
_, _ = buffered.WriteString("HTTP/1.1 101 Switching Protocols\r\n" +
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
_ = buffered.Flush()
_, _ = io.Copy(io.Discard, buffered)
_, _ = buffered.WriteString(goodbye)
_ = buffered.Flush()
})
addr, _ := startRProxy(t, servewaf.URL, app.URL, nil)
conn := dial(t, addr)
send(t, conn, "GET /socket HTTP/1.1\r\nHost: app\r\n"+
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
reader := bufio.NewReader(conn)
res, err := http.ReadResponse(reader, nil)
if err != nil {
t.Fatalf("read the answer to the upgrade: %v", err)
}
_ = res.Body.Close()
tcpConn, ok := conn.(*net.TCPConn)
if !ok {
t.Fatalf("the connection is a %T", conn)
}
err = tcpConn.CloseWrite()
if err != nil {
t.Fatalf("stop sending: %v", err)
}
text, err := io.ReadAll(reader)
if err != nil || string(text) != goodbye {
t.Errorf("read %q (%v), want %q", text, err, goodbye)
}
}
// echoAfterUpgrade is an app that switches protocols on request, and then sends // echoAfterUpgrade is an app that switches protocols on request, and then sends
// back each line it receives. // back each line it receives.
func echoAfterUpgrade(w http.ResponseWriter, _ *http.Request) { func echoAfterUpgrade(w http.ResponseWriter, _ *http.Request) {
+7 -13
View File
@@ -36,16 +36,10 @@ type servewafAnswer struct {
// askServeWAF asks servewaf about the request, and notes what is to be done // askServeWAF asks servewaf about the request, and notes what is to be done
// with it. It returns servewaf's answer, or nil when servewaf gave none, and // with it. It returns servewaf's answer, or nil when servewaf gave none, and
// SWRP_DEFAULT_DISPOSITION decides. When the visitor went away while servewaf // SWRP_DEFAULT_DISPOSITION decides. ctx is the request's own context.
// was being asked, servewaf has not failed, and nothing is noted. ctx is the
// request's own context.
func (rq *request) askServeWAF(ctx context.Context) *servewafAnswer { func (rq *request) askServeWAF(ctx context.Context) *servewafAnswer {
answer, err := rq.ask(ctx) answer, err := rq.ask(ctx)
if err != nil { if err != nil {
if ctx.Err() != nil {
return nil
}
rq.h.processLog.Warn("servewaf gave no answer", "error", err.Error()) rq.h.processLog.Warn("servewaf gave no answer", "error", err.Error())
rq.disposition = rq.h.config.DefaultDisposition rq.disposition = rq.h.config.DefaultDisposition
@@ -74,7 +68,7 @@ func (rq *request) ask(ctx context.Context) (*servewafAnswer, error) {
return nil, err return nil, err
} }
res, err := rq.h.serveWAFTransport.RoundTrip(question) res, err := rq.h.transport.RoundTrip(question)
if err != nil { if err != nil {
return nil, fmt.Errorf("send the question: %w", err) return nil, fmt.Errorf("send the question: %w", err)
} }
@@ -126,10 +120,10 @@ func (rq *request) newQuestion(ctx context.Context) (*http.Request, error) {
question.Host = in.Host question.Host = in.Host
// The length NewRequestWithContext set is that of what is held. A body whose // The length NewRequestWithContext set is that of what is held. A body whose
// length the visitor did not announce goes chunked. Named here, since Go's // length the visitor did not announce goes chunked.
// client would otherwise send a CONNECT's body with neither length nor chunks, if in.ContentLength < 0 {
// which servewaf would read as further requests. question.ContentLength = -1
question.TransferEncoding = in.TransferEncoding }
question.Header = in.Header.Clone() question.Header = in.Header.Clone()
question.Header.Set(proxy.ClientHeader, rq.client.String()) question.Header.Set(proxy.ClientHeader, rq.client.String())
@@ -202,7 +196,7 @@ func (h *handler) postReport(ctx context.Context, report *proxy.Report) error {
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
res, err := h.serveWAFTransport.RoundTrip(req) res, err := h.transport.RoundTrip(req)
if err != nil { if err != nil {
return fmt.Errorf("send the report: %w", err) return fmt.Errorf("send the report: %w", err)
} }
+33 -248
View File
@@ -1,18 +1,14 @@
package smallwebwaf_test package smallwebwaf_test
import ( import (
"bufio"
"context" "context"
"encoding/pem"
"io" "io"
"maps" "maps"
"net" "net"
"net/http" "net/http"
"net/http/httptest"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"sync/atomic" "sync/atomic"
"syscall" "syscall"
@@ -116,12 +112,12 @@ func startServeWAF(t *testing.T, env map[string]string) (string, *output, func()
} }
// serveWAFSettings are servewaf's settings with the default rule file, to listen // serveWAFSettings are servewaf's settings with the default rule file, to listen
// at any free port. // at addr.
func serveWAFSettings(t *testing.T) map[string]string { func serveWAFSettings(t *testing.T, addr string) map[string]string {
t.Helper() t.Helper()
return map[string]string{ return map[string]string{
listenAddr: localhost + ":0", listenAddr: addr,
stateDir: t.TempDir(), stateDir: t.TempDir(),
rulesDir: filepath.Join("..", "..", "share", "rules.d"), rulesDir: filepath.Join("..", "..", "share", "rules.d"),
} }
@@ -130,7 +126,7 @@ func serveWAFSettings(t *testing.T) map[string]string {
func TestABanThroughOneSiteRefusesTheClientOnTheOther(t *testing.T) { func TestABanThroughOneSiteRefusesTheClientOnTheOther(t *testing.T) {
t.Parallel() t.Parallel()
addr, serveWAFOut, stop := startServeWAF(t, serveWAFSettings(t)) addr, serveWAFOut, stop := startServeWAF(t, serveWAFSettings(t, localhost+":0"))
defer stop() defer stop()
backend := "http://" + addr backend := "http://" + addr
@@ -185,7 +181,7 @@ func serveWAFLine(t *testing.T, out *output, id, kind string) map[string]any {
func TestTheSiteAnswersWhileServeWAFIsStoppedAndStartedAgain(t *testing.T) { func TestTheSiteAnswersWhileServeWAFIsStoppedAndStartedAgain(t *testing.T) {
t.Parallel() t.Parallel()
env := serveWAFSettings(t) env := serveWAFSettings(t, localhost+":0")
addr, _, stop := startServeWAF(t, env) addr, _, stop := startServeWAF(t, env)
site, out := startRProxy(t, "http://"+addr, startApp(t)) site, out := startRProxy(t, "http://"+addr, startApp(t))
@@ -265,244 +261,6 @@ func answersNormally(ctx context.Context, url, from string) bool {
return err == nil && res.StatusCode == http.StatusOK && string(body) == greeting return err == nil && res.StatusCode == http.StatusOK && string(body) == greeting
} }
// sendRaw sends request, as it is, to rproxy at site, and returns the status of
// the answer.
func sendRaw(t *testing.T, site, request string) int {
t.Helper()
conn, err := (&net.Dialer{}).DialContext(t.Context(), "tcp",
strings.TrimPrefix(site, "http://"))
if err != nil {
t.Fatalf("dial: %v", err)
}
defer func() {
_ = conn.Close()
}()
err = conn.SetDeadline(time.Now().Add(waitLimit))
if err != nil {
t.Fatalf("set the deadline: %v", err)
}
_, err = io.WriteString(conn, request)
if err != nil {
t.Fatalf("send: %v", err)
}
res, err := http.ReadResponse(bufio.NewReader(conn), nil)
if err != nil {
t.Fatalf("read the answer: %v", err)
}
_ = res.Body.Close()
return res.StatusCode
}
func TestAConnectBodyStaysABodyAtServeWAF(t *testing.T) {
t.Parallel()
addr, serveWAFOut, stop := startServeWAF(t, serveWAFSettings(t))
defer stop()
site, _ := startRProxy(t, "http://"+addr, startApp(t))
// Read as a further request, this body would ban otherClient on every site.
const smuggledID = "smuggled-request"
smuggled := "GET " + bannedProbe + " HTTP/1.1\r\nHost: site.example\r\n" +
proxy.ClientHeader + ": " + otherClient + "\r\n" +
"X-Request-ID: " + smuggledID + "\r\n\r\n"
status := sendRaw(t, site, "CONNECT /tunnel HTTP/1.1\r\nHost: site.example\r\n"+
"X-Forwarded-For: "+placed+"\r\nTransfer-Encoding: chunked\r\n\r\n"+
strconv.FormatInt(int64(len(smuggled)), 16)+"\r\n"+smuggled+"\r\n0\r\n\r\n")
if status != http.StatusForbidden {
t.Errorf("status %d, want the 403 of the Core Rule Set, which refuses CONNECT",
status)
}
// Read as a further request, it would act straight after the answer.
for range 20 {
wantStatus(t, site+"/", otherClient, http.StatusOK)
time.Sleep(pollInterval)
}
line := serveWAFOut.findLine(t, func(fields map[string]any) bool {
return fields["request_id"] == smuggledID
})
if line != nil {
t.Errorf("servewaf read the body as a request: %v", line)
}
}
func TestATargetThatCannotGoToServeWAFIsRefusedForABannedClient(t *testing.T) {
t.Parallel()
addr, _, stop := startServeWAF(t, serveWAFSettings(t))
defer stop()
site, out := startRProxy(t, "http://"+addr, startApp(t))
wantStatus(t, site+bannedProbe, placed, http.StatusForbidden)
status := sendRaw(t, site, "GET x:admin HTTP/1.1\r\nHost: site.example\r\n"+
"X-Forwarded-For: "+placed+"\r\n\r\n")
if status != http.StatusBadRequest {
t.Errorf("status %d, want 400", status)
}
line := out.line(t, "path", "admin")
if line["disposition"] != nil || line["default_disposition"] != nil {
t.Errorf("rproxy's line is %v, want no disposition", line)
}
}
func TestAClientWithAZoneIsTheClientWithout(t *testing.T) {
t.Parallel()
const zoned = "2001:db8::1%eth0"
addr, _, stop := startServeWAF(t, serveWAFSettings(t))
defer stop()
site, out := startRProxy(t, "http://"+addr, startApp(t))
wantStatus(t, site+bannedProbe, zoned, http.StatusForbidden)
wantStatus(t, site+"/", zoned, http.StatusForbidden)
wantStatus(t, site+"/", "2001:db8::1", http.StatusForbidden)
line := out.line(t, "path", bannedProbe)
if line["client_ip"] != "2001:db8::1" || line["default_disposition"] != nil {
t.Errorf("rproxy's line is %v", line)
}
}
func TestAStatusServeWAFDoesNotTakeIsReportedAs599(t *testing.T) {
t.Parallel()
const status = 799
app := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(status)
}))
t.Cleanup(app.Close)
addr, serveWAFOut, stop := startServeWAF(t, serveWAFSettings(t))
defer stop()
site, out := startRProxy(t, "http://"+addr, app.URL)
wantStatus(t, site+"/", placed, status)
line := out.line(t, "path", "/")
if line["status"] != float64(status) {
t.Errorf("rproxy's line is %v, want the status the visitor got", line)
}
id, _ := line["request_id"].(string)
report := serveWAFLine(t, serveWAFOut, id, "report")
if report["status"] != 599.0 {
t.Errorf("servewaf's line is %v, want status 599", report)
}
}
// startRProxyProcess runs `smallwebwaf rproxy` in a process of its own, with the
// settings in env, each as NAME=value. It returns where rproxy listens, the
// process, and what rproxy writes.
func startRProxyProcess(t *testing.T, env ...string) (string, *exec.Cmd, *output) {
t.Helper()
binary, err := os.Executable()
if err != nil {
t.Fatalf("find the test binary: %v", err)
}
ctx, cancel := context.WithTimeout(t.Context(), 3*waitLimit)
t.Cleanup(cancel)
out := &output{}
cmd := exec.CommandContext(ctx, binary) //nolint:gosec // this test binary
cmd.Stdout = out
cmd.Env = append(os.Environ(),
commandForMain+"=rproxy",
"SWRP_LISTEN_ADDR="+localhost+":0")
cmd.Env = append(cmd.Env, env...)
err = cmd.Start()
if err != nil {
t.Fatalf("start: %v", err)
}
t.Cleanup(func() {
_ = cmd.Process.Kill()
_ = cmd.Wait()
})
addr, _ := out.line(t, "msg", "starting")["address"].(string)
return addr, cmd, out
}
func TestRProxyAsksAServeWAFThatSpeaksHTTP2OverHTTP1(t *testing.T) {
t.Parallel()
protocols := make(chan string, 10)
backend := httptest.NewUnstartedServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(proxy.ClientHeader) != "" {
protocols <- r.Proto
w.Header().Set(proxy.DispositionHeader, proxy.DispositionDeny)
w.WriteHeader(http.StatusForbidden)
}
}))
backend.EnableHTTP2 = true
backend.StartTLS()
t.Cleanup(backend.Close)
// rproxy has no setting for the certificates it trusts, so it is told of this
// one as Go's client is told of any, in a process of its own.
certificates := filepath.Join(t.TempDir(), "servewaf.pem")
err := os.WriteFile(certificates, pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE", Bytes: backend.Certificate().Raw,
}), 0o600)
if err != nil {
t.Fatalf("write the certificate: %v", err)
}
addr, _, _ := startRProxyProcess(t,
wafBackend+"="+backend.URL,
"SWRP_UPSTREAM_URL="+startApp(t),
"SSL_CERT_FILE="+certificates)
for name, headers := range map[string]string{
"no such header": "",
"Upgrade": "Upgrade: anything\r\n",
"Connection": "Connection: anything\r\n",
"a WebSocket upgrade": "Connection: Upgrade\r\nUpgrade: websocket\r\n",
} {
status := sendRaw(t, addr, "GET / HTTP/1.1\r\nHost: site.example\r\n"+headers+"\r\n")
if status != http.StatusForbidden {
t.Errorf("with %s: status %d, want servewaf's 403", name, status)
}
select {
case protocol := <-protocols:
if protocol != "HTTP/1.1" {
t.Errorf("with %s: asked over %s, want HTTP/1.1", name, protocol)
}
case <-time.After(waitLimit):
t.Errorf("with %s: servewaf was not asked", name)
}
}
}
func TestRProxyInvalidSettingStopsTheStart(t *testing.T) { func TestRProxyInvalidSettingStopsTheStart(t *testing.T) {
t.Parallel() t.Parallel()
@@ -563,8 +321,35 @@ func TestRProxyAddressInUseStopsTheStart(t *testing.T) {
func TestMainRunsRProxy(t *testing.T) { func TestMainRunsRProxy(t *testing.T) {
t.Parallel() t.Parallel()
binary, err := os.Executable()
if err != nil {
t.Fatalf("find the test binary: %v", err)
}
ctx, cancel := context.WithTimeout(t.Context(), 3*waitLimit)
defer cancel()
out := &output{}
cmd := exec.CommandContext(ctx, binary) //nolint:gosec // this test binary
cmd.Stdout = out
cmd.Env = append(os.Environ(),
commandForMain+"=rproxy",
"SWRP_LISTEN_ADDR="+localhost+":0",
// Nothing listens there; the health check does not ask. // Nothing listens there; the health check does not ask.
addr, cmd, out := startRProxyProcess(t, wafBackend+"=http://127.0.0.1:9") wafBackend+"=http://127.0.0.1:9")
err = cmd.Start()
if err != nil {
t.Fatalf("start: %v", err)
}
t.Cleanup(func() {
_ = cmd.Process.Kill()
_ = cmd.Wait()
})
addr, _ := out.line(t, "msg", "starting")["address"].(string)
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, req, err := http.NewRequestWithContext(t.Context(), http.MethodGet,
"http://"+addr+proxy.HealthPath, http.NoBody) "http://"+addr+proxy.HealthPath, http.NoBody)