rproxy, the proxy in front of each app, asks servewaf and fails open (closes #171)
check / check (push) Successful in 5m39s
check / check (push) Successful in 5m39s
`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 on servewaf's URL, a setting. Rule suppressed: gosec G705 on the answer written to the visitor. Judgement call: the SWRP_ size and time limits take off; SWRP_TIMEOUT_MS does not. Judgement call: every target not starting with / gets 400, not only x:admin. Model: opus-5-5
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
package rproxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
|
||||
"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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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. A body whose
|
||||
// length the visitor did not announce goes chunked. Named here, since Go's
|
||||
// client would otherwise send a CONNECT's body with neither length nor chunks,
|
||||
// which servewaf would read as further requests.
|
||||
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.out.WriteHeader(answer.status)
|
||||
_, _ = rq.out.Write(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
|
||||
}
|
||||
Reference in New Issue
Block a user