rproxy, the proxy in front of each app, asks servewaf and fails open (closes #171)
check / check (push) Failing after 12m17s

`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 limits. Any
other answer goes to the visitor as servewaf gave it. SWRP_DEFAULT_DISPOSITION
decides only when servewaf cannot be connected to, does not answer at all, or
does not answer within SWRP_TIMEOUT_MS. A method SPEC.md does not list gets 405.

Duplication: internal/rproxy copies passing-on code from internal/proxy until
#172 removes the originals.
Rule suppressed: gosec G704 and G705; gosec at
internal/smallwebwaf/rproxy_test.go:431; tagliatelle at
internal/requestlog/requestlog.go:201; paralleltest on one test.
Judgement call: an answer over 1 MiB, or with a status below 200, gets 502.
Judgement call: every target not starting with one / gets 400.

Model: opus-5-5
This commit is contained in:
2026-11-05 04:07:40 +00:00
parent 2eeea136e8
commit e0b71be0f7
23 changed files with 6181 additions and 59 deletions
+593
View File
@@ -0,0 +1,593 @@
package rproxy
import (
"context"
"crypto/rand"
"errors"
"io"
"net/http"
"net/http/httptrace"
"net/http/httputil"
"net/netip"
"net/url"
"os"
"strconv"
"sync"
"sync/atomic"
"time"
"sneak.berlin/go/smallwebwaf/internal/proxy"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules"
)
// flushAfterEachWrite has ReverseProxy pass on each part of the app's answer as
// soon as it arrives.
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
// plainText is the Content-Type of the answers rproxy gives itself.
const plainText = "text/plain; charset=utf-8"
// request is one request on its way through rproxy, from the moment its headers
// have been read to its log line and report.
type request struct {
h *handler
in *http.Request
// rc sets the deadlines of the connection to the visitor.
rc *http.ResponseController
out *responseWriter
body *requestBody // nil for a request without a body
// id is the fresh id made for the request when it arrived.
id string
client netip.Addr
peer netip.Addr
peerTrusted bool
start time.Time
// disposition is what is done with the request: servewaf's valid disposition,
// or SWRP_DEFAULT_DISPOSITION. It is "" when neither gave one.
disposition string
// answered is true when servewaf gave disposition, which makes a report due,
// and byDefault when SWRP_DEFAULT_DISPOSITION did.
answered bool
byDefault bool
// asn and country are servewaf's X-Client-ASN and X-Client-Country, for the
// app.
asn, country string
// upstreamStart is when the request was handed to the app.
upstreamStart time.Time
// cancel ends the request to the app.
cancel context.CancelFunc
// refused is the status of the first refusal, from whichever goroutine meets
// it: what the visitor is answered if the response has not started yet.
refused atomic.Int64
// upgraded is the connection to the app once the app has switched protocols,
// as for a WebSocket, and nil otherwise.
upgraded *upgradedConn
// mu guards what follows. The time limits run on goroutines of their own,
// and the transport starts and stops them, and notes the times below, from
// its own; once timersStopped is set, neither time limit acts any more.
mu sync.Mutex
timersStopped bool
requestTimer *time.Timer
responseTimer *time.Timer
// requestEnd is when the app had been sent the whole request, or the answer
// started, if that was sooner.
requestEnd time.Time
// answerStarted is when the first byte of the app's answer arrived.
answerStarted time.Time
}
// newRequest starts handling r: it notes the time, works out the visitor from
// the TCP peer and X-Forwarded-For, and makes the request's id.
func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request {
peer := peerAddress(r)
trusted := h.config.TrustedProxies
rq := &request{
h: h,
in: r,
rc: http.NewResponseController(w),
out: &responseWriter{ResponseWriter: w},
id: rand.Text(),
client: clientAddress(peer, r.Header.Values("X-Forwarded-For"), trusted),
peer: peer,
peerTrusted: isInside(peer, trusted),
start: time.Now(),
}
if r.Body != http.NoBody {
rq.body = &requestBody{body: limitBody(r.Body, h.config.MaxRequestSize), rq: rq}
}
return rq
}
// answerHealth answers the health check.
func (rq *request) answerHealth() {
rq.prepareOwnAnswer(rq.requestDeadline())
rq.out.Header().Set("Content-Type", plainText)
rq.send(http.StatusOK, []byte("ok\n"))
}
// 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
// visitor too slow to send it is answered 408. For a malformed chunk, or a
// visitor that went away or stopped its sending side, the connection is closed
// without an answer. A body over SWRP_MAX_REQUEST_SIZE goes on with the part
// read, so that servewaf is asked first; passToApp then refuses it.
func (rq *request) readFirstPartOfBody() bool {
if rq.body == nil {
return true
}
_ = rq.rc.SetReadDeadline(rq.requestDeadline())
held, err := io.ReadAll(io.LimitReader(rq.body, proxy.QuestionBodyMaxBytes))
// From here on, the request timer ends a read of the rest of the body, so that
// the answer can name the side rproxy was waiting on.
_ = rq.rc.SetReadDeadline(time.Time{})
rq.body.held = held
switch {
case err == nil || rq.refused.Load() != 0:
return true
case errors.Is(err, os.ErrDeadlineExceeded):
rq.answer(http.StatusRequestTimeout)
return false
default:
// This panic has Go's server close the connection without an answer, and
// 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
// 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
// escaped again, %2F turned into /. Opaque is sent as it is.
func setPathAsSent(out *url.URL, in *http.Request) {
out.Opaque = rules.Path(in)
}
// forward passes the request to the app, the part of the body held and then the
// rest, and the app's answer back. ctx is the request's own context.
func (rq *request) forward(ctx context.Context) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
rq.cancel = cancel
ctx = httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{
WroteRequest: rq.wroteRequest,
GotFirstResponseByte: rq.gotFirstResponseByte,
})
out := rq.in.WithContext(ctx)
if rq.body != nil {
out.Body = rq.body
}
reverseProxy := &httputil.ReverseProxy{
Rewrite: rq.rewrite,
Transport: rq.h.transport,
FlushInterval: flushAfterEachWrite,
ErrorLog: rq.h.errorLog,
ModifyResponse: rq.modifyResponse,
ErrorHandler: rq.answerError,
}
rq.startRequestTimer()
rq.upstreamStart = time.Now()
reverseProxy.ServeHTTP(rq.out, out)
}
// rewrite makes the request the app receives: the visitor's request, unchanged,
// sent to SWRP_UPSTREAM_URL, with the forwarded headers and the request's id set,
// without any X-Client-ASN or X-Client-Country the visitor sent, and with
// servewaf's.
func (rq *request) rewrite(pr *httputil.ProxyRequest) {
upstream := rq.h.config.UpstreamURL
pr.Out.URL.Scheme = upstream.Scheme
pr.Out.URL.Host = upstream.Host
// ReverseProxy drops query parameters it cannot parse; the app gets the
// query as the visitor sent it.
pr.Out.URL.RawQuery = pr.In.URL.RawQuery
setPathAsSent(pr.Out.URL, pr.In)
setForwardedHeaders(pr.In, pr.Out, rq.peer, rq.peerTrusted)
pr.Out.Header.Set(requestIDHeader, rq.id)
pr.Out.Header.Del(asnHeader)
pr.Out.Header.Del(countryHeader)
if rq.asn != "" {
pr.Out.Header.Set(asnHeader, rq.asn)
}
if rq.country != "" {
pr.Out.Header.Set(countryHeader, rq.country)
}
}
// modifyResponse looks at the app's answer before ReverseProxy passes it on.
func (rq *request) modifyResponse(res *http.Response) error {
if res.StatusCode == http.StatusSwitchingProtocols {
// An upgraded connection, such as a WebSocket, is not cut by the time
// limits. ReverseProxy writes this answer straight to the connection it
// takes over, not through rq.out, and then copies what passes each way
// through res.Body, the connection to the app. It may still refuse the
// switch, as when the app's Upgrade is not the visitor's, so the status
// is noted only once it has taken over the connection, by rq.out's
// Hijack.
rq.stopTimers()
conn, ok := res.Body.(io.ReadWriteCloser)
if ok {
rq.upgraded = &upgradedConn{ReadWriteCloser: conn}
res.Body = rq.upgraded
}
return nil
}
maxBytes := rq.h.config.MaxResponseSize
if maxBytes > 0 && res.Body != http.NoBody && res.ContentLength > maxBytes {
rq.refuse(http.StatusBadGateway)
return errResponseTooLarge
}
// 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,
// as the app sent none.
if res.Header.Get("Content-Type") == "" {
rq.out.Header()["Content-Type"] = nil
}
res.Body = &responseBody{body: limitBody(res.Body, maxBytes), rq: rq}
rq.setResponseDeadline()
return nil
}
// answerError is ReverseProxy's ErrorHandler: the request could not be passed to
// the app, or the app's answer cannot be passed on.
func (rq *request) answerError(_ http.ResponseWriter, _ *http.Request, err error) {
if rq.upgraded != nil {
// The app switched protocols, but the switch was not passed on, and
// ReverseProxy leaves the app's connection open when it refuses it.
_ = rq.upgraded.Close()
}
status := int(rq.refused.Load())
if status == 0 {
if rq.in.Context().Err() != nil {
// The visitor went away or stopped its sending side. This panic has Go's
// server close the connection without an answer, and log nothing; the
// deferred finish still runs.
panic(http.ErrAbortHandler)
}
rq.h.processLog.Warn("request to the app failed", "error", err.Error())
status = http.StatusBadGateway
}
rq.answer(status)
}
// answer sends rproxy's own answer, unless the response has already started.
func (rq *request) answer(status int) {
rq.refused.CompareAndSwap(0, int64(status))
if rq.out.status != 0 {
return // too late to answer: the connection can only be cut
}
// A visitor found too slow is read no more; any other may go on sending until
// its time is up, so that Go's server can read the rest of the body and end
// the request cleanly.
deadline := rq.requestDeadline()
if status == http.StatusRequestTimeout {
deadline = time.Now()
}
rq.prepareOwnAnswer(deadline)
header := rq.out.Header()
header.Set("Content-Type", plainText)
header.Set("X-Content-Type-Options", "nosniff")
rq.send(status, []byte(http.StatusText(status)+"\n"))
}
// send gives the visitor an answer rproxy holds whole: status, the headers set so
// far and body. Go's server would otherwise send so short an answer only once the
// handler has returned, after finish has written the log line and sent the report.
// Without Content-Length, the flush would send the body in chunks, the last of
// which would still wait for the handler. A HEAD's answer has no body, so any
// Content-Length it has stays as servewaf gave it.
func (rq *request) send(status int, body []byte) {
if rq.in.Method != http.MethodHead {
rq.out.Header().Set("Content-Length", strconv.Itoa(len(body)))
}
rq.out.WriteHeader(status)
_, _ = rq.out.Write(body)
_ = rq.rc.Flush()
}
// prepareOwnAnswer readies the connection for an answer rproxy gives itself: the
// reading of a body that has not arrived whole ends at deadline, and the answer
// has SWRP_MAX_RESPONSE_TIME_MS from now to reach the visitor, even when the app
// used that time up first.
func (rq *request) prepareOwnAnswer(deadline time.Time) {
rq.stopReadingBody(deadline)
rq.endRequest()
limit := rq.h.config.MaxResponseTime
if limit > 0 {
_ = rq.rc.SetWriteDeadline(time.Now().Add(limit))
}
}
// refuse records status, unless an earlier refusal was, and ends the request to
// the app, if one was made.
func (rq *request) refuse(status int) {
rq.refused.CompareAndSwap(0, int64(status))
if rq.cancel != nil {
rq.cancel()
}
}
// finish ends the request's time limits, writes its log line and, if servewaf
// answered the question, sends the report. ctx is the request's own context.
func (rq *request) finish(ctx context.Context) {
rq.stopTimers()
if rq.refused.Load() == 0 {
rq.stopReadingBody(rq.requestDeadline())
}
requestEnd := rq.endRequest()
end := time.Now()
requestBytes, responseBytes := rq.bytesEachWay()
err := requestlog.WriteRProxyLine(rq.h.requestLog, &requestlog.RProxyLine{
Time: requestlog.FormatTime(rq.start),
RequestID: rq.id,
ClientIP: rq.client.String(),
Host: rq.in.Host,
Method: rq.in.Method,
Path: rules.Path(rq.in),
Query: rq.in.URL.RawQuery,
Status: rq.out.status,
RequestBytes: requestBytes,
ResponseBytes: responseBytes,
DurationRequest: requestlog.Milliseconds(requestEnd.Sub(rq.start)),
DurationResponse: requestlog.Milliseconds(end.Sub(requestEnd)),
Disposition: rq.disposition,
DefaultDisposition: rq.byDefault,
})
if err != nil {
rq.h.processLog.Error("writing the request log failed", "error", err.Error())
}
if !rq.answered {
return
}
report := &proxy.Report{
RequestID: rq.id,
Status: min(rq.out.status, reportStatusMax),
RequestBytes: requestBytes,
ResponseBytes: responseBytes,
DurationTotal: requestlog.Milliseconds(end.Sub(rq.start)),
}
if !rq.upstreamStart.IsZero() {
report.DurationUpstreamTotal = requestlog.Milliseconds(end.Sub(rq.upstreamStart))
}
rq.h.sendReport(ctx, report)
}
// bytesEachWay returns the body bytes of the request and of the answer. For an
// upgraded connection, such as a WebSocket, which has closed by then, what it
// carried from the visitor counts with the request's and what it carried from the
// app with the answer's.
func (rq *request) bytesEachWay() (int64, int64) {
var requestBytes int64
if rq.body != nil {
requestBytes = rq.body.bytes.Load()
}
responseBytes := rq.out.bytes
if rq.upgraded != nil {
requestBytes += rq.upgraded.toApp.Load()
responseBytes += rq.upgraded.fromApp.Load()
}
return requestBytes, responseBytes
}
// requestDeadline is when the visitor must have sent its whole request, or zero
// when SWRP_MAX_REQUEST_TIME_MS is off.
func (rq *request) requestDeadline() time.Time {
limit := rq.h.config.MaxRequestTime
if limit == 0 {
return time.Time{}
}
return rq.start.Add(limit)
}
// stopReadingBody ends, at deadline, the reading of a visitor's body that has not
// arrived whole: Go's server then reads no more of it, and closes the connection
// after the answer.
func (rq *request) stopReadingBody(deadline time.Time) {
if rq.body == nil || rq.body.received.Load() {
return
}
_ = rq.rc.SetReadDeadline(deadline)
}
// startRequestTimer starts SWRP_MAX_REQUEST_TIME_MS, which runs from the
// request's headers being read until the app has been sent the whole request.
func (rq *request) startRequestTimer() {
deadline := rq.requestDeadline()
if deadline.IsZero() {
return
}
rq.mu.Lock()
defer rq.mu.Unlock()
rq.requestTimer = time.AfterFunc(time.Until(deadline), rq.requestTimedOut)
}
// requestTimedOut is called when SWRP_MAX_REQUEST_TIME_MS runs out while the
// request is still on its way to the app. The answer names the side rproxy was
// waiting on at that moment: 408 when it was waiting for the visitor to send more
// of its body, 504 when it was waiting for the app to be reached or to take what
// it had.
func (rq *request) requestTimedOut() {
rq.mu.Lock()
defer rq.mu.Unlock()
if rq.timersStopped {
return
}
if rq.body == nil || !rq.body.waiting.Load() {
// Once the app has answered, the transport, ReverseProxy and Go's server
// each close the visitor's body, and closing it reads what is left of a
// body under 256 KiB without requestBody.Read, so waiting is false while
// that read waits for the visitor. That read ends now, before refuse:
// should rproxy still answer itself, answer then sets the deadline it
// gives its own answers.
if !rq.answerStarted.IsZero() {
rq.stopReadingBody(time.Now())
}
rq.refuse(http.StatusGatewayTimeout)
return
}
rq.refuse(http.StatusRequestTimeout)
// The transport gives up on the app only once its Read of the visitor's body
// returns, so that Read is ended now. The lock keeps this from reaching the
// connection after the request is handled.
_ = rq.rc.SetReadDeadline(time.Now())
}
// gotFirstResponseByte is called once the first byte of the app's answer has
// arrived.
func (rq *request) gotFirstResponseByte() {
rq.mu.Lock()
defer rq.mu.Unlock()
rq.answerStarted = time.Now()
}
// wroteRequest is called once the app has been sent the whole request:
// SWRP_MAX_REQUEST_TIME_MS ends and SWRP_MAX_RESPONSE_TIME_MS starts.
func (rq *request) wroteRequest(info httptrace.WroteRequestInfo) {
if info.Err != nil {
return // the transport gives up, or tries again
}
requestEnd := rq.endRequest()
rq.mu.Lock()
defer rq.mu.Unlock()
if rq.timersStopped {
return
}
stopTimer(rq.requestTimer)
limit := rq.h.config.MaxResponseTime
if limit > 0 {
rq.responseTimer = time.AfterFunc(time.Until(requestEnd.Add(limit)),
rq.responseTimedOut)
}
}
// responseTimedOut is called when SWRP_MAX_RESPONSE_TIME_MS runs out before the
// app has sent its whole answer.
func (rq *request) responseTimedOut() {
rq.mu.Lock()
defer rq.mu.Unlock()
if !rq.timersStopped {
rq.refuse(http.StatusGatewayTimeout)
}
}
// responseReceived is called once the app has sent its whole answer. An app may
// answer before the visitor has sent its whole body, as with a 404 it sends
// without reading it. The time limits then run on: until the transport's read of
// the body returns, Go's server cannot send the answer, and
// SWRP_MAX_REQUEST_TIME_MS is what ends that read for a visitor that stops
// sending.
func (rq *request) responseReceived() {
if rq.body != nil && !rq.body.received.Load() {
return
}
rq.stopTimers()
}
// setResponseDeadline sets SWRP_MAX_RESPONSE_TIME_MS on the connection to the
// visitor: the app's answer must reach the visitor within it of the end of the
// request.
func (rq *request) setResponseDeadline() {
requestEnd := rq.endRequest()
limit := rq.h.config.MaxResponseTime
if limit > 0 {
_ = rq.rc.SetWriteDeadline(requestEnd.Add(limit))
}
}
// endRequest notes now as the end of the request, unless an end is noted, and
// returns the end noted.
func (rq *request) endRequest() time.Time {
rq.mu.Lock()
defer rq.mu.Unlock()
if rq.requestEnd.IsZero() {
rq.requestEnd = time.Now()
}
return rq.requestEnd
}
// stopTimers stops the request's time limits and keeps any from starting later:
// the app's answer is complete, the connection upgraded, or the request handled.
func (rq *request) stopTimers() {
rq.mu.Lock()
defer rq.mu.Unlock()
rq.timersStopped = true
stopTimer(rq.requestTimer)
stopTimer(rq.responseTimer)
}
// stopTimer stops t, which is nil when its time limit is off.
func stopTimer(t *time.Timer) {
if t != nil {
t.Stop()
}
}