check / check (push) Successful in 4m26s
`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 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. Only the methods SPEC.md lists pass on; any other gets 405. Duplication: internal/rproxy copies passing-on code from internal/proxy until #172 removes the originals. Rule suppressed: gosec G704 and G705; paralleltest on one test. Judgement call: an answer over 1 MiB, or with a status below 100, gets 502. Judgement call: every target not starting with one / gets 400. Model: opus-5-5
257 lines
8.0 KiB
Go
257 lines
8.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 (
|
|
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(proxy.DispositionHeader)
|
|
|
|
switch disposition {
|
|
case proxy.DispositionAllow, proxy.DispositionDeny, proxy.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 == proxy.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()
|
|
}()
|
|
|
|
body, err := io.ReadAll(io.LimitReader(res.Body, answerBodyMaxBytes+1))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read the answer: %w", err)
|
|
}
|
|
|
|
switch {
|
|
case len(body) > answerBodyMaxBytes:
|
|
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
|
|
}
|
|
|
|
// 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)
|
|
|
|
// 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 *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
|
|
}
|