Each request log line now has the fields "Request log" in SPEC.md lists whose features are built: instance (SWWAF_INSTANCE_NAME), scheme, request_id (a trusted proxy's X-Request-ID or a new one, sent on to the app), forwarded_for, client_group, content_type, content_length, the headers SWWAF_LOG_REQUEST_HEADERS names, has_authorization, has_cookie, websocket, response_content_type, cache_control, location, counts and the timings. Authorization, Cookie and Set-Cookie values are never logged. An entry of SWWAF_LOG_REQUEST_HEADERS that is not a header name, or is Host or Transfer-Encoding, stops the start. Deviation: counts has request totals only. Deviation: SWWAF_INSTANCE_NAME is on request lines only. Model: opus-5-5
This commit is contained in:
@@ -30,14 +30,17 @@ func (rq *request) banned(now time.Time) bool {
|
||||
return banned
|
||||
}
|
||||
|
||||
// limitBroken counts the request for the rate limits at now, and reports
|
||||
// whether it takes the client over one. In enforce mode such a request
|
||||
// bans the client's netblock, and sets the client's counters back to
|
||||
// zero; in observe mode it does neither.
|
||||
// limitBroken counts the request for the rate limits at now, notes the
|
||||
// client's counts for the log line, and reports whether the request takes
|
||||
// the client over a limit. In enforce mode such a request bans the
|
||||
// client's netblock, and sets the client's counters back to zero; in
|
||||
// observe mode it does neither.
|
||||
func (rq *request) limitBroken(now time.Time) bool {
|
||||
group := clientGroup(rq.client)
|
||||
|
||||
hit, over := rq.h.limiter.Count(group, now)
|
||||
counts, hit, over := rq.h.limiter.Count(group, now)
|
||||
rq.line.Counts = counts
|
||||
|
||||
if !over {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
@@ -48,6 +49,33 @@ func clientAddress(
|
||||
return client
|
||||
}
|
||||
|
||||
// requestIDHeader carries the request's id, from traefik and to the app.
|
||||
const requestIDHeader = "X-Request-ID"
|
||||
|
||||
// requestID is the request's id: the one a trusted proxy sent, or a new
|
||||
// random one. A peer outside the trusted proxies did not come through
|
||||
// traefik, so the id it sends is its own claim, and is replaced.
|
||||
func requestID(r *http.Request, peerTrusted bool) string {
|
||||
id := r.Header.Get(requestIDHeader)
|
||||
if !peerTrusted || id == "" {
|
||||
id = rand.Text()
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
// scheme is how the client reached traefik, as a trusted proxy says in
|
||||
// X-Forwarded-Proto, or otherwise http, the only scheme smallwebwaf
|
||||
// serves.
|
||||
func scheme(r *http.Request, peerTrusted bool) string {
|
||||
proto := r.Header.Get("X-Forwarded-Proto")
|
||||
if !peerTrusted || proto == "" {
|
||||
return "http"
|
||||
}
|
||||
|
||||
return proto
|
||||
}
|
||||
|
||||
// ipv6GroupPrefix is the length of the IPv6 netblock that is one client.
|
||||
const ipv6GroupPrefix = 64
|
||||
|
||||
|
||||
@@ -14,10 +14,14 @@ const (
|
||||
appHost = "app.example"
|
||||
// client is the client's address, as a proxy names it.
|
||||
client = "203.0.113.9"
|
||||
// forwardedFor is the header that lists the client and its proxies.
|
||||
forwardedFor = "X-Forwarded-For"
|
||||
// secure is the scheme a client reached traefik with.
|
||||
// forwardedFor is the header that lists the client and its proxies,
|
||||
// and forwardedProto the one that gives the scheme the client used.
|
||||
forwardedFor = "X-Forwarded-For"
|
||||
forwardedProto = "X-Forwarded-Proto"
|
||||
// secure is the scheme a client reached traefik with, and plain the
|
||||
// one smallwebwaf serves.
|
||||
secure = "https"
|
||||
plain = "http"
|
||||
)
|
||||
|
||||
// appHeaders is what the app tells about the headers it received.
|
||||
@@ -65,13 +69,13 @@ func TestClientAddressAndForwardedHeaders(t *testing.T) {
|
||||
func clientAddressCases() []clientAddressCase {
|
||||
trusted := map[string]string{trustedProxies: trustLocalhost}
|
||||
forged := http.Header{
|
||||
forwardedFor: {client},
|
||||
"X-Forwarded-Host": {"forged.example"},
|
||||
"X-Forwarded-Proto": {secure},
|
||||
"X-Real-Ip": {client},
|
||||
forwardedFor: {client},
|
||||
"X-Forwarded-Host": {"forged.example"},
|
||||
forwardedProto: {secure},
|
||||
"X-Real-Ip": {client},
|
||||
}
|
||||
replaced := appHeaders{
|
||||
ForwardedFor: localhost, ForwardedHost: appHost, ForwardedProto: "http",
|
||||
ForwardedFor: localhost, ForwardedHost: appHost, ForwardedProto: plain,
|
||||
}
|
||||
|
||||
return []clientAddressCase{{
|
||||
@@ -87,10 +91,10 @@ func clientAddressCases() []clientAddressCase {
|
||||
"outside the trusted proxies from the right",
|
||||
env: trusted,
|
||||
header: http.Header{
|
||||
forwardedFor: {"198.51.100.7, " + client + ", 10.0.0.2"},
|
||||
"X-Forwarded-Host": {appHost},
|
||||
"X-Forwarded-Proto": {secure},
|
||||
"X-Real-Ip": {client},
|
||||
forwardedFor: {"198.51.100.7, " + client + ", 10.0.0.2"},
|
||||
"X-Forwarded-Host": {appHost},
|
||||
forwardedProto: {secure},
|
||||
"X-Real-Ip": {client},
|
||||
},
|
||||
wantClient: client,
|
||||
wantApp: appHeaders{
|
||||
@@ -138,7 +142,7 @@ func requestWithHeaders(
|
||||
Host: r.Host,
|
||||
ForwardedFor: r.Header.Get(forwardedFor),
|
||||
ForwardedHost: r.Header.Get("X-Forwarded-Host"),
|
||||
ForwardedProto: r.Header.Get("X-Forwarded-Proto"),
|
||||
ForwardedProto: r.Header.Get(forwardedProto),
|
||||
RealIP: r.Header.Get("X-Real-IP"),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,14 +21,18 @@ func TestHealthEndpointIsAnsweredBeforeAnyCheck(t *testing.T) {
|
||||
// the last one would have it refused.
|
||||
addr, out := startProxy(t, app.URL, map[string]string{rateLimitPerMinute: "1"})
|
||||
|
||||
const healthChecks = 3
|
||||
const (
|
||||
healthChecks = 3
|
||||
contentType = "text/plain; charset=utf-8"
|
||||
)
|
||||
|
||||
for range healthChecks {
|
||||
got := get(t, addr, proxy.HealthPath)
|
||||
wantStatus(t, got, http.StatusOK)
|
||||
|
||||
if string(got.body) != "ok\n" {
|
||||
t.Errorf("health endpoint answered %q, want ok", got.body)
|
||||
if string(got.body) != "ok\n" || got.header.Get("Content-Type") != contentType {
|
||||
t.Errorf("health endpoint answered %q with Content-Type %q, want ok "+
|
||||
"with %q", got.body, got.header.Get("Content-Type"), contentType)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +41,11 @@ func TestHealthEndpointIsAnsweredBeforeAnyCheck(t *testing.T) {
|
||||
lines := out.requestLines(t, healthChecks+1)
|
||||
for _, line := range lines[:healthChecks] {
|
||||
wantLine(t, line, http.StatusOK, requestlog.ActionAdmin)
|
||||
|
||||
if line.ResponseContentType != contentType {
|
||||
t.Errorf("health check's log line has response_content_type %q, "+
|
||||
"want %q", line.ResponseContentType, contentType)
|
||||
}
|
||||
}
|
||||
|
||||
wantLine(t, lines[healthChecks], http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
@@ -14,6 +16,7 @@ import (
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
@@ -115,27 +118,34 @@ func wantAnswer(t *testing.T, got answer, body []byte) {
|
||||
}
|
||||
}
|
||||
|
||||
// wantRequestFields checks the log line's fields about the request.
|
||||
// wantRequestFields checks the log line's fields about the request. Its
|
||||
// time, its id and its timings are checked only for being there.
|
||||
func wantRequestFields(t *testing.T, line logLine, host string, sent, received int) {
|
||||
t.Helper()
|
||||
|
||||
want := requestlog.Line{
|
||||
Type: "request", Time: line.Time, ClientIP: localhost, PeerIP: localhost,
|
||||
Method: http.MethodPatch, Host: host, Path: rawPath, Query: rawQuery,
|
||||
Protocol: "HTTP/1.1", Status: http.StatusTeapot,
|
||||
UpstreamStatus: http.StatusTeapot, RequestBytes: int64(sent),
|
||||
hostname, _ := os.Hostname()
|
||||
|
||||
want := withTimings(line, requestlog.Line{
|
||||
Type: requestType, Time: line.Time, Instance: hostname,
|
||||
ClientIP: localhost, Method: http.MethodPatch, Scheme: plain, Host: host,
|
||||
Path: rawPath, Query: rawQuery, Protocol: protocol,
|
||||
Status: http.StatusTeapot, RequestBytes: int64(sent),
|
||||
ResponseBytes: int64(received), UserAgent: "test-agent",
|
||||
Action: requestlog.ActionForward, DurationTotal: line.DurationTotal,
|
||||
DurationUpstreamTotal: line.DurationUpstreamTotal,
|
||||
}
|
||||
if line.Line != want {
|
||||
RequestID: line.RequestID, PeerIP: localhost, ClientGroup: localhost + "/32",
|
||||
ContentLength: int64(sent), ResponseContentType: "text/plain; charset=utf-8",
|
||||
UpstreamStatus: http.StatusTeapot, Action: requestlog.ActionForward,
|
||||
Counts: ratelimit.Counts{Minute: 1, Hour: 1, Day: 1},
|
||||
})
|
||||
if !reflect.DeepEqual(line.Line, want) {
|
||||
t.Errorf("log line\n%+v\nwant\n%+v", line.Line, want)
|
||||
}
|
||||
|
||||
_, err := time.Parse(time.RFC3339, line.Time)
|
||||
if err != nil || line.DurationTotal <= 0 || line.DurationUpstreamTotal <= 0 {
|
||||
t.Errorf("log line has time %q and durations %v and %v",
|
||||
line.Time, line.DurationTotal, line.DurationUpstreamTotal)
|
||||
if err != nil || line.RequestID == "" || line.DurationTotal <= 0 ||
|
||||
line.DurationUpstreamTotal == nil || *line.DurationUpstreamTotal <= 0 {
|
||||
t.Errorf("log line has time %q, request_id %q and durations %v and %v",
|
||||
line.Time, line.RequestID, line.DurationTotal,
|
||||
line.fields["duration_upstream_total"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,8 +381,13 @@ func TestAnswers502WhenTheAppCannotBeReached(t *testing.T) {
|
||||
addr, out := startProxy(t, "http://"+localhost+":1", nil)
|
||||
|
||||
wantStatus(t, get(t, addr, "/"), http.StatusBadGateway)
|
||||
wantLine(t, out.requestLine(t), http.StatusBadGateway,
|
||||
requestlog.ActionUpstreamError)
|
||||
|
||||
line := out.requestLine(t)
|
||||
wantLine(t, line, http.StatusBadGateway, requestlog.ActionUpstreamError)
|
||||
|
||||
// There never was a connection to the app, nor an answer from it.
|
||||
wantTimings(t, line, "duration_total", "duration_checks",
|
||||
"duration_upstream_total")
|
||||
|
||||
logged := slices.ContainsFunc(out.lines(t), func(line map[string]any) bool {
|
||||
return line["type"] == "process" && line["msg"] == "request to the app failed"
|
||||
|
||||
@@ -160,6 +160,9 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// a health checker is never refused. It does not ask the app.
|
||||
if r.Method == http.MethodGet && r.URL.Path == HealthPath {
|
||||
rq.line.Action = requestlog.ActionAdmin
|
||||
// Set here rather than left to Go's server, which would set it only
|
||||
// after the log line has taken the response's headers.
|
||||
rq.out.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = io.WriteString(rq.out, "ok\n")
|
||||
|
||||
return
|
||||
@@ -169,6 +172,8 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
defer rq.addToHistory()
|
||||
|
||||
refused := rq.check(r.Context())
|
||||
rq.checked = time.Now()
|
||||
|
||||
if refused != nil {
|
||||
rq.answer(*refused)
|
||||
|
||||
|
||||
@@ -35,6 +35,10 @@ const (
|
||||
// localhost is where every test server listens, and so the address
|
||||
// smallwebwaf sees each test's requests come from.
|
||||
localhost = "127.0.0.1"
|
||||
// requestType is the type that marks a request log line.
|
||||
requestType = "request"
|
||||
// protocol is the protocol of every test's requests.
|
||||
protocol = "HTTP/1.1"
|
||||
)
|
||||
|
||||
// shortTimeoutSetting is shortTimeout as a setting's value.
|
||||
@@ -67,6 +71,8 @@ const (
|
||||
maxBanDuration = "SWWAF_MAX_BAN_DURATION"
|
||||
maxBans = "SWWAF_MAX_BANS"
|
||||
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX"
|
||||
instanceName = "SWWAF_INSTANCE_NAME"
|
||||
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS"
|
||||
)
|
||||
|
||||
// output collects what smallwebwaf writes on stdout.
|
||||
@@ -83,6 +89,14 @@ func (o *output) Write(p []byte) (int, error) {
|
||||
return o.buf.Write(p)
|
||||
}
|
||||
|
||||
// text returns everything written so far.
|
||||
func (o *output) text() string {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
|
||||
return o.buf.String()
|
||||
}
|
||||
|
||||
// lines returns every line written so far, decoded.
|
||||
func (o *output) lines(t *testing.T) []map[string]any {
|
||||
t.Helper()
|
||||
@@ -122,7 +136,7 @@ func (o *output) requestLines(t *testing.T, count int) []logLine {
|
||||
var found []logLine
|
||||
|
||||
for _, fields := range o.lines(t) {
|
||||
if fields["type"] == "request" {
|
||||
if fields["type"] == requestType {
|
||||
found = append(found, decodeLine(t, fields))
|
||||
}
|
||||
}
|
||||
|
||||
+113
-22
@@ -8,6 +8,7 @@ import (
|
||||
"net/http/httputil"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -46,7 +47,9 @@ type request struct {
|
||||
peer netip.Addr
|
||||
peerTrusted bool
|
||||
start time.Time
|
||||
// upstreamStart is when the request was handed to the app.
|
||||
// checked is when the checks were done, and upstreamStart when the
|
||||
// request was handed to the app.
|
||||
checked time.Time
|
||||
upstreamStart time.Time
|
||||
// cancel ends the request to the app.
|
||||
cancel context.CancelFunc
|
||||
@@ -56,26 +59,34 @@ type request struct {
|
||||
complete bool
|
||||
|
||||
// mu guards what follows. The timeouts run on goroutines of their
|
||||
// own, and the transport starts and stops them from its own; once
|
||||
// timersStopped is set, none of them acts any more.
|
||||
// own, and the transport starts and stops them, and notes the times
|
||||
// below, from its own; once timersStopped is set, none of the timeouts
|
||||
// acts any more.
|
||||
mu sync.Mutex
|
||||
timersStopped bool
|
||||
clientRequestTimer *time.Timer
|
||||
upstreamRequestTimer *time.Timer
|
||||
upstreamResponseTimer *time.Timer
|
||||
// requestSent is when the app had been sent the whole request.
|
||||
requestSent time.Time
|
||||
// connected is when there was a connection to the app, requestSent
|
||||
// when the app had been sent the whole request, and answerStarted
|
||||
// when the first byte of its answer arrived.
|
||||
connected time.Time
|
||||
requestSent time.Time
|
||||
answerStarted time.Time
|
||||
}
|
||||
|
||||
// newRequest starts handling r: it notes the time, counts the request as
|
||||
// under way, and works out the client.
|
||||
// under way, works out the client, and starts the log line with what is
|
||||
// known of the request.
|
||||
func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request {
|
||||
h.metrics.RequestStarted()
|
||||
|
||||
start := time.Now()
|
||||
peer := peerAddress(r)
|
||||
trusted := h.config.TrustedProxies
|
||||
client := clientAddress(peer, r.Header.Values("X-Forwarded-For"), trusted)
|
||||
peerTrusted := isInside(peer, trusted)
|
||||
forwardedFor := r.Header.Values("X-Forwarded-For")
|
||||
client := clientAddress(peer, forwardedFor, trusted)
|
||||
|
||||
rq := &request{
|
||||
h: h,
|
||||
@@ -84,22 +95,37 @@ func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request {
|
||||
out: &responseWriter{ResponseWriter: w},
|
||||
client: client,
|
||||
peer: peer,
|
||||
peerTrusted: isInside(peer, trusted),
|
||||
peerTrusted: peerTrusted,
|
||||
start: start,
|
||||
line: requestlog.Line{
|
||||
Time: requestlog.FormatTime(start),
|
||||
ClientIP: client.String(),
|
||||
PeerIP: peer.String(),
|
||||
Method: r.Method,
|
||||
Host: r.Host,
|
||||
Path: r.URL.EscapedPath(),
|
||||
Query: r.URL.RawQuery,
|
||||
Protocol: r.Proto,
|
||||
Referer: r.Referer(),
|
||||
UserAgent: r.UserAgent(),
|
||||
Action: requestlog.ActionForward,
|
||||
Time: requestlog.FormatTime(start),
|
||||
Instance: h.config.InstanceName,
|
||||
ClientIP: client.String(),
|
||||
Method: r.Method,
|
||||
Scheme: scheme(r, peerTrusted),
|
||||
Host: r.Host,
|
||||
Path: r.URL.EscapedPath(),
|
||||
Query: r.URL.RawQuery,
|
||||
Protocol: r.Proto,
|
||||
Referer: r.Referer(),
|
||||
UserAgent: r.UserAgent(),
|
||||
RequestID: requestID(r, peerTrusted),
|
||||
PeerIP: peer.String(),
|
||||
ForwardedFor: strings.Join(forwardedFor, ", "),
|
||||
ClientGroup: clientGroup(client).String(),
|
||||
ContentType: r.Header.Get("Content-Type"),
|
||||
RequestHeaders: requestHeaders(r, h.config.LogRequestHeaders),
|
||||
HasAuthorization: len(r.Header.Values("Authorization")) > 0,
|
||||
HasCookie: len(r.Header.Values("Cookie")) > 0,
|
||||
Action: requestlog.ActionForward,
|
||||
},
|
||||
}
|
||||
|
||||
// A length of -1 is a body whose length was not announced.
|
||||
if r.ContentLength > 0 {
|
||||
rq.line.ContentLength = r.ContentLength
|
||||
}
|
||||
|
||||
if r.Body != http.NoBody {
|
||||
rq.body = &requestBody{body: limitBody(r.Body, h.config.RequestMaxBytes), rq: rq}
|
||||
}
|
||||
@@ -107,6 +133,27 @@ func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request {
|
||||
return rq
|
||||
}
|
||||
|
||||
// requestHeaders returns the headers of r that names lists, by name in
|
||||
// lower case, each with its values joined by ", ". Authorization, Cookie
|
||||
// and Set-Cookie are never among them, whatever names says.
|
||||
func requestHeaders(r *http.Request, names []string) map[string]string {
|
||||
headers := map[string]string{}
|
||||
|
||||
for _, name := range names {
|
||||
switch name {
|
||||
case "authorization", "cookie", "set-cookie":
|
||||
continue
|
||||
}
|
||||
|
||||
values := r.Header.Values(name)
|
||||
if len(values) > 0 {
|
||||
headers[name] = strings.Join(values, ", ")
|
||||
}
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
// check is the one place where a request can be refused once its client
|
||||
// is known, before its body is read or anything reaches the app. It
|
||||
// returns nil to let the request through. The checks of checkClient come
|
||||
@@ -182,7 +229,9 @@ func (rq *request) forward(ctx context.Context) {
|
||||
|
||||
rq.cancel = cancel
|
||||
ctx = httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{
|
||||
WroteRequest: rq.wroteRequest,
|
||||
GotConn: rq.gotConn,
|
||||
WroteRequest: rq.wroteRequest,
|
||||
GotFirstResponseByte: rq.gotFirstResponseByte,
|
||||
})
|
||||
|
||||
out := rq.in.WithContext(ctx)
|
||||
@@ -205,7 +254,8 @@ func (rq *request) forward(ctx context.Context) {
|
||||
}
|
||||
|
||||
// rewrite makes the request the app receives: the client's request,
|
||||
// unchanged, sent to SWWAF_UPSTREAM_URL, with the forwarded headers set.
|
||||
// unchanged, sent to SWWAF_UPSTREAM_URL, with the forwarded headers and
|
||||
// the request's id set.
|
||||
func (rq *request) rewrite(pr *httputil.ProxyRequest) {
|
||||
upstream := rq.h.config.UpstreamURL
|
||||
pr.Out.URL.Scheme = upstream.Scheme
|
||||
@@ -214,6 +264,7 @@ func (rq *request) rewrite(pr *httputil.ProxyRequest) {
|
||||
// the query as the client sent it.
|
||||
pr.Out.URL.RawQuery = pr.In.URL.RawQuery
|
||||
setForwardedHeaders(pr.In, pr.Out, rq.peer, rq.peerTrusted)
|
||||
pr.Out.Header.Set(requestIDHeader, rq.line.RequestID)
|
||||
}
|
||||
|
||||
// modifyResponse looks at the app's answer before ReverseProxy passes it
|
||||
@@ -227,6 +278,7 @@ func (rq *request) modifyResponse(res *http.Response) error {
|
||||
// connection it takes over, not through rq.out.
|
||||
rq.stopTimers()
|
||||
rq.out.status = res.StatusCode
|
||||
rq.line.Websocket = true
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -322,6 +374,10 @@ func (rq *request) finish() {
|
||||
line := &rq.line
|
||||
line.Status = rq.out.status
|
||||
line.ResponseBytes = rq.out.bytes
|
||||
header := rq.out.Header()
|
||||
line.ResponseContentType = header.Get("Content-Type")
|
||||
line.CacheControl = header.Get("Cache-Control")
|
||||
line.Location = header.Get("Location")
|
||||
|
||||
if rq.body != nil {
|
||||
line.RequestBytes = rq.body.bytes.Load()
|
||||
@@ -346,12 +402,18 @@ func (rq *request) finish() {
|
||||
now := time.Now()
|
||||
duration := now.Sub(rq.start)
|
||||
line.DurationTotal = requestlog.Milliseconds(duration)
|
||||
line.DurationChecks = timing(rq.start, rq.checked)
|
||||
|
||||
var upstreamDuration time.Duration
|
||||
|
||||
if !rq.upstreamStart.IsZero() {
|
||||
upstreamDuration = now.Sub(rq.upstreamStart)
|
||||
line.DurationUpstreamTotal = requestlog.Milliseconds(upstreamDuration)
|
||||
line.DurationUpstreamTotal = new(requestlog.Milliseconds(upstreamDuration))
|
||||
|
||||
rq.mu.Lock()
|
||||
line.DurationUpstreamConnect = timing(rq.upstreamStart, rq.connected)
|
||||
line.DurationUpstreamFirstByte = timing(rq.upstreamStart, rq.answerStarted)
|
||||
rq.mu.Unlock()
|
||||
}
|
||||
|
||||
// Counted before the log line is written, so that the metrics count
|
||||
@@ -364,6 +426,17 @@ func (rq *request) finish() {
|
||||
}
|
||||
}
|
||||
|
||||
// timing is the time from start to end in milliseconds, for one of the
|
||||
// log line's timings, or nil when end is zero: what it times never
|
||||
// happened.
|
||||
func timing(start, end time.Time) *float64 {
|
||||
if end.IsZero() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return new(requestlog.Milliseconds(end.Sub(start)))
|
||||
}
|
||||
|
||||
// addToHistory adds the request, which has ended, to its client's
|
||||
// history.
|
||||
func (rq *request) addToHistory() {
|
||||
@@ -473,6 +546,24 @@ func (rq *request) bodyReceived() {
|
||||
stopTimer(rq.clientRequestTimer)
|
||||
}
|
||||
|
||||
// gotConn is called once there is a connection to the app, a new one or
|
||||
// one kept open from an earlier request.
|
||||
func (rq *request) gotConn(httptrace.GotConnInfo) {
|
||||
rq.mu.Lock()
|
||||
defer rq.mu.Unlock()
|
||||
|
||||
rq.connected = time.Now()
|
||||
}
|
||||
|
||||
// gotFirstResponseByte is called once the first byte of the app's answer
|
||||
// has arrived.
|
||||
func (rq *request) gotFirstResponseByte() {
|
||||
rq.mu.Lock()
|
||||
defer rq.mu.Unlock()
|
||||
|
||||
rq.answerStarted = time.Now()
|
||||
}
|
||||
|
||||
// wroteRequest is called once the app has been sent the whole request:
|
||||
// the request timeouts end and SWWAF_UPSTREAM_RESPONSE_TIMEOUT starts.
|
||||
func (rq *request) wroteRequest(info httptrace.WroteRequestInfo) {
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
package proxy_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"maps"
|
||||
"math"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
const (
|
||||
// requestIDHeader carries the request's id.
|
||||
requestIDHeader = "X-Request-ID"
|
||||
// instance is the SWWAF_INSTANCE_NAME a test sets.
|
||||
instance = "fsn1app1/gitea"
|
||||
// ipv6Client is a client on IPv6, and ipv6Group the netblock the rate
|
||||
// limits count it as.
|
||||
ipv6Client = "2001:db8::7"
|
||||
ipv6Group = "2001:db8::/64"
|
||||
)
|
||||
|
||||
func TestLogLineHasEachFieldWhereItApplies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
received := make(chan string, 2) // the request ids the app received
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
received <- r.Header.Get(requestIDHeader)
|
||||
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
|
||||
if r.URL.Path != "/full" {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Location", "/elsewhere")
|
||||
w.WriteHeader(http.StatusFound)
|
||||
_, _ = io.WriteString(w, "moved")
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, map[string]string{
|
||||
trustedProxies: trustLocalhost,
|
||||
rateLimitExemptNets: localhost,
|
||||
instanceName: instance,
|
||||
logRequestHeaders: "Accept,x-custom,Authorization,cookie,SET-COOKIE",
|
||||
})
|
||||
|
||||
// This request comes from ipv6Client through a trusted proxy, with a
|
||||
// body and each header the log line looks at, and is answered with a
|
||||
// redirect.
|
||||
conn := dial(t, addr)
|
||||
send(t, conn, "POST /full HTTP/1.1\r\nHost: "+appHost+"\r\n"+
|
||||
forwardedFor+": 198.51.100.7, "+ipv6Client+"\r\n"+
|
||||
forwardedProto+": "+secure+"\r\n"+requestIDHeader+": from-traefik\r\n"+
|
||||
"Content-Type: application/x-www-form-urlencoded\r\nContent-Length: 3\r\n"+
|
||||
"Accept: text/html\r\nX-Custom: one\r\nX-Custom: two\r\n"+
|
||||
"Authorization: Bearer secret-token\r\nCookie: session=secret-cookie\r\n"+
|
||||
"Set-Cookie: secret-set-cookie\r\n\r\na=b")
|
||||
wantStatus(t, readResponse(t, conn), http.StatusFound)
|
||||
|
||||
// A request's log line can come after its answer: each is waited for
|
||||
// before the next request, so that the lines are in order.
|
||||
full := out.requestLines(t, 1)[0]
|
||||
|
||||
// This one comes from 127.0.0.1, which the rate limits do not count,
|
||||
// with a body of 4 bytes whose length it does not announce, so that its
|
||||
// request_bytes is not its content_length, and no header the log line
|
||||
// looks at, and is answered with 204 and no header.
|
||||
conn = dial(t, addr)
|
||||
send(t, conn, "POST /bare HTTP/1.1\r\nHost: "+appHost+"\r\n"+
|
||||
"Transfer-Encoding: chunked\r\n\r\n4\r\nbody\r\n0\r\n\r\n")
|
||||
wantStatus(t, readResponse(t, conn), http.StatusNoContent)
|
||||
|
||||
bare := out.requestLines(t, 2)[1]
|
||||
|
||||
wantFullLine(t, full)
|
||||
wantBareLine(t, bare)
|
||||
|
||||
for _, line := range []logLine{full, bare} {
|
||||
got := <-received
|
||||
if got != line.RequestID {
|
||||
t.Errorf("the app received request id %q, the log line has %q",
|
||||
got, line.RequestID)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(out.text(), "secret") {
|
||||
t.Errorf("a value of Authorization, Cookie or Set-Cookie is logged:\n%s",
|
||||
out.text())
|
||||
}
|
||||
}
|
||||
|
||||
// wantFullLine checks the log line of the request with every header the
|
||||
// line looks at. Its timings are checked by TestTimingsAreInOrder.
|
||||
func wantFullLine(t *testing.T, line logLine) {
|
||||
t.Helper()
|
||||
|
||||
headers := map[string]string{"accept": "text/html", "x-custom": "one, two"}
|
||||
|
||||
want := withTimings(line, requestlog.Line{
|
||||
Type: requestType, Time: line.Time, Instance: instance,
|
||||
ClientIP: ipv6Client, Method: http.MethodPost, Scheme: secure,
|
||||
Host: appHost, Path: "/full", Protocol: protocol,
|
||||
Status: http.StatusFound, RequestBytes: 3, ResponseBytes: 5,
|
||||
RequestID: "from-traefik", PeerIP: localhost,
|
||||
ForwardedFor: "198.51.100.7, " + ipv6Client, ClientGroup: ipv6Group,
|
||||
ContentType: "application/x-www-form-urlencoded", ContentLength: 3,
|
||||
RequestHeaders: headers, HasAuthorization: true, HasCookie: true,
|
||||
ResponseContentType: "text/html", UpstreamStatus: http.StatusFound,
|
||||
CacheControl: "no-store", Location: "/elsewhere",
|
||||
Action: requestlog.ActionForward,
|
||||
Counts: ratelimit.Counts{Minute: 1, Hour: 1, Day: 1},
|
||||
})
|
||||
if !reflect.DeepEqual(line.Line, want) {
|
||||
t.Errorf("log line\n%+v\nwant\n%+v", line.Line, want)
|
||||
}
|
||||
}
|
||||
|
||||
// wantBareLine checks the log line of the request with none of them, and
|
||||
// that the fields that do not apply to it are left out.
|
||||
func wantBareLine(t *testing.T, line logLine) {
|
||||
t.Helper()
|
||||
|
||||
want := withTimings(line, requestlog.Line{
|
||||
Type: requestType, Time: line.Time, Instance: instance,
|
||||
ClientIP: localhost, Method: http.MethodPost, Scheme: plain,
|
||||
Host: appHost, Path: "/bare", Protocol: protocol,
|
||||
Status: http.StatusNoContent, RequestBytes: 4, RequestID: line.RequestID,
|
||||
PeerIP: localhost, ClientGroup: localhost + "/32",
|
||||
UpstreamStatus: http.StatusNoContent, Action: requestlog.ActionForward,
|
||||
})
|
||||
if !reflect.DeepEqual(line.Line, want) || line.RequestID == "" {
|
||||
t.Errorf("log line\n%+v\nwant\n%+v, with a request id", line.Line, want)
|
||||
}
|
||||
|
||||
for _, name := range []string{
|
||||
"forwarded_for", "content_type", "content_length", "request_headers",
|
||||
"has_authorization", "has_cookie", "websocket", "response_content_type",
|
||||
"cache_control", "location", "counts",
|
||||
} {
|
||||
_, present := line.fields[name]
|
||||
if present {
|
||||
t.Errorf("log line has %s, which does not apply", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// withTimings returns want with the timings of line.
|
||||
func withTimings(line logLine, want requestlog.Line) requestlog.Line {
|
||||
want.DurationTotal = line.DurationTotal
|
||||
want.DurationChecks = line.DurationChecks
|
||||
want.DurationUpstreamConnect = line.DurationUpstreamConnect
|
||||
want.DurationUpstreamFirstByte = line.DurationUpstreamFirstByte
|
||||
want.DurationUpstreamTotal = line.DurationUpstreamTotal
|
||||
|
||||
return want
|
||||
}
|
||||
|
||||
func TestHasAuthorizationAndHasCookieEachComeFromTheirOwnHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const hasAuthorization, hasCookie = "has_authorization", "has_cookie"
|
||||
|
||||
for _, tc := range []struct{ header, field, other string }{
|
||||
{"Authorization", hasAuthorization, hasCookie},
|
||||
{"Cookie", hasCookie, hasAuthorization},
|
||||
} {
|
||||
t.Run("only "+tc.header, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||
addr, out := startProxy(t, app.URL, nil)
|
||||
|
||||
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||||
req.Header.Set(tc.header, "secret")
|
||||
wantStatus(t, do(t, req), http.StatusOK)
|
||||
|
||||
line := out.requestLine(t)
|
||||
|
||||
_, otherPresent := line.fields[tc.other]
|
||||
if line.fields[tc.field] != true || otherPresent {
|
||||
t.Errorf("log line has %s %v and %s %v, want true and none",
|
||||
tc.field, line.fields[tc.field], tc.other, line.fields[tc.other])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestIDAndSchemeComeOnlyFromATrustedProxy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const sentID = "from-traefik"
|
||||
|
||||
sent := http.Header{requestIDHeader: {sentID}, forwardedProto: {secure}}
|
||||
trusted := map[string]string{trustedProxies: trustLocalhost}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
header http.Header
|
||||
// wantID is the request id logged, "" for a new one.
|
||||
wantID, wantScheme string
|
||||
}{
|
||||
{"a trusted proxy's are kept", trusted, sent, sentID, secure},
|
||||
{"without them, the id is new and the scheme http", trusted, nil, "", plain},
|
||||
{"another peer's are replaced", nil, sent, "", plain},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
received := make(chan string, 2)
|
||||
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
received <- r.Header.Get(requestIDHeader)
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, tc.env)
|
||||
|
||||
// Two requests, so that two new ids can be told apart.
|
||||
ids := make([]string, 0, 2)
|
||||
|
||||
for i := range 2 {
|
||||
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||||
maps.Copy(req.Header, tc.header)
|
||||
wantStatus(t, do(t, req), http.StatusOK)
|
||||
|
||||
line := out.requestLines(t, i+1)[i]
|
||||
ids = append(ids, line.RequestID)
|
||||
|
||||
got := <-received
|
||||
if line.RequestID != got || line.Scheme != tc.wantScheme {
|
||||
t.Errorf("log line has request_id %q and scheme %q, and the "+
|
||||
"app received id %q; want the same id and scheme %q",
|
||||
line.RequestID, line.Scheme, got, tc.wantScheme)
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case tc.wantID != "" && (ids[0] != tc.wantID || ids[1] != tc.wantID):
|
||||
t.Errorf("request ids %q, want %q", ids, tc.wantID)
|
||||
case tc.wantID == "" && (slices.Contains(ids, sentID) ||
|
||||
slices.Contains(ids, "") || ids[0] == ids[1]):
|
||||
t.Errorf("request ids %q, want two new ones", ids)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimingsAreInOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
||||
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
// The pauses set the times apart; a hold-up of the test only
|
||||
// lengthens them.
|
||||
time.Sleep(time.Millisecond)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = http.NewResponseController(w).Flush()
|
||||
|
||||
time.Sleep(time.Millisecond)
|
||||
|
||||
_, _ = io.WriteString(w, "done")
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, map[string]string{
|
||||
trustedProxies: trustLocalhost,
|
||||
denyNets: denied,
|
||||
})
|
||||
|
||||
// Each log line is waited for before the next request, so that the
|
||||
// lines are in order.
|
||||
wantStatus(t, get(t, addr, "/"), http.StatusOK)
|
||||
forwarded := out.requestLines(t, 1)[0]
|
||||
|
||||
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||||
req.Header.Set(forwardedFor, denied)
|
||||
wantStatus(t, do(t, req), http.StatusForbidden)
|
||||
refused := out.requestLines(t, 2)[1]
|
||||
|
||||
wantStatus(t, get(t, addr, proxy.HealthPath), http.StatusOK)
|
||||
health := out.requestLines(t, 3)[2]
|
||||
|
||||
// A request passed to the app has every timing; one refused, none of
|
||||
// the app's; the health check, which runs no check, only the total.
|
||||
wantTimings(t, forwarded, "duration_total", "duration_checks",
|
||||
"duration_upstream_connect", "duration_upstream_first_byte",
|
||||
"duration_upstream_total")
|
||||
wantTimings(t, refused, "duration_total", "duration_checks")
|
||||
wantTimings(t, health, "duration_total")
|
||||
|
||||
if t.Failed() {
|
||||
return
|
||||
}
|
||||
|
||||
// In whole microseconds, as they are logged, so that the sum below is
|
||||
// exact.
|
||||
total := microseconds(forwarded.DurationTotal)
|
||||
checks := microseconds(*forwarded.DurationChecks)
|
||||
connect := microseconds(*forwarded.DurationUpstreamConnect)
|
||||
firstByte := microseconds(*forwarded.DurationUpstreamFirstByte)
|
||||
upstream := microseconds(*forwarded.DurationUpstreamTotal)
|
||||
|
||||
// The checks end before the request is handed to the app, and the
|
||||
// connection comes before the answer, which the app ends after a
|
||||
// pause.
|
||||
if checks+upstream > total || connect >= firstByte || firstByte >= upstream {
|
||||
t.Errorf("timings in microseconds: total %d, checks %d, connect %d, "+
|
||||
"first byte %d, upstream total %d", total, checks, connect, firstByte,
|
||||
upstream)
|
||||
}
|
||||
|
||||
if *refused.DurationChecks > refused.DurationTotal {
|
||||
t.Errorf("refused request's checks took %v of %v milliseconds",
|
||||
*refused.DurationChecks, refused.DurationTotal)
|
||||
}
|
||||
}
|
||||
|
||||
// wantTimings checks that the timings named are the only ones line has.
|
||||
func wantTimings(t *testing.T, line logLine, want ...string) {
|
||||
t.Helper()
|
||||
|
||||
var got []string
|
||||
|
||||
for name := range line.fields {
|
||||
if strings.HasPrefix(name, "duration_") {
|
||||
got = append(got, name)
|
||||
}
|
||||
}
|
||||
|
||||
slices.Sort(got)
|
||||
slices.Sort(want)
|
||||
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("log line of %s has timings %v, want %v", line.Path, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// microseconds is a timing in whole microseconds.
|
||||
func microseconds(milliseconds float64) int64 {
|
||||
return int64(math.Round(milliseconds * 1000))
|
||||
}
|
||||
|
||||
func TestLogsAnUpgradedConnection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := startApp(t, echoAfterUpgrade)
|
||||
addr, out := startProxy(t, app.URL, nil)
|
||||
|
||||
conn := dial(t, addr)
|
||||
send(t, conn, "GET /socket HTTP/1.1\r\nHost: app\r\n"+
|
||||
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
||||
wantStatus(t, readResponse(t, conn), http.StatusSwitchingProtocols)
|
||||
|
||||
_ = conn.Close()
|
||||
|
||||
line := out.requestLine(t)
|
||||
if line.fields["websocket"] != true {
|
||||
t.Errorf("log line has websocket %v, want true", line.fields["websocket"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user