check / check (push) Failing after 3m27s
Every setting of discriminator and rproxy is read through viper, in place of the os.LookupEnv function Main passed down. NewViper turns AutomaticEnv and AllowEmptyEnv on, so a setting that is set but empty reads as set. A setting may also be given as a file, the _FILE form, which rproxy's settings now have too; rproxy still writes nothing to disk. The settings are renamed. Discriminator's take the prefix SWW_WAF_, rproxy's take SWW_RPROXY_, and the shared SWW_COMMAND keeps SWW_. The Core Rule Set's settings take SWW_WAF_CORE_RULE_SET_ rather than SWW_WAF_WAF_. Every place a name appeared changed: the code, the viper keys, tests, documents, examples, the README's settings table and the image files. Model: deepseek-v4-pro
268 lines
8.6 KiB
Go
268 lines
8.6 KiB
Go
package rproxy
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"maps"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
|
)
|
|
|
|
// answerBodyMaxBytes is the most of the body of discriminator'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 discriminator 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")
|
|
)
|
|
|
|
// discriminatorAnswer is what discriminator answered to a question.
|
|
type discriminatorAnswer struct {
|
|
status int
|
|
header http.Header
|
|
body []byte
|
|
}
|
|
|
|
// noteAnswer notes what discriminator answered. An answer without a valid disposition
|
|
// notes nothing, and no report is due for it: discriminator holds no decision to report
|
|
// on.
|
|
func (rq *request) noteAnswer(answer *discriminatorAnswer) {
|
|
disposition := answer.header.Get(discriminator.DispositionHeader)
|
|
|
|
switch disposition {
|
|
case discriminator.DispositionAllow, discriminator.DispositionDeny,
|
|
discriminator.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 discriminator was being asked. discriminator 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("discriminator's answer is dropped", "error", err.Error())
|
|
rq.answer(http.StatusBadGateway)
|
|
default:
|
|
// discriminator cannot be connected to, does not answer at all, or does not answer
|
|
// within SWW_RPROXY_TIMEOUT_MS. A request that keeps discriminator busy past
|
|
// SWW_RPROXY_TIMEOUT_MS, like any request while discriminator is overloaded, gets
|
|
// SWW_RPROXY_DEFAULT_DISPOSITION here. deny closes this, at the cost of answering 503
|
|
// whenever discriminator is slow. Making such requests hard to send is
|
|
// https://git.eeqj.de/sneak/smallwebwaf/issues/182.
|
|
rq.h.processLog.Warn("discriminator gave no answer", "error", err.Error())
|
|
|
|
rq.disposition = rq.h.config.DefaultDisposition
|
|
rq.byDefault = true
|
|
|
|
if rq.disposition == discriminator.DispositionAllow {
|
|
rq.passToApp(ctx)
|
|
} else {
|
|
rq.answer(http.StatusServiceUnavailable)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ask sends discriminator the question, and returns its answer, which must have come
|
|
// whole within SWW_RPROXY_TIMEOUT_MS. It ends when ctx does, as when the visitor goes
|
|
// away.
|
|
func (rq *request) ask(ctx context.Context) (*discriminatorAnswer, 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.discriminatorTransport.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 &discriminatorAnswer{res.StatusCode, res.Header, body}, nil
|
|
}
|
|
|
|
// canBeAsked reports whether Go's client can send discriminator 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 discriminator'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
|
|
// discriminator.
|
|
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(discriminator.ClientHeader, rq.client.String())
|
|
question.Header.Set(discriminator.PeerHeader, rq.peer.String())
|
|
question.Header.Del(discriminator.ContentLengthHeader)
|
|
|
|
announced := in.Header.Get("Content-Length")
|
|
if announced != "" {
|
|
question.Header.Set(discriminator.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 discriminator's answer, less its disposition.
|
|
func (rq *request) answerWith(answer *discriminatorAnswer) {
|
|
rq.prepareOwnAnswer(rq.requestDeadline())
|
|
|
|
header := rq.out.Header()
|
|
maps.Copy(header, answer.header)
|
|
header.Del(discriminator.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 discriminator 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 *discriminator.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 discriminator, and waits for its answer for up to
|
|
// SWW_RPROXY_TIMEOUT_MS.
|
|
func (h *handler) postReport(ctx context.Context, report *discriminator.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.discriminatorTransport.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
|
|
}
|