check / check (push) Waiting to run
The package passes nothing to an app since rproxy took that over, so it is named after the command it serves. The rproxy tests call their stand-in for servewaf standIn, since a local servewaf would hide the package. No change of behaviour. Model: opus-5-5
267 lines
8.3 KiB
Go
267 lines
8.3 KiB
Go
package rproxy
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"maps"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
)
|
|
|
|
// 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 (
|
|
errCannotPassOn = errors.New("the answer cannot be passed to the visitor")
|
|
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
|
|
}
|
|
|
|
// noteAnswer notes what servewaf answered. An answer without a valid disposition
|
|
// notes nothing, and no report is due for it: servewaf holds no decision to report
|
|
// on.
|
|
func (rq *request) noteAnswer(answer *servewafAnswer) {
|
|
disposition := answer.header.Get(servewaf.DispositionHeader)
|
|
|
|
switch disposition {
|
|
case servewaf.DispositionAllow, servewaf.DispositionDeny, servewaf.DispositionClose:
|
|
rq.disposition = disposition
|
|
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.disposition = rq.h.config.DefaultDisposition
|
|
rq.byDefault = true
|
|
|
|
if rq.disposition == servewaf.DispositionAllow {
|
|
rq.passToApp(ctx)
|
|
} else {
|
|
rq.answer(http.StatusServiceUnavailable)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// 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()
|
|
}()
|
|
|
|
// The body of a 101 is the connection itself, and the time limit does not end
|
|
// a read of that. Go's client also takes a status below 100, which Go's server
|
|
// panics when asked to send.
|
|
if res.StatusCode < http.StatusOK {
|
|
return nil, fmt.Errorf("%w: its status is %d", errCannotPassOn, res.StatusCode)
|
|
}
|
|
|
|
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, fmt.Errorf("%w: its body is over 1 MiB", errCannotPassOn)
|
|
}
|
|
|
|
return &servewafAnswer{res.StatusCode, res.Header, body}, nil
|
|
}
|
|
|
|
// 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;
|
|
// - an empty User-Agent, which it leaves out, the one case that still goes to
|
|
// servewaf.
|
|
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(servewaf.ClientHeader, rq.client.String())
|
|
question.Header.Set(servewaf.PeerHeader, rq.peer.String())
|
|
question.Header.Del(servewaf.ContentLengthHeader)
|
|
|
|
announced := in.Header.Get("Content-Length")
|
|
if announced != "" {
|
|
question.Header.Set(servewaf.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(servewaf.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)
|
|
}
|
|
|
|
// 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 *servewaf.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 *servewaf.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)
|
|
}
|
|
|
|
// The body of a 101 is the connection itself, and the time limit does not end
|
|
// a read of that.
|
|
if res.StatusCode >= http.StatusOK {
|
|
_, _ = 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
|
|
}
|