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
235 lines
7.0 KiB
Go
235 lines
7.0 KiB
Go
package rproxy
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"maps"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
|
)
|
|
|
|
// answerBodyMaxBytes is the most of the body of servewaf's answer that is read.
|
|
// It is far above any answer's.
|
|
const answerBodyMaxBytes = 1 << 20
|
|
|
|
// reportAnswerMaxBytes is the most of the answer to a report that is read,
|
|
// which has no body when servewaf takes the report.
|
|
const reportAnswerMaxBytes = 4 << 10
|
|
|
|
var (
|
|
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")
|
|
)
|
|
|
|
// servewafAnswer is what servewaf answered to a question.
|
|
type servewafAnswer struct {
|
|
status int
|
|
header http.Header
|
|
body []byte
|
|
}
|
|
|
|
// 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
|
|
// SWRP_DEFAULT_DISPOSITION decides. When the visitor went away while servewaf
|
|
// 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 {
|
|
answer, err := rq.ask(ctx)
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return nil
|
|
}
|
|
|
|
// 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.
|
|
rq.h.processLog.Warn("servewaf gave no answer", "error", err.Error())
|
|
|
|
rq.disposition = rq.h.config.DefaultDisposition
|
|
rq.byDefault = true
|
|
|
|
return nil
|
|
}
|
|
|
|
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
|
|
// whole within SWRP_TIMEOUT_MS, with a valid disposition. It ends when ctx does,
|
|
// as when the visitor goes away.
|
|
func (rq *request) ask(ctx context.Context) (*servewafAnswer, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, rq.h.config.Timeout)
|
|
defer cancel()
|
|
|
|
question, err := rq.newQuestion(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
res, err := rq.h.serveWAFTransport.RoundTrip(question)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("send the question: %w", err)
|
|
}
|
|
|
|
defer func() {
|
|
_ = res.Body.Close()
|
|
}()
|
|
|
|
body, err := io.ReadAll(io.LimitReader(res.Body, answerBodyMaxBytes+1))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read the answer: %w", err)
|
|
}
|
|
|
|
if len(body) > answerBodyMaxBytes {
|
|
return nil, errAnswerTooLong
|
|
}
|
|
|
|
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
|
|
// it. It cannot send these as they came:
|
|
// - a target that does not start with /, or that starts with //;
|
|
// - an empty Host, for which it sends servewaf's own address;
|
|
// - a Host holding an IPv6 zone, which it removes;
|
|
// - more than one User-Agent, of which it sends the first.
|
|
func canBeAsked(r *http.Request) bool {
|
|
target, host := r.RequestURI, r.Host
|
|
hostHasZone := strings.HasPrefix(host, "[") && strings.Contains(host, "%")
|
|
|
|
return strings.HasPrefix(target, "/") && !strings.HasPrefix(target, "//") &&
|
|
host != "" && !hostHasZone && len(r.Header.Values("User-Agent")) <= 1
|
|
}
|
|
|
|
// newQuestion returns the question about the request, as "The question" in
|
|
// SPEC.md gives it: the visitor's request as the visitor sent it, with the part
|
|
// of the body it holds and rproxy's headers.
|
|
func (rq *request) newQuestion(ctx context.Context) (*http.Request, error) {
|
|
in := rq.in
|
|
|
|
var held []byte
|
|
if rq.body != nil {
|
|
held = rq.body.held
|
|
}
|
|
|
|
question, err := http.NewRequestWithContext( //nolint:gosec // a setting's URL
|
|
ctx, in.Method, rq.h.config.WAFBackend.String(), bytes.NewReader(held))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("make the question: %w", err)
|
|
}
|
|
|
|
backend := question.URL
|
|
target := *in.URL
|
|
target.Scheme = backend.Scheme
|
|
target.Host = backend.Host
|
|
setPathAsSent(&target, in)
|
|
question.URL = &target
|
|
question.Host = in.Host
|
|
|
|
// The length NewRequestWithContext set is that of what is held. This line makes
|
|
// a body whose length the visitor did not announce go chunked, as "The question"
|
|
// in SPEC.md requires.
|
|
question.TransferEncoding = in.TransferEncoding
|
|
|
|
question.Header = in.Header.Clone()
|
|
question.Header.Set(proxy.ClientHeader, rq.client.String())
|
|
question.Header.Set(proxy.PeerHeader, rq.peer.String())
|
|
question.Header.Del(proxy.ContentLengthHeader)
|
|
|
|
announced := in.Header.Get("Content-Length")
|
|
if announced != "" {
|
|
question.Header.Set(proxy.ContentLengthHeader, announced)
|
|
}
|
|
|
|
question.Header.Set(requestIDHeader, rq.id)
|
|
question.Header.Set("X-Forwarded-Proto", scheme(in, rq.peerTrusted))
|
|
|
|
// Go's client adds a User-Agent of its own to a request without the key.
|
|
_, sent := question.Header["User-Agent"]
|
|
if !sent {
|
|
question.Header["User-Agent"] = nil
|
|
}
|
|
|
|
return question, nil
|
|
}
|
|
|
|
// answerWith gives the visitor servewaf's answer, less its disposition.
|
|
func (rq *request) answerWith(answer *servewafAnswer) {
|
|
rq.prepareOwnAnswer(rq.requestDeadline())
|
|
|
|
header := rq.out.Header()
|
|
maps.Copy(header, answer.header)
|
|
header.Del(proxy.DispositionHeader)
|
|
rq.send(answer.status, answer.body)
|
|
}
|
|
|
|
// sendReport sends servewaf report on a goroutine of its own, so that the
|
|
// visitor does not wait for it. The report goes on after ctx, the request's own
|
|
// context, has ended.
|
|
func (h *handler) sendReport(ctx context.Context, report *proxy.Report) {
|
|
h.reportsMu.Lock()
|
|
defer h.reportsMu.Unlock()
|
|
|
|
if h.reportsStopped {
|
|
return
|
|
}
|
|
|
|
h.reports.Go(func() {
|
|
err := h.postReport(context.WithoutCancel(ctx), report)
|
|
if err != nil {
|
|
h.processLog.Warn("a report is dropped", "error", err.Error())
|
|
}
|
|
})
|
|
}
|
|
|
|
// postReport posts report to servewaf, and waits for its answer for up to
|
|
// SWRP_TIMEOUT_MS.
|
|
func (h *handler) postReport(ctx context.Context, report *proxy.Report) error {
|
|
body, err := json.Marshal(report)
|
|
if err != nil {
|
|
return fmt.Errorf("encode the report: %w", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, h.config.Timeout)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext( //nolint:gosec // a setting's URL
|
|
ctx, http.MethodPost, h.reportURL, bytes.NewReader(body))
|
|
if err != nil {
|
|
return fmt.Errorf("make the request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
res, err := h.serveWAFTransport.RoundTrip(req)
|
|
if err != nil {
|
|
return fmt.Errorf("send the report: %w", err)
|
|
}
|
|
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(res.Body, reportAnswerMaxBytes))
|
|
_ = res.Body.Close()
|
|
|
|
if res.StatusCode != http.StatusNoContent {
|
|
return fmt.Errorf("%w %s", errReportRefused, res.Status)
|
|
}
|
|
|
|
return nil
|
|
}
|