1 Commits
Author SHA1 Message Date
sneak 553bbcbe95 rproxy, the proxy in front of each app, asks servewaf and fails open (closes #171)
check / check (push) Failing after 1m14s
`smallwebwaf rproxy` reads the first 64 KiB of each body, asks servewaf over
HTTP/1.1, 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. A request it cannot put to servewaf as
sent, it refuses itself.

Duplication: internal/rproxy copies passing-on code from internal/proxy until
#172 removes the originals.
Rule suppressed: gosec G704 and G705, on servewaf's URL and the visitor's answer.
Rule suppressed: paralleltest, on the test of a switch rproxy refuses.
Judgement call: the SWRP_ size and time limits take off; SWRP_TIMEOUT_MS does not.
Judgement call: every target not starting with one / gets 400.

Model: opus-5-5
2026-11-05 02:11:02 +00:00
12 changed files with 135 additions and 486 deletions

No files matched your search

+2 -4
View File
@@ -353,10 +353,8 @@ pinned.
each request, and passes nothing to an app (see "`servewaf`" in each request, and passes nothing to an app (see "`servewaf`" in
[`SPEC.md`](SPEC.md)). [`SPEC.md`](SPEC.md)).
- `smallwebwaf rproxy` is the reverse proxy in front of one app that asks - `smallwebwaf rproxy` is the reverse proxy in front of one app that asks
`smallwebwaf servewaf` about each request. When `servewaf` cannot be connected `smallwebwaf servewaf` about each request, and keeps the site answering while
to, does not answer at all, or does not answer within `SWRP_TIMEOUT_MS`, `servewaf` is down. Its settings start with `SWRP_`, and it needs
`SWRP_DEFAULT_DISPOSITION` decides. Its default, `allow`, keeps the site
answering. The settings of `rproxy` start with `SWRP_`, and it needs
`SWRP_WAF_BACKEND`, `servewaf`'s URL (see "`rproxy`" in [`SPEC.md`](SPEC.md)). `SWRP_WAF_BACKEND`, `servewaf`'s URL (see "`rproxy`" in [`SPEC.md`](SPEC.md)).
- `docker stop` stops both processes at once. `smallwebwaf` gives the requests - `docker stop` stops both processes at once. `smallwebwaf` gives the requests
in progress five seconds to finish, so the app must keep answering for five in progress five seconds to finish, so the app must keep answering for five
+23 -55
View File
@@ -1066,8 +1066,8 @@ request. It is the visitor's request as the visitor sent it:
- a `Host` that is not empty and holds no IPv6 zone; - a `Host` that is not empty and holds no IPv6 zone;
- at most one `User-Agent`. - at most one `User-Agent`.
A body whose length the visitor did not announce comes chunked. Any other body A body whose length the visitor did not announce comes chunked, whatever the
comes with a `Content-Length` of the part it carries. 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:
@@ -1135,8 +1135,7 @@ Every answer but the last below carries `X-Smallwebwaf-Disposition`.
place of a refusal while `SWWAF_BAN_RESPONSE` is `close`. `rproxy` closes the place of a refusal while `SWWAF_BAN_RESPONSE` is `close`. `rproxy` closes the
visitor's connection without an answer. visitor's connection without an answer.
- No such header, status `400`, for a question `servewaf` refuses. `rproxy` - No such header, status `400`, for a question `servewaf` refuses. `rproxy`
gives the visitor its status, headers and body, as it does for any answer treats this answer as it treats no answer at all.
without a valid `X-Smallwebwaf-Disposition`.
In `observe` mode a request that `enforce` mode would refuse is answered In `observe` mode a request that `enforce` mode would refuse is answered
`allow`, and its log line has `would_action`. A path under `/_smallwebwaf/` is `allow`, and its log line has `would_action`. A path under `/_smallwebwaf/` is
@@ -1151,14 +1150,9 @@ short.
Once the visitor's answer has ended, `rproxy` sends `POST /_smallwebwaf/report` Once the visitor's answer has ended, `rproxy` sends `POST /_smallwebwaf/report`
for each request `servewaf` answered with `allow`, `deny` or `close`. It sends for each request `servewaf` answered with `allow`, `deny` or `close`. It sends
none after: none after a `400` or no answer. The report carries no `X-Smallwebwaf-Client`.
Its body is one JSON object with all of these fields, named as the request log's
- an answer without a valid `X-Smallwebwaf-Disposition`, such as a `400`; fields of the same meaning:
- an answer it cannot pass on (see "Without an answer from `servewaf`");
- no answer.
The report carries no `X-Smallwebwaf-Client`. Its body is one JSON object with
all of these fields, named as the request log's fields of the same meaning:
- `request_id`: the question's `X-Request-ID`; - `request_id`: the question's `X-Request-ID`;
- `status`: what the visitor got, `0` if nothing, otherwise from `100` to `599`; - `status`: what the visitor got, `0` if nothing, otherwise from `100` to `599`;
@@ -1248,10 +1242,8 @@ by a request and ban notes name the site by its host.
`smallwebwaf rproxy` is the reverse proxy in front of one app. It asks `smallwebwaf rproxy` is the reverse proxy in front of one app. It asks
`servewaf` about each request, and passes on what `servewaf` allows. It keeps `servewaf` about each request, and passes on what `servewaf` allows. It keeps
nothing between requests and never touches disk. When `servewaf` cannot be nothing between requests and never touches disk. When `servewaf` is down, slow
connected to, does not answer at all, or does not answer within or overloaded, the site keeps answering.
`SWRP_TIMEOUT_MS`, `SWRP_DEFAULT_DISPOSITION` decides. Its default, `allow`,
keeps the site answering.
`rproxy` reads only its `SWRP_` settings, which `docs/settings.md` lists. One of `rproxy` reads only its `SWRP_` settings, which `docs/settings.md` lists. One of
them is required: `SWRP_WAF_BACKEND`, `servewaf`'s URL. It has no metrics, no them is required: `SWRP_WAF_BACKEND`, `servewaf`'s URL. It has no metrics, no
@@ -1270,9 +1262,8 @@ and health check.
request" says. It removes any IPv6 zone. request" says. It removes any IPv6 zone.
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 with a method it does not pass on, or one it cannot put 4. It refuses a request it cannot put to `servewaf` as the visitor sent it (see
to `servewaf` as the visitor sent it (see "Requests `rproxy` refuses "Requests `rproxy` refuses itself").
itself").
5. It reads up to the first 64 KiB of the body into memory. 5. It reads up to the first 64 KiB of the body into memory.
6. It sends `servewaf` the question (see "The question"). It speaks HTTP/1.1 to 6. It sends `servewaf` the question (see "The question"). It speaks HTTP/1.1 to
`servewaf`, whatever `servewaf` offers. Go's HTTP/2 client would refuse to `servewaf`, whatever `servewaf` offers. Go's HTTP/2 client would refuse to
@@ -1280,11 +1271,9 @@ and health check.
question never carries the visitor's `Trailer`. question never carries the visitor's `Trailer`.
7. It does what the answer says (see "The answer"): 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.
- `close`: it closes the visitor's connection without an answer. - `deny`: the visitor gets the answer's status, headers and body, less
- Any other answer: the visitor gets its status, headers and body, less
`X-Smallwebwaf-Disposition`. That holds for a `deny`, for an error such as
`servewaf`'s `400` or a `5xx`, and for an answer without a valid
`X-Smallwebwaf-Disposition`. `X-Smallwebwaf-Disposition`.
- `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` 8. Once the answer has ended, it writes its log line. `docs/request-log.md`
gives the fields. An app's answer can end just after the line: Go's server gives the fields. An app's answer can end just after the line: Go's server
sends the closing chunk of a chunked answer, and may send all of an empty sends the closing chunk of a chunked answer, and may send all of an empty
@@ -1297,27 +1286,11 @@ and health check.
### Requests `rproxy` refuses itself ### Requests `rproxy` refuses itself
`SWRP_DEFAULT_DISPOSITION` applies only when `servewaf` cannot be connected to, `SWRP_DEFAULT_DISPOSITION` applies only when `servewaf` itself is down, slow or
does not answer at all, or does not answer within `SWRP_TIMEOUT_MS`. A visitor answers badly. A visitor can make `servewaf` slow. A request that keeps
can make `servewaf` slow. A request that keeps `servewaf` busy past `servewaf` busy past `SWRP_TIMEOUT_MS`, like any request while `servewaf` is
`SWRP_TIMEOUT_MS`, like any request while `servewaf` is overloaded, gets overloaded, gets `SWRP_DEFAULT_DISPOSITION`. `deny` closes this, at the cost of
`SWRP_DEFAULT_DISPOSITION`. `deny` closes this, at the cost of answering `503` answering `503` whenever `servewaf` is slow.
whenever `servewaf` is slow. Making such requests hard to send is
https://git.eeqj.de/sneak/smallwebwaf/issues/182.
`rproxy` passes on only these methods:
- `GET`
- `HEAD`
- `POST`
- `OPTIONS`
- `PUT`
- `DELETE`
- `PATCH`
- `QUERY`
It answers any other method `405`, with no question and no report. The answer's
`Allow` header names these methods.
`rproxy` answers `400`, with no question and no report, to a request with: `rproxy` answers `400`, with no question and no report, to a request with:
@@ -1340,15 +1313,15 @@ log line has status `0`.
### Without an answer from `servewaf` ### Without an answer from `servewaf`
`SWRP_DEFAULT_DISPOSITION` decides what is done with the request only when `SWRP_DEFAULT_DISPOSITION` decides what is done with the request when
`servewaf`: `servewaf`:
- cannot be connected to;
- does not answer at all;
- does not answer within `SWRP_TIMEOUT_MS`, which covers connecting, sending the - does not answer within `SWRP_TIMEOUT_MS`, which covers connecting, sending the
question and reading the whole answer. question and reading the whole answer;
- cannot be reached;
An answer whose body breaks off counts as no answer at all. - answers without a valid `X-Smallwebwaf-Disposition`, as with the `400` for a
question it refuses;
- sends an answer whose body breaks off or is over 1 MiB.
`allow`, the default, passes the request to the app. `deny` answers `503`. `allow`, the default, passes the request to the app. `deny` answers `503`.
Either way `rproxy` writes a process warning, its log line has Either way `rproxy` writes a process warning, its log line has
@@ -1357,11 +1330,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.
`rproxy` cannot pass on an answer whose body is over 1 MiB or whose status is
below `100`. The visitor gets `502` from `rproxy`, never the default. `rproxy`
writes a process warning, its log line has no `disposition`, and it sends no
report.
A visitor that goes away while `servewaf` is being asked is no failure of 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 `servewaf`. `rproxy` closes the connection, with no warning, no default and no
report. Its log line has status `0` and no `disposition`. report. Its log line has status `0` and no `disposition`.
+2 -5
View File
@@ -204,11 +204,8 @@ 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: out when `servewaf` was not asked, as for the health check, and when the
- `servewaf` was not asked, as for the health check; visitor went away while it was being asked.
- the visitor went away while `servewaf` was being asked;
- `servewaf`'s answer had no valid `X-Smallwebwaf-Disposition`;
- `rproxy` could not pass on `servewaf`'s answer, and answered `502`.
- `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.
+6 -8
View File
@@ -341,10 +341,9 @@ A few limits are fixed rather than settings:
- at most 20,000 anomaly counters are kept; - at most 20,000 anomaly counters are kept;
- each alert destination's queue holds at most 1,000 alerts; - each alert destination's queue holds at most 1,000 alerts;
- `servewaf` holds at most 10,000 answered requests awaiting their reports; - `servewaf` holds at most 10,000 answered requests awaiting their reports;
- `rproxy` takes a request line and headers of at most 32 KiB, and closes a - `rproxy` takes a request line and headers of at most 32 KiB, closes a
kept-open connection idle for 120 seconds; kept-open connection idle for 120 seconds, and reads at most 1 MiB of the body
- `rproxy` reads at most 1 MiB of the body of `servewaf`'s answer, and answers of `servewaf`'s answer.
`502` to a longer one.
## `servewaf` ## `servewaf`
@@ -377,10 +376,9 @@ A time here is a whole number of milliseconds above zero, such as `2000`.
speaks HTTP/1.1 to it. 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 when - `SWRP_DEFAULT_DISPOSITION` (default `allow`): what is done with a request
`servewaf` cannot be connected to, does not answer at all, or does not answer `servewaf` gives no answer about. `allow` passes it to the app, and `deny`
within `SWRP_TIMEOUT_MS`. `allow` passes it to the app, and `deny` answers answers `503`.
`503`. It does not apply to an answer `servewaf` gives, such as an error.
- `SWRP_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A size or - `SWRP_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A size or
`off`. `off`.
- `SWRP_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A size or - `SWRP_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A size or
+4 -6
View File
@@ -27,9 +27,8 @@ type RProxy struct {
// Timeout is how long rproxy waits for servewaf's answer to a question, // Timeout is how long rproxy waits for servewaf's answer to a question,
// and to a report (SWRP_TIMEOUT_MS). It cannot be off. // and to a report (SWRP_TIMEOUT_MS). It cannot be off.
Timeout time.Duration Timeout time.Duration
// DefaultDisposition is what is done with a request when servewaf cannot be // DefaultDisposition is what is done with a request servewaf gives no
// connected to, does not answer at all, or does not answer within Timeout: // answer about, allow or deny (SWRP_DEFAULT_DISPOSITION).
// allow or deny (SWRP_DEFAULT_DISPOSITION).
DefaultDisposition string DefaultDisposition string
// MaxRequestSize is the largest request body (SWRP_MAX_REQUEST_SIZE), and // MaxRequestSize is the largest request body (SWRP_MAX_REQUEST_SIZE), and
// MaxResponseSize the largest response body (SWRP_MAX_RESPONSE_SIZE). // MaxResponseSize the largest response body (SWRP_MAX_RESPONSE_SIZE).
@@ -114,9 +113,8 @@ func (e *environment) wafBackend(name string) *url.URL {
return backend return backend
} }
// disposition reads the setting that is what is done with a request when servewaf // disposition reads the setting that is what is done with a request servewaf
// cannot be connected to, does not answer at all, or does not answer in time: // gives no answer about: allow or deny.
// allow or deny.
func (e *environment) disposition(name, defaultValue string) string { func (e *environment) disposition(name, defaultValue string) string {
value := e.value(name, defaultValue) value := e.value(name, defaultValue)
if value != "allow" && value != "deny" { if value != "allow" && value != "deny" {
+2 -3
View File
@@ -216,9 +216,8 @@ type RProxyLine struct {
// if that is sooner. DurationResponse runs from then until the answer ends. // if that is sooner. DurationResponse runs from then until the answer ends.
DurationRequest float64 `json:"duration_request"` DurationRequest float64 `json:"duration_request"`
DurationResponse float64 `json:"duration_response"` DurationResponse float64 `json:"duration_response"`
// Disposition is the valid disposition servewaf answered or, while // Disposition is servewaf's answer or, while DefaultDisposition is true, the
// DefaultDisposition is true, the value of SWRP_DEFAULT_DISPOSITION. It is "" // value of SWRP_DEFAULT_DISPOSITION. It is "" when servewaf was not asked.
// when neither gave one.
Disposition string `json:"disposition,omitempty"` Disposition string `json:"disposition,omitempty"`
DefaultDisposition bool `json:"default_disposition,omitempty"` DefaultDisposition bool `json:"default_disposition,omitempty"`
} }
+2 -69
View File
@@ -255,7 +255,7 @@ func TestAQuestionCarriesItsBodyWhateverTheMethod(t *testing.T) {
{"with its length", announcedLength(4) + "body", "body", nil}, {"with its length", announcedLength(4) + "body", "body", nil},
} { } {
for _, method := range []string{ for _, method := range []string{
http.MethodGet, http.MethodDelete, http.MethodPost, http.MethodConnect, http.MethodGet, http.MethodDelete, http.MethodPost,
} { } {
t.Run(method+" "+tc.name, func(t *testing.T) { t.Run(method+" "+tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -281,73 +281,6 @@ func TestAQuestionCarriesItsBodyWhateverTheMethod(t *testing.T) {
} }
} }
func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
t.Parallel()
for _, method := range []string{http.MethodTrace, "PROPFIND", "LOCK", "BREW"} {
t.Run(method, func(t *testing.T) {
t.Parallel()
servewaf := startServeWAF(t, allow)
app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked")
})
addr, out := startRProxy(t, servewaf.URL, app.URL, nil)
got := visit(t, newRequest(t, method, addr, "/", http.NoBody))
if got.status != http.StatusMethodNotAllowed ||
got.header.Get("Allow") != "GET, HEAD, POST, OPTIONS, PUT, DELETE, PATCH, QUERY" {
t.Errorf("answered %+v, want 405 naming the methods passed on", got)
}
line := out.requestLine(t)
if line["status"] != float64(http.StatusMethodNotAllowed) ||
line["disposition"] != nil || line["default_disposition"] != nil {
t.Errorf("line %v, want status 405 and no disposition", line)
}
if len(servewaf.questions) != 0 {
t.Error("servewaf was asked")
}
servewaf.wantNoReport(t)
})
}
}
func TestEachMethodPassedOnReachesServeWAFAndTheApp(t *testing.T) {
t.Parallel()
for _, method := range []string{
http.MethodGet, http.MethodHead, http.MethodPost, http.MethodOptions,
http.MethodPut, http.MethodDelete, http.MethodPatch, "QUERY",
} {
t.Run(method, func(t *testing.T) {
t.Parallel()
reached := make(chan string, 1)
servewaf := startServeWAF(t, allow)
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
reached <- r.Method
})
addr, _ := startRProxy(t, servewaf.URL, app.URL, nil)
got := visit(t, newRequest(t, method, addr, "/", http.NoBody))
if got.status != http.StatusOK {
t.Errorf("answered %+v, want the app's 200", got)
}
if asked := servewaf.question(t); asked.method != method {
t.Errorf("servewaf was asked about %s", asked.method)
}
if atApp := <-reached; atApp != method {
t.Errorf("the app was asked %s", atApp)
}
})
}
}
func TestARequestThatCannotGoToServeWAFAsSentIsRefused(t *testing.T) { func TestARequestThatCannotGoToServeWAFAsSentIsRefused(t *testing.T) {
t.Parallel() t.Parallel()
@@ -357,7 +290,7 @@ func TestARequestThatCannotGoToServeWAFAsSentIsRefused(t *testing.T) {
"a target of a scheme": "GET x:admin HTTP/1.1\r\n" + host, "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 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 host Go will not send": "GET http://a<b/ HTTP/1.1\r\n" + host,
"a target of a host": "GET site.example:443 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, "a target of *": "GET * HTTP/1.1\r\n" + host,
"a target of //": "GET //x/y HTTP/1.1\r\n" + host, "a target of //": "GET //x/y HTTP/1.1\r\n" + host,
"a target of // escaped": "GET //x%2Fadmin| HTTP/1.1\r\n" + host, "a target of // escaped": "GET //x%2Fadmin| HTTP/1.1\r\n" + host,
+2 -2
View File
@@ -49,8 +49,8 @@ type request struct {
peerTrusted bool peerTrusted bool
start time.Time start time.Time
// disposition is what is done with the request: servewaf's valid disposition, // disposition is what is done with the request, "" until servewaf has been
// or SWRP_DEFAULT_DISPOSITION. It is "" when neither gave one. // asked.
disposition string disposition string
// answered is true when servewaf gave disposition, which makes a report due, // answered is true when servewaf gave disposition, which makes a report due,
// and byDefault when SWRP_DEFAULT_DISPOSITION did. // and byDefault when SWRP_DEFAULT_DISPOSITION did.
+18 -34
View File
@@ -1,12 +1,9 @@
// 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. Any other answer from // app's answer back, within the size and time limits. When servewaf gives no
// servewaf goes to the visitor as servewaf gave it. SWRP_DEFAULT_DISPOSITION // answer, SWRP_DEFAULT_DISPOSITION decides. A request it cannot put to servewaf
// decides only when servewaf cannot be connected to, does not answer at all, or // as the visitor sent it, it refuses itself. It keeps nothing between requests
// does not answer within SWRP_TIMEOUT_MS. It refuses itself a request with a // and never touches disk. "rproxy" in SPEC.md says what it does.
// method it does not pass on, or one it cannot put to servewaf as the visitor
// sent it. It keeps nothing between requests and never touches disk. "rproxy" in
// SPEC.md says what it does.
package rproxy package rproxy
import ( import (
@@ -16,7 +13,6 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"net/url" "net/url"
"slices"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -49,10 +45,6 @@ const (
// never reaches the app. // never reaches the app.
const adminPrefix = "/_smallwebwaf/" const adminPrefix = "/_smallwebwaf/"
// allowedMethods lists the methods rproxy passes on, as an Allow header gives
// them. It answers any other method 405 itself.
const allowedMethods = "GET, HEAD, POST, OPTIONS, PUT, DELETE, PATCH, QUERY"
// Params are what New needs. // Params are what New needs.
type Params struct { type Params struct {
Config *config.RProxy Config *config.RProxy
@@ -158,10 +150,10 @@ func newServeWAFTransport() *http.Transport {
} }
// ServeHTTP handles one request: it answers the health check itself, refuses a // ServeHTTP handles one request: it answers the health check itself, refuses a
// request with a method it does not pass on or one it cannot put to servewaf as // request it cannot put to servewaf as the visitor sent it, and for any other
// the visitor sent it, and for any other request reads the first part of the // request reads the first part of the body, asks servewaf, and does what the
// body, asks servewaf, and does what the answer says. Once the answer has ended, // answer says. Once the answer has ended, it writes the request's log line and
// it writes the request's log line and sends its report. // 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())
@@ -172,13 +164,6 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return return
} }
if !slices.Contains(strings.Split(allowedMethods, ", "), r.Method) {
rq.out.Header().Set("Allow", allowedMethods)
rq.answer(http.StatusMethodNotAllowed)
return
}
if !canBeAsked(r) { if !canBeAsked(r) {
rq.answer(http.StatusBadRequest) rq.answer(http.StatusBadRequest)
@@ -189,24 +174,23 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return return
} }
answer, err := rq.ask(r.Context()) answer := rq.askServeWAF(r.Context())
if err != nil {
rq.handleNoUsableAnswer(r.Context(), err)
return
}
rq.noteAnswer(answer)
switch rq.disposition { switch rq.disposition {
case proxy.DispositionAllow: case proxy.DispositionAllow:
rq.passToApp(r.Context()) rq.passToApp(r.Context())
case proxy.DispositionClose: case proxy.DispositionDeny:
if answer == nil {
rq.answer(http.StatusServiceUnavailable)
return
}
rq.answerWith(answer)
default: // proxy.DispositionClose, or none when the visitor has gone
// 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)
default: // a deny, an error, or an answer without a valid disposition
rq.answerWith(answer)
} }
} }
+35 -240
View File
@@ -63,9 +63,6 @@ const (
contentLengthHeader = "Content-Length" contentLengthHeader = "Content-Length"
// fromServeWAFHeader is a header the stand-in for servewaf adds to a deny. // fromServeWAFHeader is a header the stand-in for servewaf adds to a deny.
fromServeWAFHeader = "X-From-Servewaf" fromServeWAFHeader = "X-From-Servewaf"
// addedHeaderValue is the value of a header a test adds to an answer, to see it
// reach the visitor.
addedHeaderValue = "yes"
) )
// What the stand-in for servewaf answers. // What the stand-in for servewaf answers.
@@ -295,7 +292,7 @@ func allow(w http.ResponseWriter, _ *http.Request) {
// deny answers a question with deny, status 403 and a body of its own. // deny answers a question with deny, status 403 and a body of its own.
func deny(w http.ResponseWriter, _ *http.Request) { func deny(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(dispositionHeader, dispositionDeny) w.Header().Set(dispositionHeader, dispositionDeny)
w.Header().Set(fromServeWAFHeader, addedHeaderValue) w.Header().Set(fromServeWAFHeader, "yes")
w.WriteHeader(http.StatusForbidden) w.WriteHeader(http.StatusForbidden)
_, _ = io.WriteString(w, deniedBody) _, _ = io.WriteString(w, deniedBody)
} }
@@ -508,7 +505,7 @@ func TestPassesAnAllowedRequestToTheAppAndItsAnswerBack(t *testing.T) {
app := startApp(t, func(w http.ResponseWriter, r *http.Request) { app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
seen <- r.Clone(t.Context()) seen <- r.Clone(t.Context())
w.Header().Set("X-App", addedHeaderValue) w.Header().Set("X-App", "yes")
w.WriteHeader(http.StatusTeapot) w.WriteHeader(http.StatusTeapot)
_, _ = io.WriteString(w, greeting) _, _ = io.WriteString(w, greeting)
}) })
@@ -525,7 +522,7 @@ func TestPassesAnAllowedRequestToTheAppAndItsAnswerBack(t *testing.T) {
got := visit(t, req) got := visit(t, req)
if got.status != http.StatusTeapot || got.body != greeting || if got.status != http.StatusTeapot || got.body != greeting ||
got.header.Get("X-App") != addedHeaderValue { got.header.Get("X-App") != "yes" {
t.Errorf("answered %+v, want the app's answer", got) t.Errorf("answered %+v, want the app's answer", got)
} }
@@ -712,7 +709,7 @@ func TestDenyGivesTheVisitorServeWAFsAnswer(t *testing.T) {
got := get(t, addr, "/") got := get(t, addr, "/")
if got.status != http.StatusForbidden || got.body != deniedBody || if got.status != http.StatusForbidden || got.body != deniedBody ||
got.header.Get(fromServeWAFHeader) != addedHeaderValue { got.header.Get(fromServeWAFHeader) != "yes" {
t.Errorf("answered %+v, want servewaf's answer", got) t.Errorf("answered %+v, want servewaf's answer", got)
} }
@@ -846,27 +843,22 @@ func TestCloseClosesTheConnectionWithoutAnAnswer(t *testing.T) {
} }
} }
// answeringWith returns what starts a stand-in for servewaf that answers each // noAnswers are the ways servewaf can fail to give an answer that holds.
// question as answer does, and returns its URL.
func answeringWith(answer http.HandlerFunc) func(t *testing.T) string {
return func(t *testing.T) string {
t.Helper()
return startServeWAF(t, answer).URL
}
}
// noAnswers are the ways servewaf can leave the decision to the default.
func noAnswers() map[string]func(t *testing.T) string { func noAnswers() map[string]func(t *testing.T) string {
answeringWith := func(answer http.HandlerFunc) func(t *testing.T) string {
return func(t *testing.T) string {
t.Helper()
return startServeWAF(t, answer).URL
}
}
return map[string]func(t *testing.T) string{ return map[string]func(t *testing.T) string{
"no connection": func(*testing.T) string { return nowhere }, "down": func(*testing.T) string { return nowhere },
"no answer at all": answeringWith(func(http.ResponseWriter, *http.Request) { "too slow": answeringWith(func(_ http.ResponseWriter, r *http.Request) {
panic(http.ErrAbortHandler)
}),
"a slow start": answeringWith(func(_ http.ResponseWriter, r *http.Request) {
<-r.Context().Done() <-r.Context().Done()
}), }),
"headers followed by a stalled body": answeringWith( "too slow after its headers": answeringWith(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(dispositionHeader, dispositionDeny) w.Header().Set(dispositionHeader, dispositionDeny)
w.WriteHeader(http.StatusForbidden) w.WriteHeader(http.StatusForbidden)
@@ -874,6 +866,17 @@ func noAnswers() map[string]func(t *testing.T) string {
<-r.Context().Done() <-r.Context().Done()
}), }),
"no disposition": answeringWith(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}),
"a question refused": answeringWith(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "refused", http.StatusBadRequest)
}),
"an unknown disposition": answeringWith(
func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(dispositionHeader, "maybe")
w.WriteHeader(http.StatusOK)
}),
"an answer cut short": answeringWith( "an answer cut short": answeringWith(
func(w http.ResponseWriter, _ *http.Request) { func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(dispositionHeader, dispositionDeny) w.Header().Set(dispositionHeader, dispositionDeny)
@@ -885,220 +888,14 @@ func noAnswers() map[string]func(t *testing.T) string {
panic(http.ErrAbortHandler) panic(http.ErrAbortHandler)
}), }),
"an answer too long": answeringWith(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(dispositionHeader, dispositionDeny)
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write(make([]byte, 1<<20+1))
}),
} }
} }
func TestAnAnswerOtherThanAllowGoesToTheVisitorAsServeWAFGaveIt(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name, disposition, body string
status int
}{
{"a refusal", dispositionDeny, deniedBody, http.StatusForbidden},
{"a refusal, 429", dispositionDeny, deniedBody, http.StatusTooManyRequests},
{"servewaf's 400", "", "refused\n", http.StatusBadRequest},
{"a 500", "", "broken\n", http.StatusInternalServerError},
{"a 503 denied", dispositionDeny, "busy\n", http.StatusServiceUnavailable},
{"no header", "", "who knows\n", http.StatusOK},
{"an unknown disposition", "maybe", "who knows\n", http.StatusOK},
} {
for _, setting := range []string{dispositionAllow, dispositionDeny} {
t.Run(tc.name+" with the default "+setting, func(t *testing.T) {
t.Parallel()
servewaf := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
if tc.disposition != "" {
w.Header().Set(dispositionHeader, tc.disposition)
}
w.Header().Set(fromServeWAFHeader, addedHeaderValue)
w.WriteHeader(tc.status)
_, _ = io.WriteString(w, tc.body)
})
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{
defaultDisposition: setting,
})
got := get(t, addr, "/")
if got.status != tc.status || got.body != tc.body ||
got.header.Get(fromServeWAFHeader) != addedHeaderValue {
t.Errorf("answered %+v, want servewaf's answer", got)
}
if len(got.header.Values(dispositionHeader)) != 0 {
t.Errorf("the visitor got %s", dispositionHeader)
}
line := out.requestLine(t)
if line["status"] != float64(tc.status) || line["default_disposition"] != nil {
t.Errorf("line %v, want status %d and no default", line, tc.status)
}
if out.has("servewaf gave no answer") {
t.Error("rproxy warned that servewaf gave no answer")
}
})
}
}
}
func TestAnAnswerWithoutAValidDispositionGetsNoReport(t *testing.T) {
t.Parallel()
servewaf := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "refused", http.StatusBadRequest)
})
app := startApp(t, greet)
addr, out := startRProxy(t, servewaf.URL, app.URL, nil)
if got := get(t, addr, "/"); got.status != http.StatusBadRequest {
t.Errorf("answered %+v, want servewaf's 400", got)
}
if line := out.requestLine(t); line["disposition"] != nil {
t.Errorf("line %v, want no disposition", line)
}
servewaf.wantNoReport(t)
}
func TestAnAnswerWithoutAContentTypeGoesToTheVisitorWithNone(t *testing.T) {
t.Parallel()
servewaf := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header()["Content-Type"] = nil
w.WriteHeader(http.StatusInternalServerError)
_, _ = io.WriteString(w, "<html>broken")
})
addr, _ := startRProxy(t, servewaf.URL, nowhere, nil)
got := get(t, addr, "/")
if contentType, sent := got.header["Content-Type"]; sent {
t.Errorf("the visitor got the Content-Type %v", contentType)
}
}
func TestAnAnswerOf1MiBGoesToTheVisitor(t *testing.T) {
t.Parallel()
const size = 1 << 20
servewaf := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write(make([]byte, size))
})
addr, _ := startRProxy(t, servewaf.URL, nowhere, nil)
got := get(t, addr, "/")
if got.status != http.StatusInternalServerError || len(got.body) != size {
t.Errorf("answered %d with %d bytes, want servewaf's 500 with %d",
got.status, len(got.body), size)
}
}
// startServeWAFWriting starts a stand-in for servewaf that reads each question and
// answers it with text, as it is, and returns its URL. It can send what Go's
// server would not.
func startServeWAFWriting(t *testing.T, text string) string {
t.Helper()
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() {
_ = listener.Close()
})
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
_ = conn.SetDeadline(time.Now().Add(waitLimit))
_, _ = http.ReadRequest(bufio.NewReader(conn))
_, _ = io.WriteString(conn, text)
_ = conn.Close()
}
}()
return "http://" + listener.Addr().String()
}
// overOneMiB answers a question with a body over the most rproxy reads, and with
// disposition unless it is "".
func overOneMiB(disposition string) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
if disposition != "" {
w.Header().Set(dispositionHeader, disposition)
}
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write(make([]byte, 1<<20+1))
}
}
func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
t.Parallel()
for name, backend := range map[string]func(t *testing.T) string{
"a deny over 1 MiB": answeringWith(overOneMiB(dispositionDeny)),
"an allow over 1 MiB": answeringWith(overOneMiB(dispositionAllow)),
"no header over 1 MiB": answeringWith(overOneMiB("")),
"a status below 100": func(t *testing.T) string {
t.Helper()
return startServeWAFWriting(t, "HTTP/1.1 099 Odd\r\nContent-Length: 0\r\n\r\n")
},
} {
for _, setting := range []string{dispositionAllow, dispositionDeny} {
t.Run(name+" with the default "+setting, func(t *testing.T) {
t.Parallel()
app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked")
})
addr, out := startRProxy(t, backend(t), app.URL, map[string]string{
defaultDisposition: setting,
})
if got := get(t, addr, "/"); got.status != http.StatusBadGateway {
t.Errorf("answered %+v, want 502", got)
}
line := out.requestLine(t)
if line["status"] != float64(http.StatusBadGateway) ||
line["disposition"] != nil || line["default_disposition"] != nil {
t.Errorf("line %v, want status 502 and no disposition", line)
}
out.waitFor(t, "servewaf's answer is dropped")
})
}
}
}
func TestAnAnswerThatCannotBePassedOnGetsNoReport(t *testing.T) {
t.Parallel()
servewaf := startServeWAF(t, overOneMiB(dispositionDeny))
addr, out := startRProxy(t, servewaf.URL, nowhere, nil)
if got := get(t, addr, "/"); got.status != http.StatusBadGateway {
t.Errorf("answered %+v, want 502", got)
}
out.requestLine(t)
servewaf.wantNoReport(t)
}
func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) { func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1187,13 +984,11 @@ func TestAVisitorThatGoesAwayWhileServeWAFIsAskedGetsNoDefault(t *testing.T) {
func TestSendsNoReportWithoutAnAnswer(t *testing.T) { func TestSendsNoReportWithoutAnAnswer(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, func(_ http.ResponseWriter, r *http.Request) { servewaf := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
<-r.Context().Done() http.Error(w, "refused", http.StatusBadRequest)
}) })
app := startApp(t, greet) app := startApp(t, greet)
addr, out := startRProxy(t, servewaf.URL, app.URL, map[string]string{ addr, out := startRProxy(t, servewaf.URL, app.URL, nil)
timeoutSetting: "200",
})
wantGreeting(t, get(t, addr, "/")) wantGreeting(t, get(t, addr, "/"))
out.requestLine(t) out.requestLine(t)
+34 -56
View File
@@ -23,7 +23,8 @@ const answerBodyMaxBytes = 1 << 20
const reportAnswerMaxBytes = 4 << 10 const reportAnswerMaxBytes = 4 << 10
var ( var (
errCannotPassOn = errors.New("the answer cannot be passed to the visitor") errNoDisposition = errors.New("the answer has no valid " + proxy.DispositionHeader)
errAnswerTooLong = errors.New("the answer's body is over 1 MiB")
errReportRefused = errors.New("the report was answered") errReportRefused = errors.New("the report was answered")
) )
@@ -34,57 +35,40 @@ type servewafAnswer struct {
body []byte body []byte
} }
// noteAnswer notes what servewaf answered. An answer without a valid disposition // askServeWAF asks servewaf about the request, and notes what is to be done
// notes nothing, and no report is due for it: servewaf holds no decision to report // with it. It returns servewaf's answer, or nil when servewaf gave none, and
// on. // SWRP_DEFAULT_DISPOSITION decides. When the visitor went away while servewaf
func (rq *request) noteAnswer(answer *servewafAnswer) { // was being asked, servewaf has not failed, and nothing is noted. ctx is the
disposition := answer.header.Get(proxy.DispositionHeader) // request's own context.
func (rq *request) askServeWAF(ctx context.Context) *servewafAnswer {
answer, err := rq.ask(ctx)
if err != nil {
if ctx.Err() != nil {
return nil
}
switch disposition { // A request that keeps servewaf busy past SWRP_TIMEOUT_MS, like any request
case proxy.DispositionAllow, proxy.DispositionDeny, proxy.DispositionClose: // while servewaf is overloaded, gets SWRP_DEFAULT_DISPOSITION here. deny closes
rq.disposition = disposition // this, at the cost of answering 503 whenever servewaf is slow.
rq.answered = true
rq.asn = answer.header.Get(asnHeader)
rq.country = answer.header.Get(countryHeader)
}
}
// handleNoUsableAnswer deals with a request whose question ended in err, which
// leaves no answer to act on. ctx is the request's own context.
func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
switch {
case ctx.Err() != nil:
// The visitor went away while servewaf was being asked. servewaf has not
// failed, and nothing is noted. This panic has Go's server close the
// connection without an answer, and log nothing; the deferred finish still
// runs.
panic(http.ErrAbortHandler)
case errors.Is(err, errCannotPassOn):
rq.h.processLog.Warn("servewaf's answer is dropped", "error", err.Error())
rq.answer(http.StatusBadGateway)
default:
// servewaf cannot be connected to, does not answer at all, or does not answer
// within SWRP_TIMEOUT_MS. A request that keeps servewaf busy past
// SWRP_TIMEOUT_MS, like any request while servewaf is overloaded, gets
// SWRP_DEFAULT_DISPOSITION here. deny closes this, at the cost of answering 503
// whenever servewaf is slow. Making such requests hard to send is
// https://git.eeqj.de/sneak/smallwebwaf/issues/182.
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
rq.byDefault = true rq.byDefault = true
if rq.disposition == proxy.DispositionAllow { return nil
rq.passToApp(ctx)
} else {
rq.answer(http.StatusServiceUnavailable)
}
} }
rq.disposition = answer.header.Get(proxy.DispositionHeader)
rq.answered = true
rq.asn = answer.header.Get(asnHeader)
rq.country = answer.header.Get(countryHeader)
return answer
} }
// ask sends servewaf the question, and returns its answer, which must have come // ask sends servewaf the question, and returns its answer, which must have come
// whole within SWRP_TIMEOUT_MS. It ends when ctx does, as when the visitor goes // whole within SWRP_TIMEOUT_MS, with a valid disposition. It ends when ctx does,
// away. // as when the visitor goes away.
func (rq *request) ask(ctx context.Context) (*servewafAnswer, error) { func (rq *request) ask(ctx context.Context) (*servewafAnswer, error) {
ctx, cancel := context.WithTimeout(ctx, rq.h.config.Timeout) ctx, cancel := context.WithTimeout(ctx, rq.h.config.Timeout)
defer cancel() defer cancel()
@@ -108,15 +92,16 @@ func (rq *request) ask(ctx context.Context) (*servewafAnswer, error) {
return nil, fmt.Errorf("read the answer: %w", err) return nil, fmt.Errorf("read the answer: %w", err)
} }
switch { if len(body) > answerBodyMaxBytes {
case len(body) > answerBodyMaxBytes: return nil, errAnswerTooLong
return nil, fmt.Errorf("%w: its body is over 1 MiB", errCannotPassOn)
case res.StatusCode < http.StatusContinue:
// Go's client takes such a status. Go's server panics when asked to send it.
return nil, fmt.Errorf("%w: its status is %d", errCannotPassOn, res.StatusCode)
} }
return &servewafAnswer{res.StatusCode, res.Header, body}, nil switch res.Header.Get(proxy.DispositionHeader) {
case proxy.DispositionAllow, proxy.DispositionDeny, proxy.DispositionClose:
return &servewafAnswer{res.StatusCode, res.Header, body}, nil
default:
return nil, fmt.Errorf("%w, and status %d", errNoDisposition, res.StatusCode)
}
} }
// canBeAsked reports whether Go's client can send servewaf r as the visitor sent // canBeAsked reports whether Go's client can send servewaf r as the visitor sent
@@ -192,13 +177,6 @@ func (rq *request) answerWith(answer *servewafAnswer) {
header := rq.out.Header() header := rq.out.Header()
maps.Copy(header, answer.header) maps.Copy(header, answer.header)
header.Del(proxy.DispositionHeader) header.Del(proxy.DispositionHeader)
// Go's server adds a Content-Type it guesses from the body to a response whose
// headers have no Content-Type key. The key with no value sends none.
if header.Get("Content-Type") == "" {
header["Content-Type"] = nil
}
rq.send(answer.status, answer.body) rq.send(answer.status, answer.body)
} }
+5 -4
View File
@@ -304,7 +304,7 @@ func sendRaw(t *testing.T, site, request string) int {
return res.StatusCode return res.StatusCode
} }
func TestAChunkedBodyStaysABodyAtServeWAF(t *testing.T) { func TestAConnectBodyStaysABodyAtServeWAF(t *testing.T) {
t.Parallel() t.Parallel()
addr, serveWAFOut, stop := startServeWAF(t, serveWAFSettings(t)) addr, serveWAFOut, stop := startServeWAF(t, serveWAFSettings(t))
@@ -319,11 +319,12 @@ func TestAChunkedBodyStaysABodyAtServeWAF(t *testing.T) {
proxy.ClientHeader + ": " + otherClient + "\r\n" + proxy.ClientHeader + ": " + otherClient + "\r\n" +
"X-Request-ID: " + smuggledID + "\r\n\r\n" "X-Request-ID: " + smuggledID + "\r\n\r\n"
status := sendRaw(t, site, "POST /upload HTTP/1.1\r\nHost: site.example\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"+ "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") strconv.FormatInt(int64(len(smuggled)), 16)+"\r\n"+smuggled+"\r\n0\r\n\r\n")
if status != http.StatusOK { if status != http.StatusForbidden {
t.Errorf("status %d, want the app's 200", status) 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. // Read as a further request, it would act straight after the answer.