check / check (push) Failing after 14m17s
`smallwebwaf rproxy` reads the first 64 KiB of each body, asks servewaf, 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. 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. Model: opus-5-5
213 lines
5.9 KiB
Go
213 lines
5.9 KiB
Go
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. ctx is the request's own context.
|
|
func (rq *request) askServeWAF(ctx context.Context) *servewafAnswer {
|
|
answer, err := rq.ask(ctx)
|
|
if err != 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.transport.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.
|
|
if in.ContentLength < 0 {
|
|
question.ContentLength = -1
|
|
}
|
|
|
|
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.transport.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
|
|
}
|