check / check (push) Successful in 4m27s
`smallwebwaf servewaf` runs today's checks on the visitor's request a question carries and answers allow, deny or close, instead of passing it to an app. rproxy's report then counts the bytes toward the byte limits and adds the request to the client's history. SPEC.md gives the exact question, answer and report, which rproxy follows. `smallwebwaf` with no command is unchanged. Judgement call: a third disposition, close, since closing the question's connection would read as servewaf down and let the request through. Judgement call: allow can answer a new request id, since a visitor behind a trusted proxy can choose its own. Model: opus-5-5
351 lines
12 KiB
Go
351 lines
12 KiB
Go
package proxy
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/netip"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
lru "github.com/hashicorp/golang-lru/v2"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
)
|
|
|
|
// The headers of rproxy's question and of servewaf's answer, as
|
|
// "servewaf" in SPEC.md gives them.
|
|
const (
|
|
// ClientHeader marks a request as rproxy's question, and gives the
|
|
// visitor's address.
|
|
ClientHeader = "X-Smallwebwaf-Client"
|
|
// PeerHeader gives the address of rproxy's own TCP peer.
|
|
PeerHeader = "X-Smallwebwaf-Peer"
|
|
// ContentLengthHeader gives the body length the visitor announced.
|
|
ContentLengthHeader = "X-Smallwebwaf-Content-Length"
|
|
// DispositionHeader gives servewaf's answer.
|
|
DispositionHeader = "X-Smallwebwaf-Disposition"
|
|
)
|
|
|
|
// The values of DispositionHeader.
|
|
const (
|
|
// DispositionAllow tells rproxy to pass the request to the app.
|
|
DispositionAllow = "allow"
|
|
// DispositionDeny tells rproxy to give the visitor the answer's status,
|
|
// headers and body.
|
|
DispositionDeny = "deny"
|
|
// DispositionClose tells rproxy to close the visitor's connection
|
|
// without an answer, which SWWAF_BAN_RESPONSE asks for when it is close.
|
|
// servewaf closing the question's connection instead would read as
|
|
// servewaf being down, and rproxy would then let the request through.
|
|
DispositionClose = "close"
|
|
)
|
|
|
|
// ReportPath is where rproxy sends the report of each request it passed to
|
|
// the app.
|
|
const ReportPath = "/_smallwebwaf/report"
|
|
|
|
// QuestionBodyMaxBytes is the most of a visitor's body a question carries.
|
|
const QuestionBodyMaxBytes = 64 << 10
|
|
|
|
const (
|
|
// maxAwaitingReport is the most allowed requests held until their
|
|
// reports come. Past it, the request asked about longest ago is dropped.
|
|
maxAwaitingReport = 10000
|
|
// reportBodyMaxBytes is the most of a report's body that is read. It is
|
|
// far above any report's, since the longest thing in one is the request
|
|
// id, which comes from a header.
|
|
reportBodyMaxBytes = 1 << 20
|
|
// serveWAFTimeout bounds how long a request to servewaf takes to arrive,
|
|
// and its answer to leave, and serveWAFIdleTimeout how long a kept-open
|
|
// connection waits for its next request.
|
|
serveWAFTimeout = 60 * time.Second
|
|
serveWAFIdleTimeout = 120 * time.Second
|
|
)
|
|
|
|
// Report is the JSON object rproxy posts to ReportPath once the visitor's
|
|
// answer has ended, its fields named as the request log's of the same
|
|
// meaning. RequestID is the id servewaf's allow gave. Status is what the
|
|
// visitor got, 0 if nothing. The durations are in milliseconds.
|
|
//
|
|
//nolint:tagliatelle // docs/request-log.md names the fields in snake_case
|
|
type Report struct {
|
|
RequestID string `json:"request_id"`
|
|
Status int `json:"status"`
|
|
RequestBytes int64 `json:"request_bytes"`
|
|
ResponseBytes int64 `json:"response_bytes"`
|
|
DurationTotal float64 `json:"duration_total"`
|
|
DurationUpstreamTotal float64 `json:"duration_upstream_total"`
|
|
}
|
|
|
|
// NewServeWAF returns the server `smallwebwaf servewaf` runs. It answers
|
|
// rproxy's questions with the checks New runs, takes rproxy's reports and
|
|
// answers its own endpoints. It passes nothing to an app. rproxy applies
|
|
// the size and time limits to the visitor, so their settings do not apply
|
|
// here. Go's default limit on a request's line and headers, 1 MiB, stays:
|
|
// it is far above rproxy's, and a question refused for its size would have
|
|
// rproxy let the request through.
|
|
func NewServeWAF(params Params) *Server {
|
|
cfg := *params.Config
|
|
cfg.RequestMaxBytes = 0
|
|
cfg.ClientRequestTimeout = serveWAFTimeout
|
|
// The server's WriteTimeout does this. It sets a new deadline for each
|
|
// request, where a deadline a handler sets stays on a kept-open
|
|
// connection, and would run out under a later request's answer.
|
|
cfg.ClientResponseTimeout = 0
|
|
// A question's body of QuestionBodyMaxBytes may be cut short, and the
|
|
// Core Rule Set does not read a JSON or XML body over its limit.
|
|
cfg.WAFBodyLimit = min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1)
|
|
params.Config = &cfg
|
|
|
|
h := newHandler(params)
|
|
|
|
awaitingReport, err := lru.New[string, *request](maxAwaitingReport)
|
|
if err != nil {
|
|
panic(err) // New fails only for a size below one
|
|
}
|
|
|
|
h.awaitingReport = awaitingReport
|
|
|
|
return h.server(&http.Server{
|
|
Handler: http.HandlerFunc(h.serveWAF),
|
|
ReadTimeout: serveWAFTimeout,
|
|
WriteTimeout: serveWAFTimeout,
|
|
IdleTimeout: serveWAFIdleTimeout,
|
|
})
|
|
}
|
|
|
|
// serveWAF handles one request to servewaf: a request carrying
|
|
// ClientHeader is rproxy's question, and any other is for servewaf itself.
|
|
func (h *handler) serveWAF(w http.ResponseWriter, r *http.Request) {
|
|
if len(r.Header.Values(ClientHeader)) > 0 {
|
|
h.answerQuestion(w, r)
|
|
|
|
return
|
|
}
|
|
|
|
h.answerOwnEndpoint(w, r)
|
|
}
|
|
|
|
// answerQuestion runs the checks on the visitor's request a question
|
|
// carries, and answers rproxy what to do with it. A denied request is
|
|
// finished here. An allowed one is finished by its report. A question
|
|
// without a visitor's address gets no disposition, so that rproxy treats
|
|
// it as it treats no answer.
|
|
func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
|
|
client, err := netip.ParseAddr(r.Header.Get(ClientHeader))
|
|
if err != nil {
|
|
h.processLog.Warn("a question gives no visitor's address", "error", err.Error())
|
|
http.Error(w, "X-Smallwebwaf-Client is not an address", http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
// One that cannot be read leaves the log line's peer_ip invalid.
|
|
peer, _ := netip.ParseAddr(r.Header.Get(PeerHeader))
|
|
|
|
rq := h.startRequest(w, asTheVisitorSentIt(r), client.Unmap(), peer.Unmap(), true)
|
|
defer rq.finish()
|
|
|
|
rq.site = r.Host
|
|
refused := rq.check(r.Context())
|
|
rq.checked = time.Now()
|
|
underOwnPrefix := strings.HasPrefix(r.URL.Path, adminPrefix)
|
|
|
|
if refused == nil && !underOwnPrefix {
|
|
// In observe mode, what enforce mode would have refused counts here.
|
|
defer rq.countRefusal()
|
|
|
|
rq.allow()
|
|
|
|
return
|
|
}
|
|
|
|
// The last deferred runs first, as in ServeHTTP.
|
|
defer rq.addToHistory()
|
|
defer rq.countAnomalies()
|
|
defer rq.countRefusal()
|
|
|
|
if refused != nil {
|
|
rq.deny(*refused)
|
|
|
|
return
|
|
}
|
|
|
|
// The app never sees this prefix. servewaf's own endpoints are at its
|
|
// own address, so this answers as a path with no endpoint does.
|
|
rq.line.Action = requestlog.ActionAdmin
|
|
rq.out.Header().Set(DispositionHeader, DispositionDeny)
|
|
http.Error(rq.out, http.StatusText(http.StatusNotFound), http.StatusNotFound)
|
|
}
|
|
|
|
// asTheVisitorSentIt returns a copy of r, a question, as the checks and the
|
|
// log line are to see it: without the headers rproxy adds for servewaf, and
|
|
// with the length the visitor announced, as -1 when it announced none. The
|
|
// question's own length covers only the part of the body it carries.
|
|
func asTheVisitorSentIt(r *http.Request) *http.Request {
|
|
sent := r.Clone(r.Context())
|
|
announced := sent.Header.Get(ContentLengthHeader)
|
|
|
|
sent.Header.Del(ClientHeader)
|
|
sent.Header.Del(PeerHeader)
|
|
sent.Header.Del(ContentLengthHeader)
|
|
sent.Header.Del("Content-Length")
|
|
|
|
sent.ContentLength = -1
|
|
|
|
length, err := strconv.ParseInt(announced, 10, 64)
|
|
if err == nil && length >= 0 {
|
|
sent.ContentLength = length
|
|
sent.Header.Set("Content-Length", announced)
|
|
}
|
|
|
|
return sent
|
|
}
|
|
|
|
// allow answers a question with DispositionAllow, the request's id and,
|
|
// while SWWAF_ADD_LOOKUP_HEADERS is set, the client's AS number and country,
|
|
// and holds the request until its report comes.
|
|
func (rq *request) allow() {
|
|
rq.allowed = true
|
|
|
|
// servewaf sends no app this part of the body, and the table of requests
|
|
// awaiting their reports must not hold it.
|
|
if rq.body != nil {
|
|
rq.body.readByCoreRuleSet = nil
|
|
}
|
|
|
|
rq.awaitReport()
|
|
|
|
header := rq.out.Header()
|
|
header.Set(DispositionHeader, DispositionAllow)
|
|
header.Set(requestIDHeader, rq.line.RequestID)
|
|
|
|
if rq.h.config.AddLookupHeaders {
|
|
setLookupHeaders(header, rq.line.ASN, rq.line.Country)
|
|
}
|
|
|
|
// Go's server sends this only once the handler has returned, so a report
|
|
// that answers it cannot come while the handler still uses rq.
|
|
rq.out.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
// awaitReport holds the request by its id, which its report is to name. A
|
|
// visitor behind a trusted proxy can choose its id, which must not let it
|
|
// have its report taken for another request's, or lost, and its bytes not
|
|
// counted. So the request gets a new id when another held request has the
|
|
// question's, or when that is not valid UTF-8, which a JSON report cannot
|
|
// carry unchanged.
|
|
func (rq *request) awaitReport() {
|
|
id := rq.line.RequestID
|
|
if !utf8.ValidString(id) {
|
|
id = rand.Text()
|
|
}
|
|
|
|
held, _ := rq.h.awaitingReport.ContainsOrAdd(id, rq)
|
|
if held {
|
|
id = rand.Text()
|
|
rq.h.awaitingReport.Add(id, rq)
|
|
}
|
|
|
|
rq.line.RequestID = id
|
|
}
|
|
|
|
// deny answers a question with what the visitor is to get for r.
|
|
func (rq *request) deny(r refusal) {
|
|
if r.status == 0 {
|
|
// SWWAF_BAN_RESPONSE is close. Written past rq.out, since the log line's
|
|
// status stays 0: the visitor is sent nothing.
|
|
rq.refused.CompareAndSwap(nil, &r)
|
|
rq.out.Header().Set(DispositionHeader, DispositionClose)
|
|
rq.out.ResponseWriter.WriteHeader(http.StatusOK)
|
|
|
|
return
|
|
}
|
|
|
|
rq.out.Header().Set(DispositionHeader, DispositionDeny)
|
|
rq.answer(r)
|
|
}
|
|
|
|
// answerOwnEndpoint answers a request to servewaf's own address. No check
|
|
// runs on it: the address is on the private network, where the peers are
|
|
// rproxy and the admin's tools, not visitors. A report writes no log line.
|
|
func (h *handler) answerOwnEndpoint(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodPost && r.URL.Path == ReportPath {
|
|
h.takeReport(w, r)
|
|
|
|
return
|
|
}
|
|
|
|
peer := peerAddress(r)
|
|
|
|
rq := h.startRequest(w, r, peer, peer, false)
|
|
defer rq.finish()
|
|
|
|
if r.Method == http.MethodGet && r.URL.Path == HealthPath {
|
|
rq.answerHealth()
|
|
|
|
return
|
|
}
|
|
|
|
rq.answerAdmin()
|
|
}
|
|
|
|
// takeReport answers POST ReportPath: it finishes the request the report
|
|
// names, if it holds that request, and ignores the report otherwise.
|
|
func (h *handler) takeReport(w http.ResponseWriter, r *http.Request) {
|
|
var report Report
|
|
|
|
err := json.NewDecoder(http.MaxBytesReader(w, r.Body, reportBodyMaxBytes)).
|
|
Decode(&report)
|
|
if err != nil || report.RequestBytes < 0 || report.ResponseBytes < 0 {
|
|
http.Error(w, "the body is not a report", http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
rq, held := h.awaitingReport.Peek(report.RequestID)
|
|
// Remove reports true to one report only, of several that name the id.
|
|
if held && h.awaitingReport.Remove(report.RequestID) {
|
|
rq.finishWith(report)
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// finishWith finishes an allowed request with what its report says: the
|
|
// steps ServeHTTP takes once a request passed to the app has ended, with
|
|
// the status and bytes of the report in place of those it would have seen.
|
|
func (rq *request) finishWith(report Report) {
|
|
// A limit the question broke has been logged.
|
|
rq.line.LimitHit = ""
|
|
// addToHistory goes by this for whether the request reached the app.
|
|
rq.upstreamStart = time.Now()
|
|
rq.out = &responseWriter{status: report.Status, bytes: report.ResponseBytes}
|
|
rq.body = &requestBody{}
|
|
rq.body.bytes.Store(report.RequestBytes)
|
|
|
|
rq.countBytes()
|
|
rq.countAnomalies()
|
|
rq.addToHistory()
|
|
|
|
rq.line.Status = report.Status
|
|
rq.line.RequestBytes = report.RequestBytes
|
|
rq.line.ResponseBytes = report.ResponseBytes
|
|
rq.h.metrics.RequestReported(&rq.line,
|
|
time.Duration(report.DurationTotal*float64(time.Millisecond)),
|
|
time.Duration(report.DurationUpstreamTotal*float64(time.Millisecond)))
|
|
|
|
// The request's log line went out with the answer to its question.
|
|
if rq.line.LimitHit != "" {
|
|
rq.h.processLog.Warn("a report's bytes broke a byte limit",
|
|
"request_id", rq.line.RequestID,
|
|
"client_ip", rq.line.ClientIP,
|
|
"host", rq.line.Host,
|
|
"limit_hit", rq.line.LimitHit,
|
|
"ban_expires", rq.line.BanExpires)
|
|
}
|
|
}
|