Replaces #559, which conflicted. Model: opus-5-5
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/middleware"
|
||||
"sneak.berlin/go/webhooker/internal/middleware/middlewaretest"
|
||||
)
|
||||
|
||||
// floodRequests is the number of distinct invented paths each flood
|
||||
@@ -26,6 +28,13 @@ const floodRequests = 64
|
||||
// line for a redirected or rejected request may contain it.
|
||||
const attackerMarker = "QQATTACKERTEXTQQ"
|
||||
|
||||
// knownEntrypointUUID and failingEntrypointUUID are the UUIDs the
|
||||
// stand-in receiver in accessLogRouter answers 200 and 500 to.
|
||||
const (
|
||||
knownEntrypointUUID = "6f1e2a9c-4b7d-4e3a-9c2f-1d8b5a7e3c60"
|
||||
failingEntrypointUUID = "0b7c3e5a-2d9f-4a61-8e4b-7c1d6f2a9e35"
|
||||
)
|
||||
|
||||
// maxLineBytes bounds a single access log line whose client-supplied
|
||||
// fields are of ordinary size. Well above what the fixed fields need,
|
||||
// well below the length of the oversized input the amplification tests
|
||||
@@ -63,6 +72,12 @@ const (
|
||||
// capturingMiddleware returns a Middleware whose logger writes JSON
|
||||
// lines into the returned buffer, so the access log can be asserted
|
||||
// on directly.
|
||||
//
|
||||
// It trusts 192.0.2.1, the peer address httptest.NewRequestWithContext
|
||||
// gives a request, as a proxy, the way a deployment trusts its reverse
|
||||
// proxy: a request built that way and carrying X-Forwarded-For is
|
||||
// logged with the client that header names as clientIP, and one
|
||||
// without it with the peer.
|
||||
func capturingMiddleware(t *testing.T) (*middleware.Middleware, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
|
||||
@@ -72,16 +87,19 @@ func capturingMiddleware(t *testing.T) (*middleware.Middleware, *bytes.Buffer) {
|
||||
&slog.HandlerOptions{Level: slog.LevelInfo},
|
||||
))
|
||||
|
||||
cfg := &config.Config{Environment: config.EnvironmentDev}
|
||||
cfg := &config.Config{
|
||||
Environment: config.EnvironmentDev,
|
||||
TrustedProxies: trustedProxies("192.0.2.1/32"),
|
||||
}
|
||||
|
||||
return middleware.NewForTest(log, cfg, nil), buf
|
||||
return middlewaretest.New(t, log, cfg, nil), buf
|
||||
}
|
||||
|
||||
// capturingTextMiddleware is capturingMiddleware for the other handler
|
||||
// internal/logger can select: slog's text handler, which
|
||||
// internal/logger/logger.go installs when stderr is a tty. It escapes
|
||||
// differently from the JSON one, so the line bound has to be asserted
|
||||
// against both.
|
||||
// against both. It trusts the same peer.
|
||||
func capturingTextMiddleware(
|
||||
t *testing.T,
|
||||
) (*middleware.Middleware, *bytes.Buffer) {
|
||||
@@ -93,9 +111,12 @@ func capturingTextMiddleware(
|
||||
&slog.HandlerOptions{Level: slog.LevelInfo},
|
||||
))
|
||||
|
||||
cfg := &config.Config{Environment: config.EnvironmentDev}
|
||||
cfg := &config.Config{
|
||||
Environment: config.EnvironmentDev,
|
||||
TrustedProxies: trustedProxies("192.0.2.1/32"),
|
||||
}
|
||||
|
||||
return middleware.NewForTest(log, cfg, nil), buf
|
||||
return middlewaretest.New(t, log, cfg, nil), buf
|
||||
}
|
||||
|
||||
// accessLogRouter mirrors the production route shapes that an
|
||||
@@ -119,17 +140,21 @@ func accessLogRouter(m *middleware.Middleware) *chi.Mux {
|
||||
)
|
||||
|
||||
router.HandleFunc(
|
||||
"/webhook/{uuid}",
|
||||
middleware.ReceiverRoute,
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
// Stands in for the real handler: an unknown entrypoint
|
||||
// UUID 404s, a known one succeeds.
|
||||
if chi.URLParam(r, "uuid") != "known" {
|
||||
// Stands in for the real handler: a known entrypoint
|
||||
// UUID succeeds, a failing one is a server error, and
|
||||
// any other 404s.
|
||||
switch chi.URLParam(r, "uuid") {
|
||||
case knownEntrypointUUID:
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case failingEntrypointUUID:
|
||||
http.Error(
|
||||
w, "boom", http.StatusInternalServerError,
|
||||
)
|
||||
default:
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -271,11 +296,11 @@ func TestAccessLog_InventedReceiverPathsLogRoutePattern(t *testing.T) {
|
||||
assertFloodIsBounded(
|
||||
t,
|
||||
func(i int) string {
|
||||
return "/webhook/" + attackerMarker +
|
||||
return "/h/" + attackerMarker +
|
||||
strings.Repeat("x", i) + "?q=" + attackerMarker
|
||||
},
|
||||
http.StatusNotFound,
|
||||
"/webhook/{uuid}",
|
||||
"/h/{uuid}",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -322,11 +347,13 @@ func oversizedValue(ch string) string {
|
||||
}
|
||||
|
||||
// oversizedHeaders fills every client-supplied header the access log
|
||||
// reads with the same value.
|
||||
// reads with the same value. The Referer carries it as the host of an
|
||||
// absolute URL, because the access log keeps only a Referer's scheme
|
||||
// and host.
|
||||
func oversizedHeaders(value string) map[string]string {
|
||||
return map[string]string{
|
||||
"User-Agent": value,
|
||||
"Referer": value,
|
||||
"Referer": "http://" + value,
|
||||
"X-Request-Id": value,
|
||||
}
|
||||
}
|
||||
@@ -334,11 +361,12 @@ func oversizedHeaders(value string) map[string]string {
|
||||
// sizeCase is one way of pointing 8 KB of client-chosen text at the
|
||||
// access log.
|
||||
type sizeCase struct {
|
||||
target string
|
||||
headers map[string]string
|
||||
wantStatus int
|
||||
wantURL string
|
||||
bound int
|
||||
target string
|
||||
headers map[string]string
|
||||
wantStatus int
|
||||
wantURL string
|
||||
wantClientIP string
|
||||
bound int
|
||||
}
|
||||
|
||||
// lineSizeCases enumerates every part of a request that reaches the
|
||||
@@ -346,10 +374,10 @@ type sizeCase struct {
|
||||
func lineSizeCases() map[string]sizeCase {
|
||||
cases := map[string]sizeCase{
|
||||
"oversized path segment": {
|
||||
target: "/webhook/" + attackerMarker +
|
||||
target: "/h/" + attackerMarker +
|
||||
strings.Repeat("x", oversizedSegmentBytes),
|
||||
wantStatus: http.StatusNotFound,
|
||||
wantURL: "/webhook/{uuid}",
|
||||
wantURL: "/h/{uuid}",
|
||||
bound: maxLineBytes,
|
||||
},
|
||||
// /.well-known/healthcheck answers 200 to anyone and has no
|
||||
@@ -375,8 +403,7 @@ func lineSizeCases() map[string]sizeCase {
|
||||
}
|
||||
|
||||
// The url field on a 5xx keeps the concrete path, so it reaches its
|
||||
// own budget on the same line as the three header fields. That is
|
||||
// the widest access log line the service can be made to write.
|
||||
// own budget on the same line as the three header fields.
|
||||
longPath := "/boom/" + strings.Repeat("x", oversizedSegmentBytes)
|
||||
wantLongURL := longPath[:maxFieldBytes] + truncationSuffix
|
||||
|
||||
@@ -420,6 +447,29 @@ func lineSizeCases() map[string]sizeCase {
|
||||
}
|
||||
}
|
||||
|
||||
// From a trusted proxy, clientIP is read out of X-Forwarded-For,
|
||||
// which the client writes. What bounds the field is that only one
|
||||
// address from the header is written, and it is written parsed, with
|
||||
// no zone. An IPv6 address with all eight groups at four digits is
|
||||
// the longest such address; here it carries an 8 KB zone, which must
|
||||
// not reach the line. It goes on the 5xx line with all three header
|
||||
// fields at their budget.
|
||||
const longestIPv6 = "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"
|
||||
|
||||
forwarded := oversizedHeaders(oversizedValue("h"))
|
||||
forwarded[headerXFF] = oversizedValue("h") + ", " +
|
||||
longestIPv6 + "%" + oversizedValue("h")
|
||||
|
||||
cases["oversized X-Forwarded-For from a trusted proxy "+
|
||||
"with a 5xx concrete url"] = sizeCase{
|
||||
target: longPath,
|
||||
headers: forwarded,
|
||||
wantStatus: http.StatusInternalServerError,
|
||||
wantURL: wantLongURL,
|
||||
wantClientIP: longestIPv6,
|
||||
bound: maxCappedLineBytes,
|
||||
}
|
||||
|
||||
return cases
|
||||
}
|
||||
|
||||
@@ -460,6 +510,14 @@ func TestAccessLog_LineSizeDoesNotTrackInputSize(t *testing.T) {
|
||||
require.Len(t, entries, 1)
|
||||
assert.Equal(t, tc.wantURL, entries[0]["url"])
|
||||
|
||||
// Set only by the X-Forwarded-For case, where it proves
|
||||
// the header was read rather than ignored.
|
||||
if tc.wantClientIP != "" {
|
||||
assert.Equal(
|
||||
t, tc.wantClientIP, entries[0]["clientIP"],
|
||||
)
|
||||
}
|
||||
|
||||
// The markers sit at the far end of the client-chosen
|
||||
// text, so their absence is what proves the redaction and
|
||||
// the truncation actually ran.
|
||||
@@ -605,17 +663,111 @@ func TestAccessLog_SuccessKeepsConcretePathAndRedactsQuery(
|
||||
router := accessLogRouter(m)
|
||||
|
||||
assert.Equal(
|
||||
t, http.StatusOK, get(t, router, "/webhook/known?src=ci"),
|
||||
t, http.StatusOK,
|
||||
get(t, router, "/.well-known/healthcheck?src=ci"),
|
||||
)
|
||||
|
||||
// The path resolved against a stored entrypoint, so it stays. The
|
||||
// query never does: see TestAccessLog_UnauthenticatedSuccess...
|
||||
entries := accessLogEntries(t, buf)
|
||||
require.Len(t, entries, 1)
|
||||
assert.Equal(t, "/webhook/known?(redacted)", entries[0]["url"])
|
||||
assert.Equal(
|
||||
t, "/.well-known/healthcheck?(redacted)", entries[0]["url"],
|
||||
)
|
||||
assert.NotContains(t, buf.String(), "src=ci")
|
||||
}
|
||||
|
||||
// TestAccessLog_ReceiverNeverLogsTheUUID covers the two statuses that
|
||||
// keep the concrete path on every other route. On the receiver that
|
||||
// path carries the entrypoint UUID, so the line names the route
|
||||
// pattern instead.
|
||||
func TestAccessLog_ReceiverNeverLogsTheUUID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for entrypointUUID, wantStatus := range map[string]int{
|
||||
knownEntrypointUUID: http.StatusOK,
|
||||
failingEntrypointUUID: http.StatusInternalServerError,
|
||||
} {
|
||||
t.Run(strconv.Itoa(wantStatus), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m, buf := capturingMiddleware(t)
|
||||
router := accessLogRouter(m)
|
||||
|
||||
assert.Equal(
|
||||
t, wantStatus,
|
||||
get(t, router, "/h/"+entrypointUUID+"?src=ci"),
|
||||
)
|
||||
|
||||
entries := accessLogEntries(t, buf)
|
||||
require.Len(t, entries, 1)
|
||||
assert.Equal(t, middleware.ReceiverRoute, entries[0]["url"])
|
||||
assert.NotContains(t, buf.String(), entrypointUUID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccessLog_ReceiverPreflightNeverLogsTheUUID sends a CORS
|
||||
// preflight to the receiver in dev, in the production middleware
|
||||
// order. CORS answers it with 200 before chi routes it, so the line
|
||||
// names no route and carries no UUID.
|
||||
func TestAccessLog_ReceiverPreflightNeverLogsTheUUID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m, buf := capturingMiddleware(t)
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Use(m.Logging())
|
||||
router.Use(m.CORS())
|
||||
router.HandleFunc(
|
||||
middleware.ReceiverRoute,
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodOptions,
|
||||
"/h/"+knownEntrypointUUID, nil,
|
||||
)
|
||||
req.Header.Set("Origin", "https://sender.example")
|
||||
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
entries := accessLogEntries(t, buf)
|
||||
require.Len(t, entries, 1)
|
||||
assert.Equal(t, unmatchedRouteLiteral, entries[0]["url"])
|
||||
assert.NotContains(t, buf.String(), knownEntrypointUUID)
|
||||
}
|
||||
|
||||
// TestAccessLog_RefererNeverLogsTheUUID sends receiver requests whose
|
||||
// Referer is the entrypoint URL, as a sender's is after it follows a
|
||||
// redirect from http:// to https://. The referer field keeps only the
|
||||
// scheme and host, and a Referer without them is logged as a marker.
|
||||
func TestAccessLog_RefererNeverLogsTheUUID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m, buf := capturingMiddleware(t)
|
||||
router := accessLogRouter(m)
|
||||
|
||||
for _, referer := range []string{
|
||||
"http://webhooker.example/h/" + knownEntrypointUUID + "?src=ci",
|
||||
"/h/" + knownEntrypointUUID,
|
||||
} {
|
||||
assert.Equal(t, http.StatusOK, getWithHeaders(
|
||||
t, router, "/h/"+knownEntrypointUUID,
|
||||
map[string]string{"Referer": referer},
|
||||
))
|
||||
}
|
||||
|
||||
entries := accessLogEntries(t, buf)
|
||||
require.Len(t, entries, 2)
|
||||
assert.Equal(t, "http://webhooker.example", entries[0]["referer"])
|
||||
assert.Equal(t, "(redacted)", entries[1]["referer"])
|
||||
assert.NotContains(t, buf.String(), knownEntrypointUUID)
|
||||
}
|
||||
|
||||
func TestAccessLog_ServerErrorKeepsConcreteURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -640,7 +792,7 @@ func TestAccessLog_RetainsEveryOtherField(t *testing.T) {
|
||||
assert.Equal(
|
||||
t,
|
||||
http.StatusNotFound,
|
||||
get(t, router, "/webhook/"+attackerMarker),
|
||||
get(t, router, "/h/"+attackerMarker),
|
||||
)
|
||||
|
||||
entries := accessLogEntries(t, buf)
|
||||
@@ -648,7 +800,8 @@ func TestAccessLog_RetainsEveryOtherField(t *testing.T) {
|
||||
|
||||
for _, key := range []string{
|
||||
"request_start", "method", "url", "useragent", "request_id",
|
||||
"referer", "proto", "remoteIP", "status", "latency_ms",
|
||||
"referer", "proto", "remoteIP", "clientIP", "status",
|
||||
"latency_ms",
|
||||
} {
|
||||
assert.Contains(t, entries[0], key)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package middleware_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/middleware"
|
||||
"sneak.berlin/go/webhooker/internal/middleware/middlewaretest"
|
||||
)
|
||||
|
||||
const (
|
||||
// forwardedChain is the X-Forwarded-For a request arrives with:
|
||||
// the client, then a second proxy inside trustedProxyCIDR that the
|
||||
// request passed through before reaching trustedPeer.
|
||||
forwardedChain = clientIPv4 + ", 10.0.0.2"
|
||||
|
||||
// untrustedPeer is a peer outside trustedProxyCIDR, so its
|
||||
// X-Forwarded-For is ignored and the peer is the client.
|
||||
untrustedPeer = "192.0.2.10:5555"
|
||||
|
||||
// oneRequestPerMinute is the receiver limit these tests install:
|
||||
// the second request on a path is rejected, and the aggregate
|
||||
// limit is ReceiverAggregateMultiplierConst.
|
||||
oneRequestPerMinute = 1
|
||||
)
|
||||
|
||||
// clientLogSite is one log line that names the client. build wraps the
|
||||
// middleware that writes it around a handler, and requests is how many
|
||||
// identical requests it takes before the line is written.
|
||||
type clientLogSite struct {
|
||||
build func(m *middleware.Middleware) http.Handler
|
||||
requests int
|
||||
}
|
||||
|
||||
// clientLogSites maps the message of each line that names the client
|
||||
// to the way to make it be written.
|
||||
func clientLogSites() map[string]clientLogSite {
|
||||
served := func(*middleware.Middleware) http.Handler {
|
||||
return okHandler()
|
||||
}
|
||||
|
||||
receiver := func(m *middleware.Middleware) http.Handler {
|
||||
return m.ReceiverRateLimit()(okHandler())
|
||||
}
|
||||
|
||||
login := func(m *middleware.Middleware) http.Handler {
|
||||
return http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
m.RecordLoginFailure(r, "someone")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
csrf := func(m *middleware.Middleware) http.Handler {
|
||||
return m.CSRF(http.HandlerFunc(forbidden))(okHandler())
|
||||
}
|
||||
|
||||
passwordChange := func(m *middleware.Middleware) http.Handler {
|
||||
return m.PasswordChangeRateLimit()(okHandler())
|
||||
}
|
||||
|
||||
replay := func(m *middleware.Middleware) http.Handler {
|
||||
return m.ReplayRateLimit()(okHandler())
|
||||
}
|
||||
|
||||
resubmit := func(m *middleware.Middleware) http.Handler {
|
||||
return m.ResubmitRateLimit()(okHandler())
|
||||
}
|
||||
|
||||
return map[string]clientLogSite{
|
||||
"http request": {
|
||||
build: served,
|
||||
requests: 1,
|
||||
},
|
||||
"webhook receiver rate limit exceeded": {
|
||||
build: receiver,
|
||||
requests: oneRequestPerMinute + 1,
|
||||
},
|
||||
// The aggregate limit sits in front of the per-entrypoint
|
||||
// one, so the requests that one rejects count towards it.
|
||||
"webhook receiver aggregate rate limit exceeded": {
|
||||
build: receiver,
|
||||
requests: middleware.ReceiverAggregateMultiplierConst*
|
||||
oneRequestPerMinute + 1,
|
||||
},
|
||||
"login failure limit exceeded": {
|
||||
build: login,
|
||||
requests: middleware.LoginRateLimitConst + 1,
|
||||
},
|
||||
"csrf: token validation failed": {
|
||||
build: csrf,
|
||||
requests: 1,
|
||||
},
|
||||
"password change rate limit exceeded": {
|
||||
build: passwordChange,
|
||||
requests: middleware.PasswordChangeRateLimitConst + 1,
|
||||
},
|
||||
"delivery replay rate limit exceeded": {
|
||||
build: replay,
|
||||
requests: middleware.ReplayRateLimitConst + 1,
|
||||
},
|
||||
"event resubmit rate limit exceeded": {
|
||||
build: resubmit,
|
||||
requests: middleware.ResubmitRateLimitConst + 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// clientLogLines sends the site's requests from peer, each carrying
|
||||
// forwardedChain, through Logging and then the site, as production
|
||||
// does, and returns the logged lines whose message is msg.
|
||||
func clientLogLines(
|
||||
t *testing.T, site clientLogSite, msg, peer string,
|
||||
) []map[string]any {
|
||||
t.Helper()
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
log := slog.New(slog.NewJSONHandler(
|
||||
buf,
|
||||
&slog.HandlerOptions{Level: slog.LevelDebug},
|
||||
))
|
||||
|
||||
cfg := &config.Config{
|
||||
Environment: config.EnvironmentDev,
|
||||
ReceiverRateLimit: oneRequestPerMinute,
|
||||
TrustedProxies: trustedProxies(trustedProxyCIDR),
|
||||
}
|
||||
|
||||
m := middlewaretest.New(
|
||||
t, log, cfg, newTestSessionManager(t, cfg),
|
||||
)
|
||||
handler := m.Logging()(site.build(m))
|
||||
|
||||
for range site.requests {
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodPost, "/h/x", nil,
|
||||
)
|
||||
req.RemoteAddr = peer
|
||||
req.Header.Set(headerXFF, forwardedChain)
|
||||
|
||||
handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
}
|
||||
|
||||
var lines []map[string]any
|
||||
|
||||
for _, entry := range accessLogEntries(t, buf) {
|
||||
if entry["msg"] == msg {
|
||||
lines = append(lines, entry)
|
||||
}
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
// TestClientIP_LoggedNextToThePeer checks that every line that names
|
||||
// the client carries both addresses: remoteIP, the connecting peer,
|
||||
// and clientIP, the client the rate limiters key on.
|
||||
func TestClientIP_LoggedNextToThePeer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]struct {
|
||||
peer string
|
||||
wantRemote string
|
||||
wantClient string
|
||||
}{
|
||||
"trusted proxy with a forwarded chain": {
|
||||
peer: trustedPeer,
|
||||
wantRemote: "10.0.0.1",
|
||||
wantClient: clientIPv4,
|
||||
},
|
||||
"untrusted peer": {
|
||||
peer: untrustedPeer,
|
||||
wantRemote: "192.0.2.10",
|
||||
wantClient: "192.0.2.10",
|
||||
},
|
||||
}
|
||||
|
||||
for msg, site := range clientLogSites() {
|
||||
for name, tc := range cases {
|
||||
t.Run(msg+"/"+name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lines := clientLogLines(t, site, msg, tc.peer)
|
||||
require.NotEmpty(t, lines, "%q was never logged", msg)
|
||||
|
||||
for _, line := range lines {
|
||||
assert.Equal(t, tc.wantRemote, line["remoteIP"])
|
||||
assert.Equal(t, tc.wantClient, line["clientIP"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ func CSRFToken(r *http.Request) string {
|
||||
// key to sign a CSRF cookie and validates a masked token submitted via
|
||||
// the "csrf_token" form field (or the "X-CSRF-Token" header) on
|
||||
// POST/PUT/PATCH/DELETE requests. Requests with an invalid or missing
|
||||
// token receive a 403 Forbidden response.
|
||||
// token are logged and answered by forbidden, which must write the 403.
|
||||
//
|
||||
// The middleware detects the client-facing transport protocol
|
||||
// per-request via reqtls.IsTLS, the single TLS predicate the session
|
||||
@@ -36,17 +36,19 @@ func CSRFToken(r *http.Request) string {
|
||||
// Two gorilla/csrf instances are maintained — one with Secure cookies
|
||||
// (for TLS) and one without (for plaintext HTTP) — because the
|
||||
// csrf.Secure option is set at creation time, not per-request.
|
||||
func (m *Middleware) CSRF() func(http.Handler) http.Handler {
|
||||
func (m *Middleware) CSRF(
|
||||
forbidden http.Handler,
|
||||
) func(http.Handler) http.Handler {
|
||||
csrfErrorHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// CSRF is registered ahead of RequireAuth on every route
|
||||
// group that uses it, so this WARN is reachable by an
|
||||
// unauthenticated client: a POST with no token to
|
||||
// /source/<any length of any text>/edit lands here. The
|
||||
// /hook/<any length of any text>/edit lands here. The
|
||||
// method and path are capped against the same budgets as
|
||||
// the access log. remote_addr is set by net/http from the
|
||||
// accepted connection rather than by the client, and
|
||||
// the access log. remoteIP and clientIP are the same
|
||||
// addresses the access log carries, and
|
||||
// csrf.FailureReason returns one of gorilla/csrf's own
|
||||
// fixed error values, so neither is client-sized.
|
||||
// fixed error values, so none of them is client-sized.
|
||||
m.log.Warn("csrf: token validation failed",
|
||||
"method", logfield.Truncate(
|
||||
r.Method, maxLogMethodBytes,
|
||||
@@ -54,10 +56,11 @@ func (m *Middleware) CSRF() func(http.Handler) http.Handler {
|
||||
"path", logfield.Truncate(
|
||||
r.URL.Path, logfield.MaxBytes,
|
||||
),
|
||||
"remote_addr", r.RemoteAddr,
|
||||
"remoteIP", RemoteIP(r),
|
||||
"clientIP", ClientIP(r),
|
||||
"reason", csrf.FailureReason(r),
|
||||
)
|
||||
http.Error(w, "Forbidden - invalid CSRF token", http.StatusForbidden)
|
||||
forbidden.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
key := m.session.GetKey()
|
||||
|
||||
@@ -18,6 +18,12 @@ import (
|
||||
// csrfCookieName is the gorilla/csrf cookie name.
|
||||
const csrfCookieName = "_gorilla_csrf"
|
||||
|
||||
// forbidden stands in for the error page the server hands CSRF to
|
||||
// answer a refused request with.
|
||||
func forbidden(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}
|
||||
|
||||
// csrfGetToken performs a GET request through the CSRF middleware
|
||||
// and returns the token and cookies.
|
||||
func csrfGetToken(
|
||||
@@ -98,7 +104,7 @@ func TestCSRF_GETSetsToken(t *testing.T) {
|
||||
|
||||
var gotToken string
|
||||
|
||||
handler := m.CSRF()(http.HandlerFunc(
|
||||
handler := m.CSRF(http.HandlerFunc(forbidden))(http.HandlerFunc(
|
||||
func(_ http.ResponseWriter, r *http.Request) {
|
||||
gotToken = middleware.CSRFToken(r)
|
||||
},
|
||||
@@ -120,7 +126,7 @@ func TestCSRF_POSTWithValidToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
||||
csrfMW := m.CSRF()
|
||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||
|
||||
getReq := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
@@ -152,7 +158,7 @@ func csrfPOSTWithoutTokenTest(
|
||||
t.Helper()
|
||||
|
||||
m, _ := testMiddleware(t, env)
|
||||
csrfMW := m.CSRF()
|
||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||
|
||||
// GET to establish the CSRF cookie
|
||||
getHandler := csrfMW(http.HandlerFunc(
|
||||
@@ -209,7 +215,7 @@ func TestCSRF_POSTWithInvalidToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
||||
csrfMW := m.CSRF()
|
||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||
|
||||
// GET to establish the CSRF cookie
|
||||
getHandler := csrfMW(http.HandlerFunc(
|
||||
@@ -265,7 +271,7 @@ func TestCSRF_GETDoesNotValidate(t *testing.T) {
|
||||
|
||||
var called bool
|
||||
|
||||
handler := m.CSRF()(http.HandlerFunc(
|
||||
handler := m.CSRF(http.HandlerFunc(forbidden))(http.HandlerFunc(
|
||||
func(_ http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
},
|
||||
@@ -328,7 +334,7 @@ func csrfTookStrictPath(
|
||||
t.Helper()
|
||||
|
||||
m, _ := testMiddleware(t, env)
|
||||
csrfMW := m.CSRF()
|
||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||
|
||||
newReq := func(method string) *http.Request {
|
||||
r := httptest.NewRequestWithContext(
|
||||
@@ -477,7 +483,7 @@ func TestCSRF_ProdMode_PlaintextHTTP_POSTWithValidToken(
|
||||
t.Parallel()
|
||||
|
||||
m, _ := testMiddleware(t, config.EnvironmentProd)
|
||||
csrfMW := m.CSRF()
|
||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||
|
||||
getReq := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
@@ -517,7 +523,7 @@ func TestCSRF_ProdMode_BehindProxy_POSTWithValidToken(
|
||||
t.Parallel()
|
||||
|
||||
m, _ := testMiddleware(t, config.EnvironmentProd)
|
||||
csrfMW := m.CSRF()
|
||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||
|
||||
getReq := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
@@ -562,7 +568,7 @@ func TestCSRF_ProdMode_DirectTLS_POSTWithValidToken(
|
||||
t.Parallel()
|
||||
|
||||
m, _ := testMiddleware(t, config.EnvironmentProd)
|
||||
csrfMW := m.CSRF()
|
||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||
|
||||
getReq := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
|
||||
@@ -10,8 +10,7 @@ import (
|
||||
|
||||
// MetricsMiddlewareForTest builds the metrics recording middleware
|
||||
// against a caller-supplied recorder, so a test can gather from its
|
||||
// own Prometheus registry rather than the process-wide default one
|
||||
// that Middleware.Metrics uses.
|
||||
// own Prometheus registry without building a whole Middleware.
|
||||
func MetricsMiddlewareForTest(
|
||||
rec httpmetrics.Recorder,
|
||||
) func(http.Handler) http.Handler {
|
||||
@@ -133,6 +132,12 @@ func (g *LoginGuard) TrackedKeysForTest() (int, int) {
|
||||
// passwordChangeRateLimit constant.
|
||||
const PasswordChangeRateLimitConst = passwordChangeRateLimit
|
||||
|
||||
// ReplayRateLimitConst exposes the replayRateLimit constant.
|
||||
const ReplayRateLimitConst = replayRateLimit
|
||||
|
||||
// ResubmitRateLimitConst exposes the resubmitRateLimit constant.
|
||||
const ResubmitRateLimitConst = resubmitRateLimit
|
||||
|
||||
// ReceiverAggregateMultiplierConst exposes the
|
||||
// receiverAggregateMultiplier constant.
|
||||
const ReceiverAggregateMultiplierConst = receiverAggregateMultiplier
|
||||
|
||||
@@ -10,8 +10,10 @@ package middleware_test
|
||||
// the client's choosing.
|
||||
// - CSRF's 403 rejection, at WARN, also registered ahead of
|
||||
// RequireAuth.
|
||||
// - The rate limiters' 429 rejection, at WARN, on the
|
||||
// unauthenticated receiver among others.
|
||||
// - The rate limiters' 429 rejection, at WARN. On the receiver
|
||||
// route it names the route pattern rather than the path, and
|
||||
// the limiters that log a path sit behind RequireAuth, so its cap
|
||||
// is defensive.
|
||||
// - RequireAuth's own unauthenticated-request line, at DEBUG.
|
||||
// - RecordLoginFailure's throttle rejection, at WARN. Its cap is
|
||||
// defensive rather than load-bearing today: chi pins the one
|
||||
@@ -41,6 +43,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/middleware"
|
||||
"sneak.berlin/go/webhooker/internal/middleware/middlewaretest"
|
||||
)
|
||||
|
||||
// bodyLimitBytes is the MaxBodySize cap these tests install. Any
|
||||
@@ -155,9 +158,9 @@ func capturingBoundMiddleware(
|
||||
ReceiverRateLimit: receiverLimitPerMinute,
|
||||
}
|
||||
|
||||
sess := newTestSessionManager(cfg, log, nil)
|
||||
sess := newTestSessionManager(t, cfg)
|
||||
|
||||
return middleware.NewForTest(log, cfg, sess), buf
|
||||
return middlewaretest.New(t, log, cfg, sess), buf
|
||||
}
|
||||
|
||||
// unreachable is a next-handler that fails the test if the middleware
|
||||
@@ -260,15 +263,21 @@ func logSites() map[string]logSite {
|
||||
) http.Handler {
|
||||
t.Helper()
|
||||
|
||||
return m.CSRF()(unreachable(t))
|
||||
return m.CSRF(http.HandlerFunc(forbidden))(
|
||||
unreachable(t),
|
||||
)
|
||||
},
|
||||
send: postNoToken,
|
||||
wantStatus: http.StatusForbidden,
|
||||
},
|
||||
// The per-entrypoint receiver limiter, unauthenticated. Its
|
||||
// bucket is keyed on the path, so the first request through a
|
||||
// fresh path is served and only the ones after it are
|
||||
// rejected; sendUntilLimited absorbs that.
|
||||
// The 429 line every limiter but the aggregate one shares.
|
||||
// Mounted outside any route, as here, it logs the path, as
|
||||
// it does for the limiters behind RequireAuth; on
|
||||
// ReceiverRoute it names the pattern instead. The
|
||||
// per-entrypoint receiver limiter's bucket is keyed on the
|
||||
// path, so the first request through a fresh path is served
|
||||
// and only the ones after it are rejected; sendUntilLimited
|
||||
// absorbs that.
|
||||
"receiver rate limit 429": {
|
||||
build: func(
|
||||
t *testing.T, m *middleware.Middleware,
|
||||
@@ -383,7 +392,7 @@ func TestLogLines_ClientChosenPathDoesNotSizeTheLine(t *testing.T) {
|
||||
t, newHandler,
|
||||
)
|
||||
|
||||
path := "/source/" +
|
||||
path := "/hook/" +
|
||||
oversizedPathSegment(fill) + "/edit"
|
||||
|
||||
assert.Equal(
|
||||
@@ -434,7 +443,7 @@ func TestLoginThrottle_LogLineDoesNotTrackPathSize(t *testing.T) {
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodPost,
|
||||
"/source/"+
|
||||
"/hook/"+
|
||||
oversizedPathSegment(fill)+"/login",
|
||||
nil,
|
||||
)
|
||||
@@ -499,7 +508,7 @@ func TestMaxBodySize_FloodOfOversizePathsDoesNotGrowTheLog(
|
||||
http.StatusRequestEntityTooLarge,
|
||||
postOversize(
|
||||
h,
|
||||
"/source/"+segment(i)+"/edit",
|
||||
"/hook/"+segment(i)+"/edit",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -108,10 +108,10 @@ type failureWindow struct {
|
||||
//
|
||||
// A limiter that spends budget on arrival cannot protect a
|
||||
// single-admin product: behind the reverse proxy the deployment
|
||||
// requires, with TRUSTED_PROXIES unset, every client keys on the
|
||||
// proxy, so a stranger trickling five POSTs a minute keeps the one
|
||||
// bucket full and the operator's own correct password is answered 429
|
||||
// forever. There is no second administrative path.
|
||||
// requires, when TRUSTED_PROXIES does not cover it, every client
|
||||
// keys on the proxy, so a stranger trickling five POSTs a minute
|
||||
// keeps the one bucket full and the operator's own correct password
|
||||
// is answered 429 forever. There is no second administrative path.
|
||||
//
|
||||
// So budget is spent only by a FAILED verification. A correct
|
||||
// password is never throttled, whatever the counters say, which is
|
||||
@@ -385,6 +385,8 @@ func (m *Middleware) RecordLoginFailure(
|
||||
"path", logfield.Truncate(
|
||||
r.URL.Path, logfield.MaxBytes,
|
||||
),
|
||||
"remoteIP", RemoteIP(r),
|
||||
"clientIP", ClientIP(r),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -236,9 +236,9 @@ func TestLoginGuard_SemaphoreBoundsConcurrentVerifications(
|
||||
|
||||
// rendezvousDeadlock is the deadlock guard described below.
|
||||
// It is orders of magnitude longer than any scheduling delay,
|
||||
// so it never decides the result, and well inside script/test's
|
||||
// 30s timeout, so a wedge fails on the assertion instead of
|
||||
// blowing the package timeout.
|
||||
// so it never decides the result, and well inside the 90s
|
||||
// package timeout of the Dockerfile's test phase, so a wedge
|
||||
// fails on the assertion instead of blowing that timeout.
|
||||
rendezvousDeadlock = 5 * time.Second
|
||||
)
|
||||
|
||||
|
||||
@@ -7,9 +7,7 @@ import (
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
httpmetrics "github.com/slok/go-http-metrics/metrics"
|
||||
prommetrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
||||
ghmm "github.com/slok/go-http-metrics/middleware"
|
||||
"github.com/slok/go-http-metrics/middleware/std"
|
||||
)
|
||||
|
||||
// inflightHandler is the fixed `handler` label on
|
||||
@@ -40,10 +38,11 @@ const unmatchedMethod = unmatchedRoute
|
||||
//
|
||||
// The pattern is what bounds the label's domain to the routes the
|
||||
// service registers. The path does not bound it at all — every byte
|
||||
// after /webhook/ is client-chosen, so labelling by path lets any
|
||||
// after /h/ is client-chosen, so labelling by path lets any
|
||||
// unauthenticated client mint permanent series at will, and publishes
|
||||
// the entrypoint UUID (the receiver's only credential) in the scrape
|
||||
// while doing it.
|
||||
// while doing it. The access log and the 429 line read it for the
|
||||
// same reason: see ReceiverRoute.
|
||||
//
|
||||
// chi populates the route context during routeHTTP, so this is only
|
||||
// valid once routing has run. Every caller below is on the recording
|
||||
@@ -151,17 +150,17 @@ func (r boundedLabelRecorder) AddInflightRequests(
|
||||
|
||||
var _ httpmetrics.Recorder = boundedLabelRecorder{}
|
||||
|
||||
// Metrics returns middleware that records Prometheus HTTP metrics on
|
||||
// the default registry, which is the one the /metrics route gathers.
|
||||
// Metrics returns middleware that records Prometheus HTTP metrics
|
||||
// with the Middleware's one recorder, which New builds on the registry
|
||||
// it is given: in the application, the one the /metrics route serves.
|
||||
// Every call reuses that recorder, so any number of routers can
|
||||
// install it.
|
||||
func (s *Middleware) Metrics() func(http.Handler) http.Handler {
|
||||
return metricsMiddleware(
|
||||
prommetrics.NewRecorder(prommetrics.Config{}),
|
||||
)
|
||||
return metricsMiddleware(s.metricsRecorder)
|
||||
}
|
||||
|
||||
// metricsMiddleware builds the recording middleware against a given
|
||||
// recorder, so tests can gather from a registry of their own instead
|
||||
// of the process-wide default.
|
||||
// recorder, so tests can gather from a registry of their own.
|
||||
func metricsMiddleware(
|
||||
rec httpmetrics.Recorder,
|
||||
) func(http.Handler) http.Handler {
|
||||
@@ -170,13 +169,72 @@ func metricsMiddleware(
|
||||
})
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
// The handler id is unmatchedRoute rather than "" so that
|
||||
// the client-chosen URL path never enters the metrics
|
||||
// pipeline at all: an empty id is the library's signal to
|
||||
// substitute it. boundedLabelRecorder overwrites this value
|
||||
// on every observation, so it is reachable only if that
|
||||
// decorator is removed — in which case the metrics collapse
|
||||
// to one series instead of leaking again.
|
||||
return std.Handler(unmatchedRoute, mdlw, next)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mw := &metricsResponseWriter{
|
||||
ResponseWriter: w,
|
||||
request: r,
|
||||
statusCode: http.StatusOK,
|
||||
}
|
||||
|
||||
// The handler id is unmatchedRoute rather than "" so
|
||||
// that the client-chosen URL path never enters the
|
||||
// metrics pipeline at all: an empty id is the library's
|
||||
// signal to substitute it. boundedLabelRecorder
|
||||
// overwrites this value on every observation, so it is
|
||||
// reachable only if that decorator is removed — in which
|
||||
// case the metrics collapse to one series instead of
|
||||
// leaking again.
|
||||
mdlw.Measure(unmatchedRoute, mw, func() {
|
||||
next.ServeHTTP(mw, r)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// metricsResponseWriter records the status code and body size of a
|
||||
// response, and hands them with the request to go-http-metrics'
|
||||
// Measure as its Reporter.
|
||||
//
|
||||
// It stands in for the library's std.Handler, whose writer has no
|
||||
// Unwrap: behind it, http.ResponseController cannot reach net/http's
|
||||
// own writer, so a handler's write deadline fails with metrics on.
|
||||
type metricsResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
|
||||
request *http.Request
|
||||
statusCode int
|
||||
bytesWritten int64
|
||||
}
|
||||
|
||||
func (w *metricsResponseWriter) WriteHeader(code int) {
|
||||
w.statusCode = code
|
||||
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (w *metricsResponseWriter) Write(b []byte) (int, error) {
|
||||
w.bytesWritten += int64(len(b))
|
||||
|
||||
//nolint:wrapcheck // Pass the writer's own error through unchanged.
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// Unwrap lets http.ResponseController reach the writer underneath, so
|
||||
// a handler can still flush or set a write deadline with metrics on.
|
||||
func (w *metricsResponseWriter) Unwrap() http.ResponseWriter {
|
||||
return w.ResponseWriter
|
||||
}
|
||||
|
||||
func (w *metricsResponseWriter) Method() string { return w.request.Method }
|
||||
|
||||
func (w *metricsResponseWriter) Context() context.Context {
|
||||
return w.request.Context()
|
||||
}
|
||||
|
||||
func (w *metricsResponseWriter) URLPath() string { return w.request.URL.Path }
|
||||
|
||||
func (w *metricsResponseWriter) StatusCode() int { return w.statusCode }
|
||||
|
||||
func (w *metricsResponseWriter) BytesWritten() int64 { return w.bytesWritten }
|
||||
|
||||
var _ ghmm.Reporter = (*metricsResponseWriter)(nil)
|
||||
|
||||
@@ -50,7 +50,7 @@ func realMethods() []string {
|
||||
// dimension varying, so any series growth a probe produces is the
|
||||
// method label's and nothing else's.
|
||||
func methodProbePath() string {
|
||||
return "/webhook/" + uuid.NewString()
|
||||
return "/h/" + uuid.NewString()
|
||||
}
|
||||
|
||||
// inventedMethods returns n distinct RFC 9110 method tokens that no
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/middleware"
|
||||
"sneak.berlin/go/webhooker/internal/middleware/middlewaretest"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -28,7 +29,7 @@ const (
|
||||
|
||||
// receiverRoutePattern is the one handler label every receiver
|
||||
// request must produce, however the client varies the path.
|
||||
receiverRoutePattern = "/webhook/{uuid}"
|
||||
receiverRoutePattern = "/h/{uuid}"
|
||||
|
||||
// okRoute is a static route used to pin that the response-writer
|
||||
// interceptor still reports status and size after the handler id
|
||||
@@ -57,9 +58,8 @@ const (
|
||||
// Server.setupWebhookRoutes inside it. That ordering is the whole
|
||||
// defect, so a test that flattens it would prove nothing.
|
||||
//
|
||||
// The recorder writes to a registry of the test's own rather than the
|
||||
// process-wide default one, so each test observes only its own
|
||||
// traffic.
|
||||
// The recorder writes to a registry of the test's own, so each test
|
||||
// observes only its own traffic.
|
||||
func metricsTestRouter(
|
||||
t *testing.T,
|
||||
receiverLimit int,
|
||||
@@ -71,8 +71,8 @@ func metricsTestRouter(
|
||||
Environment: "prod",
|
||||
ReceiverRateLimit: receiverLimit,
|
||||
}
|
||||
m := middleware.NewForTest(
|
||||
log, cfg, newTestSessionManager(cfg, log, nil),
|
||||
m := middlewaretest.New(
|
||||
t, log, cfg, newTestSessionManager(t, cfg),
|
||||
)
|
||||
|
||||
reg := prometheus.NewRegistry()
|
||||
@@ -143,13 +143,13 @@ func drivePaths(
|
||||
return drive(t, h, probes)
|
||||
}
|
||||
|
||||
// receiverPaths returns n distinct /webhook/ paths, each naming a
|
||||
// receiverPaths returns n distinct /h/ paths, each naming a
|
||||
// fresh UUID exactly as an unauthenticated flood would.
|
||||
func receiverPaths(n int) []string {
|
||||
paths := make([]string, 0, n)
|
||||
|
||||
for range n {
|
||||
paths = append(paths, "/webhook/"+uuid.NewString())
|
||||
paths = append(paths, "/h/"+uuid.NewString())
|
||||
}
|
||||
|
||||
return paths
|
||||
@@ -220,7 +220,7 @@ func keys(set map[string]struct{}) []string {
|
||||
|
||||
// TestMetrics_DistinctReceiverPathsMintOneLabelSet is the direct
|
||||
// assertion the issue asks for: N requests to N distinct
|
||||
// /webhook/<uuid> paths must produce exactly ONE handler label, the
|
||||
// /h/<uuid> paths must produce exactly ONE handler label, the
|
||||
// route pattern. Before the fix this produced N of them.
|
||||
func TestMetrics_DistinctReceiverPathsMintOneLabelSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -250,7 +250,7 @@ func TestMetrics_DistinctReceiverPathsMintOneLabelSet(t *testing.T) {
|
||||
// The scrape must not republish the UUIDs it was driven with.
|
||||
// They are the receiver's only credential.
|
||||
for _, p := range paths {
|
||||
id := strings.TrimPrefix(p, "/webhook/")
|
||||
id := strings.TrimPrefix(p, "/h/")
|
||||
for label := range labels {
|
||||
assert.NotContains(
|
||||
t, label, id,
|
||||
@@ -354,7 +354,7 @@ func TestMetrics_UnmatchedPathsCollapseToTheSentinel(t *testing.T) {
|
||||
if i%2 == 0 {
|
||||
paths = append(paths, "/"+id)
|
||||
} else {
|
||||
paths = append(paths, "/webhook/"+id+"/"+id)
|
||||
paths = append(paths, "/h/"+id+"/"+id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,3 +455,29 @@ func TestMetrics_StatusAndSizeStillRecorded(t *testing.T) {
|
||||
"the interceptor must still count written bytes",
|
||||
)
|
||||
}
|
||||
|
||||
// TestMetrics_WorksOnMiddlewaretestNew pins that a Middleware built
|
||||
// by middlewaretest.New has a recorder of its own: its Metrics()
|
||||
// serves a request instead of panicking, and a second one does not
|
||||
// collide with the first.
|
||||
func TestMetrics_WorksOnMiddlewaretestNew(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
log := slog.New(slog.DiscardHandler)
|
||||
cfg := &config.Config{Environment: "prod"}
|
||||
ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(okBody))
|
||||
})
|
||||
|
||||
for range 2 {
|
||||
h := middlewaretest.New(t, log, cfg, nil).Metrics()(ok)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, okRoute, nil,
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,21 +3,26 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
basicauth "github.com/99designs/basicauth-go"
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
httpmetrics "github.com/slok/go-http-metrics/metrics"
|
||||
prommetrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
"sneak.berlin/go/webhooker/internal/logfield"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
"sneak.berlin/go/webhooker/internal/referer"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
)
|
||||
|
||||
@@ -65,18 +70,19 @@ const (
|
||||
// url, useragent, referer 3*(512+11) = 1569
|
||||
// request_id 128+11 = 139
|
||||
// method 32+11 = 43
|
||||
// fixed portion = 336
|
||||
// fixed portion = 405
|
||||
// ----
|
||||
// 2087
|
||||
// 2156
|
||||
//
|
||||
// The 512 is logfield.MaxBytes; the 11 is the truncation marker,
|
||||
// charged on top of each budget rather than inside it.
|
||||
//
|
||||
// The fixed portion is the JSON punctuation, the field names, the
|
||||
// level and the message, both timestamps at their longest, an IPv6
|
||||
// remoteIP with a zone, a three-digit status and a full-width int64
|
||||
// latency. Stated at 2560 so the figure carries headroom rather
|
||||
// than sitting on the arithmetic.
|
||||
// level and the message, both timestamps at their longest, remoteIP
|
||||
// and clientIP each charged as an IPv6 address with a zone, a
|
||||
// three-digit status and a full-width int64 latency. Stated at 2560
|
||||
// so the figure carries headroom rather than sitting on the
|
||||
// arithmetic.
|
||||
//
|
||||
// The tty text handler in internal/logger is covered by the same
|
||||
// figure. logfield.EncodedBytes charges every rune at least what
|
||||
@@ -84,20 +90,20 @@ const (
|
||||
// bytes strconv.Quote spends on a non-printable rune at or above
|
||||
// U+10000, which is four more than the JSON handler ever spends —
|
||||
// so each budget bounds the encoded field under either handler.
|
||||
// The text handler's fixed portion is 286, the smaller of the two,
|
||||
// which puts its worst case at 2037.
|
||||
// The text handler's fixed portion is 351, the smaller of the two,
|
||||
// which puts its worst case at 2102.
|
||||
//
|
||||
// It is also the ceiling on every OTHER line this service writes
|
||||
// THROUGH SLOG that carries text an UNAUTHENTICATED client
|
||||
// supplies. Those lines — the MaxBodySize rejection, the CSRF
|
||||
// rejection, the rate-limit rejection, the unauthenticated-request
|
||||
// and unknown-entrypoint DEBUG lines, the failed-login DEBUG
|
||||
// lines, and the two login-throttle WARN lines ("login failure
|
||||
// limit exceeded" in loginguard.go and "password verification
|
||||
// capacity exhausted" in internal/handlers/auth.go) — spend the
|
||||
// same per-field budgets, and each carries strictly fewer
|
||||
// client-supplied fields than the access log does,
|
||||
// so none of them can reach a width the access log cannot. That is
|
||||
// rejection, the unauthenticated-request DEBUG line, the
|
||||
// failed-login DEBUG lines, and the two login-throttle WARN
|
||||
// lines ("login failure limit exceeded" in loginguard.go and
|
||||
// "password verification capacity exhausted" in
|
||||
// internal/handlers/auth.go) — spend the same per-field budgets,
|
||||
// and each carries strictly fewer client-supplied fields than the
|
||||
// access log does, so none of them can reach a width the access
|
||||
// log cannot. That is
|
||||
// asserted directly, per line and under both handlers, rather than
|
||||
// left to the reasoning: see logbound_test.go in this package and
|
||||
// in internal/handlers.
|
||||
@@ -109,24 +115,24 @@ const (
|
||||
// a caller on a parameterised route would supply, which is the
|
||||
// only way those caps can be pinned at all.
|
||||
//
|
||||
// One writer in that set is not a handler's own slog call. The
|
||||
// GORM adapter in internal/gormlog logs the statement with the
|
||||
// client-chosen parameter already interpolated into it, which on
|
||||
// the receiver and login lookups is exactly the value the budgets
|
||||
// above exist for. Its widest line spends two logfield.MaxBytes
|
||||
// budgets — the statement and the driver error — against a fixed
|
||||
// portion smaller than this one's, and
|
||||
// internal/gormlog/gormlog_test.go asserts every line it emits
|
||||
// against this constant directly rather than leaving it as
|
||||
// arithmetic.
|
||||
// The GORM adapter in internal/gormlog writes through slog as well.
|
||||
// It logs each statement with its placeholders rather than the
|
||||
// values bound to them (see its ParamsFilter), so the key a client
|
||||
// sends to the receiver or login lookup does not reach its lines.
|
||||
// Its widest line spends two logfield.MaxBytes budgets — the
|
||||
// statement and the driver error — against a fixed portion smaller
|
||||
// than this one's, and internal/gormlog/gormlog_test.go asserts
|
||||
// every line it emits against this constant directly rather than
|
||||
// leaving it as arithmetic.
|
||||
//
|
||||
// What it does NOT cover, so that the figure above is not read as
|
||||
// more than it is:
|
||||
//
|
||||
// - Lines carrying an AUTHENTICATED operator's own input, which
|
||||
// are not truncated at all: the webhook name on "webhook
|
||||
// created" and the target host on "target URL blocked by SSRF
|
||||
// protection" (both internal/handlers/source_management.go),
|
||||
// created" (internal/handlers/webhook_create.go) and the target
|
||||
// host on "target URL blocked by SSRF protection"
|
||||
// (internal/handlers/target_create.go),
|
||||
// and target_name in internal/delivery/engine.go and
|
||||
// target_http.go. Each is bounded only by the 1 MB form body
|
||||
// cap, so a 100 KB name writes one line of roughly 600 KB.
|
||||
@@ -144,14 +150,21 @@ const (
|
||||
MaxAccessLogLineBytes = 2560
|
||||
)
|
||||
|
||||
// ReceiverRoute is the route pattern the webhook receiver is served
|
||||
// at. Its path carries the entrypoint UUID, which is the receiver's
|
||||
// only credential, so the log lines this package writes name a
|
||||
// request on this route by the pattern, never by its path.
|
||||
const ReceiverRoute = "/h/{uuid}"
|
||||
|
||||
//nolint:revive // MiddlewareParams is a standard fx naming convention.
|
||||
type MiddlewareParams struct {
|
||||
fx.In
|
||||
|
||||
Logger *logger.Logger
|
||||
Globals *globals.Globals
|
||||
Config *config.Config
|
||||
Session *session.Session
|
||||
Logger *slog.Logger
|
||||
Globals *globals.Globals
|
||||
Config *config.Config
|
||||
Session *session.Session
|
||||
Registry *prometheus.Registry
|
||||
}
|
||||
|
||||
// Middleware provides HTTP middleware for logging, CORS, auth, and
|
||||
@@ -161,6 +174,12 @@ type Middleware struct {
|
||||
params *MiddlewareParams
|
||||
session *session.Session
|
||||
|
||||
// metricsRecorder records the inbound HTTP metrics on
|
||||
// params.Registry. It is built once per Middleware and Metrics
|
||||
// reuses it, because building it registers its collectors, and a
|
||||
// second registration on the same registry panics.
|
||||
metricsRecorder httpmetrics.Recorder
|
||||
|
||||
// loginGuard counts failed credential verifications and bounds
|
||||
// concurrent password hashing. It is built on first use so that
|
||||
// every construction path gets one; see guard().
|
||||
@@ -177,8 +196,11 @@ func New(
|
||||
) (*Middleware, error) {
|
||||
s := new(Middleware)
|
||||
s.params = ¶ms
|
||||
s.log = params.Logger.Get()
|
||||
s.log = params.Logger
|
||||
s.session = params.Session
|
||||
s.metricsRecorder = prommetrics.NewRecorder(
|
||||
prommetrics.Config{Registry: params.Registry},
|
||||
)
|
||||
|
||||
return s, nil
|
||||
}
|
||||
@@ -199,6 +221,28 @@ func ipFromHostPort(hp string) string {
|
||||
return h
|
||||
}
|
||||
|
||||
// RemoteIP returns the address of the connecting peer, without its
|
||||
// port. Behind a reverse proxy it is the proxy. Every log line that
|
||||
// names the client logs it as remoteIP, next to clientIP.
|
||||
func RemoteIP(r *http.Request) string {
|
||||
return ipFromHostPort(r.RemoteAddr)
|
||||
}
|
||||
|
||||
// clientIPKey is the request context key under which Logging stores
|
||||
// the value ClientIP returns.
|
||||
type clientIPKey struct{}
|
||||
|
||||
// ClientIP returns the address the request is attributed to, which
|
||||
// Logging works out once per request with clientAddr in ratelimit.go
|
||||
// and logs as clientIP. The other lines that name the client read it
|
||||
// from here, so all of them agree. It is empty for a request Logging
|
||||
// has not seen.
|
||||
func ClientIP(r *http.Request) string {
|
||||
ip, _ := r.Context().Value(clientIPKey{}).(string)
|
||||
|
||||
return ip
|
||||
}
|
||||
|
||||
type loggingResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
|
||||
@@ -217,26 +261,35 @@ func (lrw *loggingResponseWriter) WriteHeader(code int) {
|
||||
lrw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// Unwrap lets http.ResponseController reach the writer underneath, so
|
||||
// a handler can still flush or set a write deadline through the access
|
||||
// log.
|
||||
func (lrw *loggingResponseWriter) Unwrap() http.ResponseWriter {
|
||||
return lrw.ResponseWriter
|
||||
}
|
||||
|
||||
// concreteLogURL renders the request's own URL for the access log
|
||||
// branches that keep it, with the query string replaced by a fixed
|
||||
// marker.
|
||||
//
|
||||
// The path on those branches is bounded by the service's routes or by
|
||||
// the operator's data — a 2xx on the receiver means the UUID named a
|
||||
// stored entrypoint, a 2xx under /s means the file is in the embedded
|
||||
// tree. The query is not bounded by anything: /.well-known/healthcheck
|
||||
// and /s/* take no authentication and sit behind no rate limiter, and
|
||||
// /pages/login behind only the login limiter, so any of them will
|
||||
// answer 200 to a URL carrying an arbitrary number of arbitrary bytes
|
||||
// after the '?'. Keeping the path and dropping the query is what makes
|
||||
// this branch as bounded as the pattern branches below.
|
||||
// the operator's data — a 2xx under /s means the file is in the
|
||||
// embedded tree. The query is not bounded by anything:
|
||||
// /.well-known/healthcheck and /s/* take no authentication and sit
|
||||
// behind no rate limiter, and /pages/login behind only the login
|
||||
// limiter, so any of them will answer 200 to a URL carrying an
|
||||
// arbitrary number of arbitrary bytes after the '?'. Keeping the path
|
||||
// and dropping the query is what makes this branch as bounded as the
|
||||
// pattern branches below.
|
||||
//
|
||||
// Nothing debuggable is lost. One route in the service reads a query
|
||||
// parameter at all — `page`, on the authenticated pagination links in
|
||||
// internal/handlers/source_management.go — and the alternatives that
|
||||
// would preserve more (a key count, a key allowlist) all require
|
||||
// parsing an attacker-sized query on every request, which is work an
|
||||
// unauthenticated client would then be choosing for us.
|
||||
// Nothing debuggable is lost. The only query parameters the service
|
||||
// reads are the login page's `next`, the page to return to,
|
||||
// `notice`, which names the line a page shows after an action, and
|
||||
// the event log's `show`, which picks the events it lists. The
|
||||
// alternatives that would preserve more (a key count, a key
|
||||
// allowlist) all require parsing an attacker-sized query on every
|
||||
// request, which is work an unauthenticated client would then be
|
||||
// choosing for us.
|
||||
func concreteLogURL(r *http.Request) string {
|
||||
path := r.URL.EscapedPath()
|
||||
|
||||
@@ -249,36 +302,40 @@ func concreteLogURL(r *http.Request) string {
|
||||
|
||||
// accessLogURL returns the value for the access log's url field.
|
||||
//
|
||||
// 2xx and 5xx responses get the concrete path (see concreteLogURL). A
|
||||
// success resolved against a static route or against the operator's
|
||||
// own data — on the receiver, a 2xx means the UUID named a stored
|
||||
// entrypoint — and a server error is our own bug, where the exact URL
|
||||
// is the primary evidence and which no client can provoke at will.
|
||||
// On ReceiverRoute it is the route pattern whatever the status, so
|
||||
// the entrypoint UUID in the path is never logged. A request chi never
|
||||
// routed gets unmatchedRoute whatever the status, for the same reason:
|
||||
// in dev, CORS answers a preflight to the receiver before routing.
|
||||
//
|
||||
// On every other route, 2xx and 5xx responses get the concrete path
|
||||
// (see concreteLogURL). A success resolved against a static route or
|
||||
// against the operator's own data, and a server error is our own bug,
|
||||
// where the exact URL is the primary evidence and which no client can
|
||||
// provoke at will.
|
||||
//
|
||||
// 3xx and 4xx responses get the chi route pattern instead. Those are
|
||||
// the outcomes an unauthenticated client drives for free: 404 or 429
|
||||
// on any invented /webhook/ path, 303 to the login page on any
|
||||
// invented /user/ path. Logging the concrete URL there lets a flood
|
||||
// write attacker-chosen text, of attacker-chosen length, into the
|
||||
// operator's log at one line per request. The pattern comes from the
|
||||
// router's own table, so it is bounded by the service's routes while
|
||||
// still naming which class of request was rejected.
|
||||
// the outcomes an unauthenticated client drives for free, such as a
|
||||
// 303 to the login page on any invented /user/ path. Logging the
|
||||
// concrete URL there lets a flood write attacker-chosen text, of
|
||||
// attacker-chosen length, into the operator's log at one line per
|
||||
// request. The pattern comes from the router's own table, so it is
|
||||
// bounded by the service's routes while still naming which class of
|
||||
// request was rejected.
|
||||
//
|
||||
// The pattern is only populated once routing has run, so this must be
|
||||
// called after the handler returns, not before.
|
||||
func accessLogURL(r *http.Request, status int) string {
|
||||
pattern := routePatternID(r.Context())
|
||||
if pattern == ReceiverRoute || pattern == unmatchedRoute {
|
||||
return pattern
|
||||
}
|
||||
|
||||
if status < http.StatusMultipleChoices ||
|
||||
status >= http.StatusInternalServerError {
|
||||
return concreteLogURL(r)
|
||||
}
|
||||
|
||||
if rc := chi.RouteContext(r.Context()); rc != nil {
|
||||
if pattern := rc.RoutePattern(); pattern != "" {
|
||||
return pattern
|
||||
}
|
||||
}
|
||||
|
||||
return unmatchedRoute
|
||||
return pattern
|
||||
}
|
||||
|
||||
// Logging returns middleware that logs each HTTP request with
|
||||
@@ -293,6 +350,13 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
|
||||
lrw := newLoggingResponseWriter(w)
|
||||
ctx := r.Context()
|
||||
|
||||
// When RemoteAddr is not an address, the peer's own
|
||||
// text is all the request can be attributed to.
|
||||
clientIP := RemoteIP(r)
|
||||
if addr, ok := s.clientAddr(r); ok {
|
||||
clientIP = addr.String()
|
||||
}
|
||||
|
||||
defer func() {
|
||||
latency := time.Since(start)
|
||||
requestID := ""
|
||||
@@ -324,16 +388,20 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
|
||||
requestID, maxLogRequestIDBytes,
|
||||
),
|
||||
"referer", logfield.Truncate(
|
||||
r.Referer(), logfield.MaxBytes,
|
||||
referer.SchemeAndHost(r.Referer()),
|
||||
logfield.MaxBytes,
|
||||
),
|
||||
"proto", r.Proto,
|
||||
"remoteIP", ipFromHostPort(r.RemoteAddr),
|
||||
"remoteIP", RemoteIP(r),
|
||||
"clientIP", clientIP,
|
||||
"status", lrw.statusCode,
|
||||
"latency_ms", latency.Milliseconds(),
|
||||
)
|
||||
}()
|
||||
|
||||
next.ServeHTTP(lrw, r)
|
||||
next.ServeHTTP(lrw, r.WithContext(
|
||||
context.WithValue(ctx, clientIPKey{}, clientIP),
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -366,6 +434,30 @@ func (s *Middleware) CORS() func(http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// NextParam is the query parameter on the login redirect, and the
|
||||
// login form field, that holds the page to return to after login.
|
||||
const NextParam = "next"
|
||||
|
||||
// MaxNextBytes bounds the NextParam value. The login page writes it
|
||||
// into its form, and every page is rendered into a buffer first, so
|
||||
// without a bound a request would choose the size of that buffer.
|
||||
const MaxNextBytes = 2048
|
||||
|
||||
// loginURL is the login page RequireAuth redirects to. A GET carries
|
||||
// its own path and query in NextParam so that logging in returns to
|
||||
// it, unless they are longer than MaxNextBytes; loginDestination in
|
||||
// the handlers package checks whether that value is safe to follow.
|
||||
// Other methods carry nothing, since a redirect cannot repeat them.
|
||||
func loginURL(r *http.Request) string {
|
||||
next := r.URL.RequestURI()
|
||||
|
||||
if r.Method != http.MethodGet || len(next) > MaxNextBytes {
|
||||
return "/pages/login"
|
||||
}
|
||||
|
||||
return "/pages/login?" + url.Values{NextParam: {next}}.Encode()
|
||||
}
|
||||
|
||||
// RequireAuth returns middleware that checks for a valid session.
|
||||
// Unauthenticated users are redirected to the login page.
|
||||
func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
|
||||
@@ -381,7 +473,7 @@ func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
|
||||
"error", err,
|
||||
)
|
||||
http.Redirect(
|
||||
w, r, "/pages/login", http.StatusSeeOther,
|
||||
w, r, loginURL(r), http.StatusSeeOther,
|
||||
)
|
||||
|
||||
return
|
||||
@@ -409,7 +501,7 @@ func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
|
||||
),
|
||||
)
|
||||
http.Redirect(
|
||||
w, r, "/pages/login", http.StatusSeeOther,
|
||||
w, r, loginURL(r), http.StatusSeeOther,
|
||||
)
|
||||
|
||||
return
|
||||
@@ -454,18 +546,25 @@ func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
// SecurityHeaders returns middleware that sets production security
|
||||
// headers on every response: HSTS, X-Content-Type-Options,
|
||||
// X-Frame-Options, CSP, Referrer-Policy, and Permissions-Policy.
|
||||
// headers on every response: HSTS (unless HSTS_MAX_AGE is zero),
|
||||
// X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy, and
|
||||
// Permissions-Policy. The CSP takes scripts and styles only from
|
||||
// files on this origin, so a template may hold no inline script,
|
||||
// event handler or style attribute.
|
||||
func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
|
||||
maxAge := s.params.Config.HSTSMaxAge
|
||||
hsts := "max-age=" + strconv.FormatInt(int64(maxAge/time.Second), 10) +
|
||||
"; includeSubDomains; preload"
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
) {
|
||||
w.Header().Set(
|
||||
"Strict-Transport-Security",
|
||||
"max-age=63072000; includeSubDomains; preload",
|
||||
)
|
||||
if maxAge > 0 {
|
||||
w.Header().Set("Strict-Transport-Security", hsts)
|
||||
}
|
||||
|
||||
w.Header().Set(
|
||||
"X-Content-Type-Options", "nosniff",
|
||||
)
|
||||
@@ -473,8 +572,8 @@ func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
|
||||
w.Header().Set(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; "+
|
||||
"script-src 'self' 'unsafe-inline'; "+
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"script-src 'self'; "+
|
||||
"style-src 'self'",
|
||||
)
|
||||
w.Header().Set(
|
||||
"Referrer-Policy",
|
||||
@@ -519,7 +618,10 @@ func bodyLimitedMethod(method string) bool {
|
||||
}
|
||||
|
||||
// MaxBodySize returns middleware that limits the size of
|
||||
// POST/PUT/PATCH request bodies to maxBytes. It must be registered
|
||||
// POST/PUT/PATCH request bodies to maxBytes. A request with any other
|
||||
// method passes through uncapped, deliberately: no route behind it
|
||||
// reads a body on GET, HEAD or DELETE. A handler that starts to needs
|
||||
// its method added to bodyLimitedMethod first. It must be registered
|
||||
// before any middleware that parses the body — notably CSRF, which
|
||||
// calls r.PostFormValue — so that form parsing happens under this
|
||||
// cap rather than net/http's 10 MB default.
|
||||
@@ -560,7 +662,7 @@ func (s *Middleware) MaxBodySize(
|
||||
// internal/server/routes.go), so an
|
||||
// unauthenticated client reaches it with a path
|
||||
// of its own choosing and its own length —
|
||||
// POST /source/<8 KB>/edit with an oversize
|
||||
// POST /hook/<8 KB>/edit with an oversize
|
||||
// declared Content-Length costs nothing to
|
||||
// send. At WARN, on by default, that is a
|
||||
// write into the operator's log sized by the
|
||||
|
||||
@@ -12,39 +12,36 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/fx/fxtest"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/middleware"
|
||||
"sneak.berlin/go/webhooker/internal/middleware/middlewaretest"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
)
|
||||
|
||||
const testKeySize = 32
|
||||
|
||||
// testMiddleware creates a Middleware with minimal dependencies
|
||||
// for testing. It uses a real session.Session backed by an
|
||||
// in-memory cookie store.
|
||||
// for testing. It uses a real session.Session.
|
||||
func testMiddleware(
|
||||
t *testing.T,
|
||||
env string,
|
||||
) (*middleware.Middleware, *session.Session) {
|
||||
t.Helper()
|
||||
|
||||
m, s, _ := testMiddlewareWithSessionClock(t, env, 0, nil)
|
||||
|
||||
return m, s
|
||||
return testMiddlewareWithIdleTimeout(t, env, 0)
|
||||
}
|
||||
|
||||
// testMiddlewareWithSessionClock is testMiddleware with a
|
||||
// configurable session idle timeout and a manually advanced clock,
|
||||
// for the session-expiry tests. A nil clock uses the real one.
|
||||
func testMiddlewareWithSessionClock(
|
||||
// testMiddlewareWithIdleTimeout is testMiddleware with a
|
||||
// configurable session idle timeout, for the session-expiry tests.
|
||||
func testMiddlewareWithIdleTimeout(
|
||||
t *testing.T,
|
||||
env string,
|
||||
idleTimeout time.Duration,
|
||||
clock *fakeClock,
|
||||
) (*middleware.Middleware, *session.Session, *fakeClock) {
|
||||
) (*middleware.Middleware, *session.Session) {
|
||||
t.Helper()
|
||||
|
||||
log := slog.New(slog.NewTextHandler(
|
||||
@@ -57,66 +54,44 @@ func testMiddlewareWithSessionClock(
|
||||
SessionIdleTimeout: idleTimeout,
|
||||
}
|
||||
|
||||
sessManager := newTestSessionManager(cfg, log, clock)
|
||||
sessManager := newTestSessionManager(t, cfg)
|
||||
|
||||
m := middleware.NewForTest(log, cfg, sessManager)
|
||||
m := middlewaretest.New(t, log, cfg, sessManager)
|
||||
|
||||
return m, sessManager, clock
|
||||
return m, sessManager
|
||||
}
|
||||
|
||||
// newTestSessionManager builds the real session.Session the
|
||||
// middleware tests run against: an in-memory cookie store with a
|
||||
// known key, and optionally a manually advanced clock.
|
||||
// middleware tests run against, through session.New, with its key
|
||||
// in a main database of its own.
|
||||
func newTestSessionManager(
|
||||
t *testing.T,
|
||||
cfg *config.Config,
|
||||
log *slog.Logger,
|
||||
clock *fakeClock,
|
||||
) *session.Session {
|
||||
key := make([]byte, testKeySize)
|
||||
t.Helper()
|
||||
|
||||
for i := range key {
|
||||
key[i] = byte(i)
|
||||
}
|
||||
discard := slog.New(slog.DiscardHandler)
|
||||
|
||||
store := sessions.NewCookieStore(key)
|
||||
store.Options = &sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: 86400 * 7,
|
||||
HttpOnly: true,
|
||||
Secure: false,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
db, err := database.Open(t.TempDir(), discard)
|
||||
require.NoError(t, err)
|
||||
|
||||
var now func() time.Time
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
if clock != nil {
|
||||
now = clock.Now
|
||||
}
|
||||
lc := fxtest.NewLifecycle(t)
|
||||
|
||||
return session.NewForTest(store, cfg, log, key, now)
|
||||
}
|
||||
sessManager, err := session.New(lc, session.Params{
|
||||
Config: cfg,
|
||||
Database: db,
|
||||
Logger: discard,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// fakeClock is a manually advanced clock, so session expiry can be
|
||||
// tested without sleeping.
|
||||
type fakeClock struct {
|
||||
t time.Time
|
||||
}
|
||||
// The start hook reads the key from db and builds the cookie
|
||||
// store.
|
||||
lc.RequireStart()
|
||||
t.Cleanup(lc.RequireStop)
|
||||
|
||||
func (c *fakeClock) Now() time.Time {
|
||||
return c.t
|
||||
}
|
||||
|
||||
func (c *fakeClock) Advance(d time.Duration) {
|
||||
c.t = c.t.Add(d)
|
||||
}
|
||||
|
||||
// newFakeClock returns a clock started at a fixed instant.
|
||||
func newFakeClock() *fakeClock {
|
||||
return &fakeClock{
|
||||
t: time.Date(
|
||||
2026, time.January, 2, 3, 4, 5, 0, time.UTC,
|
||||
),
|
||||
}
|
||||
return sessManager
|
||||
}
|
||||
|
||||
// --- Logging Middleware Tests ---
|
||||
@@ -338,6 +313,76 @@ func TestRequireAuth_NoSession_RedirectsToLogin(t *testing.T) {
|
||||
"unauthenticated request",
|
||||
)
|
||||
assert.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t, "/pages/login?next=%2Fdashboard", w.Header().Get("Location"),
|
||||
)
|
||||
}
|
||||
|
||||
// TestRequireAuth_LoginRedirectCarriesOnlyAGet pins what the login
|
||||
// redirect carries: a GET's path and query, so logging in can return
|
||||
// there, and nothing for a POST, which a redirect cannot repeat.
|
||||
func TestRequireAuth_LoginRedirectCarriesOnlyAGet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
||||
|
||||
handler := m.RequireAuth()(http.HandlerFunc(
|
||||
func(_ http.ResponseWriter, _ *http.Request) {},
|
||||
))
|
||||
|
||||
get := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodGet, "/hook/abc/events?page=2", nil,
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, get)
|
||||
|
||||
assert.Equal(
|
||||
t, "/pages/login?next=%2Fhook%2Fabc%2Fevents%3Fpage%3D2",
|
||||
w.Header().Get("Location"),
|
||||
)
|
||||
|
||||
post := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodPost, "/hook/abc/delete", nil,
|
||||
)
|
||||
w = httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, post)
|
||||
|
||||
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
||||
}
|
||||
|
||||
// TestRequireAuth_LoginRedirectLeavesOutALongURL: a GET whose path
|
||||
// and query are longer than the login page accepts goes to the plain
|
||||
// login page, so a long URL does not make the redirect long.
|
||||
func TestRequireAuth_LoginRedirectLeavesOutALongURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
||||
|
||||
handler := m.RequireAuth()(http.HandlerFunc(
|
||||
func(_ http.ResponseWriter, _ *http.Request) {},
|
||||
))
|
||||
|
||||
atLimit := "/" + strings.Repeat("a", middleware.MaxNextBytes-1)
|
||||
|
||||
get := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, atLimit, nil,
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, get)
|
||||
|
||||
assert.Equal(
|
||||
t, "/pages/login?next=%2F"+atLimit[1:],
|
||||
w.Header().Get("Location"),
|
||||
)
|
||||
|
||||
get = httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, atLimit+"a", nil,
|
||||
)
|
||||
w = httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, get)
|
||||
|
||||
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
||||
}
|
||||
|
||||
@@ -443,7 +488,9 @@ func TestRequireAuth_UnauthenticatedSession_RedirectsToLogin(
|
||||
"unauthenticated session",
|
||||
)
|
||||
assert.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
||||
assert.Equal(
|
||||
t, "/pages/login?next=%2Fdashboard", w.Header().Get("Location"),
|
||||
)
|
||||
}
|
||||
|
||||
// --- RequireAuth Session Expiry Tests ---
|
||||
@@ -519,6 +566,40 @@ func sessionCookies(
|
||||
return out
|
||||
}
|
||||
|
||||
// aged re-issues the session cookie in cookies with both of its
|
||||
// timestamps moved back by d: the cookie as it stands once d has
|
||||
// passed, so session expiry can be tested without sleeping.
|
||||
func aged(
|
||||
t *testing.T,
|
||||
sessManager *session.Session,
|
||||
cookies []*http.Cookie,
|
||||
d time.Duration,
|
||||
) []*http.Cookie {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
sess, err := sessManager.Get(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, key := range []string{session.CreatedAtKey, session.LastSeenKey} {
|
||||
at, ok := sess.Values[key].(int64)
|
||||
require.True(t, ok, "the session has no %s", key)
|
||||
|
||||
sess.Values[key] = at - int64(d/time.Second)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
require.NoError(t, sessManager.Save(req, w, sess))
|
||||
|
||||
return sessionCookies(w)
|
||||
}
|
||||
|
||||
func TestRequireAuth_IdleExpiredSession_RedirectsToLogin(
|
||||
t *testing.T,
|
||||
) {
|
||||
@@ -526,13 +607,11 @@ func TestRequireAuth_IdleExpiredSession_RedirectsToLogin(
|
||||
|
||||
idle := time.Hour
|
||||
|
||||
m, sessManager, clock := testMiddlewareWithSessionClock(
|
||||
t, config.EnvironmentDev, idle, newFakeClock(),
|
||||
m, sessManager := testMiddlewareWithIdleTimeout(
|
||||
t, config.EnvironmentDev, idle,
|
||||
)
|
||||
|
||||
cookies := loginCookies(t, sessManager)
|
||||
|
||||
clock.Advance(idle)
|
||||
cookies := aged(t, sessManager, loginCookies(t, sessManager), idle)
|
||||
|
||||
called, w := runAuthed(t, m, cookies)
|
||||
|
||||
@@ -541,7 +620,9 @@ func TestRequireAuth_IdleExpiredSession_RedirectsToLogin(
|
||||
"handler should not run for an idle-expired session",
|
||||
)
|
||||
assert.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
||||
assert.Equal(
|
||||
t, "/pages/login?next=%2Fdashboard", w.Header().Get("Location"),
|
||||
)
|
||||
assert.Empty(
|
||||
t, sessionCookies(w),
|
||||
"an expired session must not be refreshed",
|
||||
@@ -555,14 +636,12 @@ func TestRequireAuth_RefreshesIdleDeadlineOnActivity(
|
||||
|
||||
idle := time.Hour
|
||||
|
||||
m, sessManager, clock := testMiddlewareWithSessionClock(
|
||||
t, config.EnvironmentDev, idle, newFakeClock(),
|
||||
m, sessManager := testMiddlewareWithIdleTimeout(
|
||||
t, config.EnvironmentDev, idle,
|
||||
)
|
||||
|
||||
cookies := loginCookies(t, sessManager)
|
||||
|
||||
// Activity halfway through the idle window.
|
||||
clock.Advance(idle / 2)
|
||||
cookies := aged(t, sessManager, loginCookies(t, sessManager), idle/2)
|
||||
|
||||
called, w := runAuthed(t, m, cookies)
|
||||
require.True(t, called, "handler should run while valid")
|
||||
@@ -574,16 +653,22 @@ func TestRequireAuth_RefreshesIdleDeadlineOnActivity(
|
||||
)
|
||||
|
||||
// Past the original deadline. The refreshed cookie is still
|
||||
// good; the original one is not.
|
||||
clock.Advance(idle - time.Second)
|
||||
// good; the original one is not. A minute short of the idle
|
||||
// window leaves room for the real clock, which the session
|
||||
// reads, to tick on while the test runs.
|
||||
later := idle - time.Minute
|
||||
|
||||
calledRefreshed, _ := runAuthed(t, m, refreshed)
|
||||
calledRefreshed, _ := runAuthed(
|
||||
t, m, aged(t, sessManager, refreshed, later),
|
||||
)
|
||||
assert.True(
|
||||
t, calledRefreshed,
|
||||
"refreshed session should outlive the original deadline",
|
||||
)
|
||||
|
||||
calledStale, staleW := runAuthed(t, m, cookies)
|
||||
calledStale, staleW := runAuthed(
|
||||
t, m, aged(t, sessManager, cookies, later),
|
||||
)
|
||||
assert.False(
|
||||
t, calledStale,
|
||||
"the pre-refresh cookie carries the old idle deadline",
|
||||
@@ -596,8 +681,8 @@ func TestRequireAuth_UnauthenticatedRequestDoesNotRefresh(
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
m, sessManager, _ := testMiddlewareWithSessionClock(
|
||||
t, config.EnvironmentDev, time.Hour, newFakeClock(),
|
||||
m, sessManager := testMiddlewareWithIdleTimeout(
|
||||
t, config.EnvironmentDev, time.Hour,
|
||||
)
|
||||
|
||||
// A session cookie that exists but was never authenticated.
|
||||
@@ -640,7 +725,7 @@ func TestNoCache_SetsHeaders(t *testing.T) {
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodGet, "/sources", nil,
|
||||
http.MethodGet, "/hooks", nil,
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -660,14 +745,93 @@ func TestNoCache_SetsHeaders(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
// --- SecurityHeaders Middleware Tests ---
|
||||
|
||||
// securityHeaders serves one request through SecurityHeaders built
|
||||
// over a Config with the given HSTSMaxAge, and returns the response's
|
||||
// headers.
|
||||
func securityHeaders(t *testing.T, maxAge time.Duration) http.Header {
|
||||
t.Helper()
|
||||
|
||||
cfg := &config.Config{HSTSMaxAge: maxAge}
|
||||
m := middlewaretest.New(
|
||||
t, slog.New(slog.DiscardHandler), cfg,
|
||||
newTestSessionManager(t, cfg),
|
||||
)
|
||||
|
||||
handler := m.SecurityHeaders()(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
))
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil,
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
return w.Header()
|
||||
}
|
||||
|
||||
func TestSecurityHeaders_HSTS(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
maxAge time.Duration
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "default of ten years",
|
||||
maxAge: 87600 * time.Hour,
|
||||
expected: []string{"max-age=315360000; includeSubDomains; preload"},
|
||||
},
|
||||
{
|
||||
name: "custom value is sent in seconds",
|
||||
maxAge: 90 * time.Minute,
|
||||
expected: []string{"max-age=5400; includeSubDomains; preload"},
|
||||
},
|
||||
{
|
||||
name: "zero sends no header",
|
||||
maxAge: 0,
|
||||
expected: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(
|
||||
t, tt.expected,
|
||||
securityHeaders(t, tt.maxAge).Values(
|
||||
"Strict-Transport-Security",
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecurityHeaders_CSP pins the Content-Security-Policy: a page
|
||||
// takes its scripts and styles only from files on its own origin,
|
||||
// never from an inline script, event handler or style.
|
||||
func TestSecurityHeaders_CSP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(
|
||||
t, "default-src 'self'; script-src 'self'; style-src 'self'",
|
||||
securityHeaders(t, 0).Get("Content-Security-Policy"),
|
||||
)
|
||||
}
|
||||
|
||||
// --- MaxBodySize Middleware Tests ---
|
||||
|
||||
const testBodyLimit int64 = 64
|
||||
|
||||
// maxBodySizeHandler wraps a sentinel handler in MaxBodySize with
|
||||
// testBodyLimit. The sentinel records whether it ran and how much of
|
||||
// the body it managed to read, so tests can distinguish "never
|
||||
// reached" from "reached but truncated".
|
||||
// maxBodySizeResult is what runMaxBodySize's sentinel handler saw,
|
||||
// together with the response.
|
||||
type maxBodySizeResult struct {
|
||||
called bool
|
||||
read int
|
||||
@@ -675,6 +839,10 @@ type maxBodySizeResult struct {
|
||||
response *httptest.ResponseRecorder
|
||||
}
|
||||
|
||||
// runMaxBodySize wraps a sentinel handler in MaxBodySize with
|
||||
// testBodyLimit and serves req through it. The sentinel records
|
||||
// whether it ran and how much of the body it managed to read, so
|
||||
// tests can distinguish "never reached" from "reached but truncated".
|
||||
func runMaxBodySize(
|
||||
t *testing.T,
|
||||
req *http.Request,
|
||||
@@ -856,13 +1024,9 @@ func metricsAuthMiddleware(
|
||||
MetricsPassword: "secret",
|
||||
}
|
||||
|
||||
key := make([]byte, testKeySize)
|
||||
store := sessions.NewCookieStore(key)
|
||||
store.Options = &sessions.Options{Path: "/", MaxAge: 86400}
|
||||
|
||||
sessManager := session.NewForTest(store, cfg, log, key, nil)
|
||||
|
||||
return middleware.NewForTest(log, cfg, sessManager)
|
||||
return middlewaretest.New(
|
||||
t, log, cfg, newTestSessionManager(t, cfg),
|
||||
)
|
||||
}
|
||||
|
||||
// runMetricsAuthRequest sends a GET /metrics request with the
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// Package middlewaretest builds a Middleware for tests in other
|
||||
// packages.
|
||||
package middlewaretest
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/fx/fxtest"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/middleware"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
)
|
||||
|
||||
// New builds a Middleware through middleware.New, on a
|
||||
// lifecycle that is never started.
|
||||
//
|
||||
// Its metrics recorder writes to a fresh registry of its own, so
|
||||
// Metrics() works on it and two of them never collide.
|
||||
func New(
|
||||
t *testing.T,
|
||||
log *slog.Logger,
|
||||
cfg *config.Config,
|
||||
sess *session.Session,
|
||||
) *middleware.Middleware {
|
||||
t.Helper()
|
||||
|
||||
m, err := middleware.New(
|
||||
fxtest.NewLifecycle(t),
|
||||
middleware.MiddlewareParams{
|
||||
Logger: log,
|
||||
Config: cfg,
|
||||
Session: sess,
|
||||
Registry: prometheus.NewRegistry(),
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
return m
|
||||
}
|
||||
@@ -63,7 +63,7 @@ const (
|
||||
|
||||
// receiverAggregateMultiplier scales the configured
|
||||
// per-entrypoint receiver limit into the aggregate limit one
|
||||
// client IP may spend across the whole /webhook/* route. Ten
|
||||
// client IP may spend across the whole /h/* route. Ten
|
||||
// entrypoints' worth lets a single sender address drive several
|
||||
// entrypoints at their full rate, while still capping what one
|
||||
// address costs the unauthenticated receiver.
|
||||
@@ -123,9 +123,8 @@ func bucketKey(addr netip.Addr) string {
|
||||
return prefix.String()
|
||||
}
|
||||
|
||||
// isTrustedProxy reports whether addr belongs to a network the
|
||||
// operator listed in TRUSTED_PROXIES. The list is empty by default,
|
||||
// so by default nothing is trusted.
|
||||
// isTrustedProxy reports whether addr belongs to a network in
|
||||
// TRUSTED_PROXIES, which by default is the RFC 1918 private ranges.
|
||||
func (m *Middleware) isTrustedProxy(addr netip.Addr) bool {
|
||||
for _, prefix := range m.params.Config.TrustedProxies {
|
||||
if prefix.Contains(addr) {
|
||||
@@ -203,66 +202,87 @@ func (m *Middleware) forwardedClientAddr(
|
||||
}
|
||||
|
||||
// rateLimitKey is the client identity every rate limiter in this
|
||||
// package buckets on. Forwarded headers are honoured only when the
|
||||
// direct peer (RemoteAddr) is inside the configured trusted-proxy
|
||||
// set; otherwise the peer address itself is the key. Without that
|
||||
// gate any client could mint a fresh bucket per request, or starve
|
||||
// another client's bucket, by picking an X-Forwarded-For value —
|
||||
// which makes every limit here decorative against a deliberate
|
||||
// attacker.
|
||||
//
|
||||
// The address that identifies the client is then reduced to a bucket
|
||||
// by bucketKey: full address for IPv4, /64 prefix for IPv6.
|
||||
// package buckets on: the address clientAddr attributes the request
|
||||
// to, reduced to a bucket by bucketKey — full address for IPv4, /64
|
||||
// prefix for IPv6.
|
||||
func (m *Middleware) rateLimitKey(r *http.Request) (string, error) {
|
||||
return m.clientKey(r), nil
|
||||
}
|
||||
|
||||
// clientKey computes the bucket key described on rateLimitKey.
|
||||
func (m *Middleware) clientKey(r *http.Request) string {
|
||||
peer, err := netip.ParseAddr(ipFromHostPort(r.RemoteAddr))
|
||||
if err != nil {
|
||||
addr, ok := m.clientAddr(r)
|
||||
if !ok {
|
||||
// Not an address we can reason about; key on the raw
|
||||
// value, the most specific identity left. Distinct
|
||||
// RemoteAddr values stay in distinct buckets, so this
|
||||
// path cannot silently collapse unrelated clients
|
||||
// together. On a Unix-socket listener every peer
|
||||
// carries the same RemoteAddr and so shares one bucket,
|
||||
// which is the fail-closed direction.
|
||||
// which is the fail-closed direction. An empty RemoteAddr
|
||||
// is a different case, which net/http never produces for
|
||||
// a TCP listener and only a hand-built request carries,
|
||||
// but it fails closed the same way: every such request
|
||||
// shares the one bucket keyed on the empty string.
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
return bucketKey(addr)
|
||||
}
|
||||
|
||||
// clientAddr is the address a request is attributed to. The rate
|
||||
// limiters key on it and the logs name it as clientIP.
|
||||
//
|
||||
// Forwarded headers are honoured only when the direct peer
|
||||
// (RemoteAddr) is inside the configured trusted-proxy set; otherwise
|
||||
// the peer address itself is the client. Without that gate any client
|
||||
// could mint a fresh bucket per request, or starve another client's
|
||||
// bucket, by picking an X-Forwarded-For value — which makes every
|
||||
// limit here decorative against a deliberate attacker.
|
||||
//
|
||||
// ok is false when RemoteAddr is not an address at all.
|
||||
func (m *Middleware) clientAddr(r *http.Request) (netip.Addr, bool) {
|
||||
peer, err := netip.ParseAddr(ipFromHostPort(r.RemoteAddr))
|
||||
if err != nil {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
|
||||
peer = normalizeAddr(peer)
|
||||
if !m.isTrustedProxy(peer) {
|
||||
return bucketKey(peer)
|
||||
return peer, true
|
||||
}
|
||||
|
||||
if addr, ok := m.forwardedClientAddr(r); ok {
|
||||
return bucketKey(addr)
|
||||
return addr, true
|
||||
}
|
||||
|
||||
return bucketKey(peer)
|
||||
return peer, true
|
||||
}
|
||||
|
||||
// tooManyRequests returns the 429 handler used by the
|
||||
// password-change and per-entrypoint receiver limiters: it logs the
|
||||
// tooManyRequests returns the 429 handler used by the postRateLimit
|
||||
// limiters and the per-entrypoint receiver limiter: it logs the
|
||||
// rejection with logMessage and answers with responseMessage.
|
||||
// httprate adds the Retry-After header (RFC 6585). The aggregate
|
||||
// receiver limiter uses floodTooManyRequests instead.
|
||||
//
|
||||
// The path is capped against the same budget as the access log's url
|
||||
// field. The per-entrypoint receiver limiter is unauthenticated and
|
||||
// its path is a client-chosen segment of client-chosen length, so at
|
||||
// WARN an uncapped path would let a sender pick the size of the line
|
||||
// it writes — the same defect the access log capping closed.
|
||||
// On ReceiverRoute the line names the route pattern, so the
|
||||
// entrypoint UUID in the path is never logged. Elsewhere it names the
|
||||
// path, capped against the same budget as the access log's url field;
|
||||
// the limiters there sit behind RequireAuth, so the cap is defensive.
|
||||
func (m *Middleware) tooManyRequests(
|
||||
logMessage, responseMessage string,
|
||||
) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
if routePatternID(r.Context()) == ReceiverRoute {
|
||||
path = ReceiverRoute
|
||||
}
|
||||
|
||||
m.log.Warn(
|
||||
logMessage,
|
||||
"path", logfield.Truncate(
|
||||
r.URL.Path, logfield.MaxBytes,
|
||||
),
|
||||
"path", logfield.Truncate(path, logfield.MaxBytes),
|
||||
"remoteIP", RemoteIP(r),
|
||||
"clientIP", ClientIP(r),
|
||||
)
|
||||
http.Error(w, responseMessage, http.StatusTooManyRequests)
|
||||
}
|
||||
@@ -287,8 +307,12 @@ func (m *Middleware) tooManyRequests(
|
||||
func (m *Middleware) floodTooManyRequests(
|
||||
logMessage, responseMessage string,
|
||||
) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
m.log.Debug(logMessage)
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
m.log.Debug(
|
||||
logMessage,
|
||||
"remoteIP", RemoteIP(r),
|
||||
"clientIP", ClientIP(r),
|
||||
)
|
||||
http.Error(w, responseMessage, http.StatusTooManyRequests)
|
||||
}
|
||||
}
|
||||
@@ -390,7 +414,7 @@ func (m *Middleware) postRateLimit(
|
||||
// It is Config.ReceiverRateLimit requests per minute.
|
||||
//
|
||||
// That limit alone bounds nothing in aggregate. The route pattern
|
||||
// /webhook/{uuid} matches any single segment, so a client that
|
||||
// /h/{uuid} matches any single segment, so a client that
|
||||
// invents a fresh path per request mints a fresh bucket per request
|
||||
// and never refills one — and every such request still reaches the
|
||||
// handler's entrypoint lookup before it 404s. The outer limit is
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/middleware"
|
||||
"sneak.berlin/go/webhooker/internal/middleware/middlewaretest"
|
||||
)
|
||||
|
||||
func TestPostRateLimit_AllowsGET(t *testing.T) {
|
||||
@@ -198,7 +199,7 @@ func rateLimitMiddleware(
|
||||
&slog.HandlerOptions{Level: slog.LevelDebug},
|
||||
))
|
||||
|
||||
return middleware.NewForTest(log, cfg, nil)
|
||||
return middlewaretest.New(t, log, cfg, nil)
|
||||
}
|
||||
|
||||
// trustedProxies parses CIDR strings for a test Config.
|
||||
@@ -275,7 +276,7 @@ func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) {
|
||||
// pass.
|
||||
for i := range limit {
|
||||
w := receiverPost(
|
||||
handler, "9.9.9.9:1234", "/webhook/uuid-a",
|
||||
handler, "9.9.9.9:1234", "/h/uuid-a",
|
||||
)
|
||||
assert.Equal(
|
||||
t, http.StatusOK, w.Code,
|
||||
@@ -286,7 +287,7 @@ func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) {
|
||||
// The next request over the limit is rejected with a 429
|
||||
// carrying a Retry-After header.
|
||||
w := receiverPost(
|
||||
handler, "9.9.9.9:1234", "/webhook/uuid-a",
|
||||
handler, "9.9.9.9:1234", "/h/uuid-a",
|
||||
)
|
||||
assert.Equal(t, http.StatusTooManyRequests, w.Code)
|
||||
assert.NotEmpty(
|
||||
@@ -296,7 +297,7 @@ func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) {
|
||||
|
||||
// The same IP is not limited on a different entrypoint.
|
||||
w = receiverPost(
|
||||
handler, "9.9.9.9:1234", "/webhook/uuid-b",
|
||||
handler, "9.9.9.9:1234", "/h/uuid-b",
|
||||
)
|
||||
assert.Equal(
|
||||
t, http.StatusOK, w.Code,
|
||||
@@ -305,7 +306,7 @@ func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) {
|
||||
|
||||
// A different IP is not limited on the same entrypoint.
|
||||
w = receiverPost(
|
||||
handler, "8.8.8.8:1234", "/webhook/uuid-a",
|
||||
handler, "8.8.8.8:1234", "/h/uuid-a",
|
||||
)
|
||||
assert.Equal(
|
||||
t, http.StatusOK, w.Code,
|
||||
@@ -322,7 +323,7 @@ func TestReceiverRateLimit_CountsEveryMethod(t *testing.T) {
|
||||
const (
|
||||
limit = 2
|
||||
ip = "7.7.7.7:1234"
|
||||
path = "/webhook/uuid-c"
|
||||
path = "/h/uuid-c"
|
||||
)
|
||||
|
||||
handler := receiverLimitedHandler(t, limit)
|
||||
@@ -384,8 +385,8 @@ const (
|
||||
// trustedProxyCIDR is the proxy network the forwarded-path
|
||||
// tests configure, and trustedPeer an address inside it. A
|
||||
// production deployment is required to run behind a reverse
|
||||
// proxy with TRUSTED_PROXIES set, so this is the shape the
|
||||
// bucketing has to hold in.
|
||||
// proxy that TRUSTED_PROXIES covers, either by the default or by
|
||||
// a set value, so this is the shape the bucketing has to hold in.
|
||||
trustedProxyCIDR = "10.0.0.0/8"
|
||||
trustedPeer = "10.0.0.1:44444"
|
||||
)
|
||||
@@ -426,8 +427,8 @@ func assertSharedBucket(
|
||||
}
|
||||
|
||||
// TestRateLimitKey_SpoofedForwardedFromUntrustedPeer is the test
|
||||
// this gating exists for: with no trusted proxies configured (the
|
||||
// default), a client that rotates a forwarded header on every
|
||||
// this gating exists for: from a peer that is not a trusted
|
||||
// proxy, a client that rotates a forwarded header on every
|
||||
// request must stay in one bucket. If forwarded headers were
|
||||
// trusted unconditionally, each spoofed value would mint a fresh
|
||||
// bucket and the limit would stop no one.
|
||||
@@ -715,7 +716,7 @@ func TestReceiverRateLimit_LimitsAggregateAcrossInventedPaths(
|
||||
// none of them shares a per-entrypoint bucket with another.
|
||||
for i := range aggregate {
|
||||
w := receiverPost(
|
||||
handler, ip, fmt.Sprintf("/webhook/invented-%d", i),
|
||||
handler, ip, fmt.Sprintf("/h/invented-%d", i),
|
||||
)
|
||||
assert.Equal(
|
||||
t, http.StatusOK, w.Code,
|
||||
@@ -724,17 +725,17 @@ func TestReceiverRateLimit_LimitsAggregateAcrossInventedPaths(
|
||||
}
|
||||
|
||||
w := receiverPost(
|
||||
handler, ip, fmt.Sprintf("/webhook/invented-%d", aggregate),
|
||||
handler, ip, fmt.Sprintf("/h/invented-%d", aggregate),
|
||||
)
|
||||
assert.Equal(
|
||||
t, http.StatusTooManyRequests, w.Code,
|
||||
"a client must not be able to raise its aggregate rate "+
|
||||
"against /webhook/* by varying the path",
|
||||
"against /h/* by varying the path",
|
||||
)
|
||||
|
||||
// The aggregate limit is still per client IP: exhausting one
|
||||
// address must not throttle another.
|
||||
w = receiverPost(handler, "6.6.6.7:1234", "/webhook/invented-0")
|
||||
w = receiverPost(handler, "6.6.6.7:1234", "/h/invented-0")
|
||||
assert.Equal(
|
||||
t, http.StatusOK, w.Code,
|
||||
"a different client IP must not be affected",
|
||||
@@ -771,7 +772,7 @@ func TestReceiverRateLimit_RejectedRequestsCountTowardAggregate(
|
||||
// limit requests are served; the rest are rejected by the
|
||||
// per-entrypoint limiter but still count against the aggregate.
|
||||
for i := range aggregate {
|
||||
w := receiverPost(handler, ip, "/webhook/exhausted")
|
||||
w := receiverPost(handler, ip, "/h/exhausted")
|
||||
|
||||
want := http.StatusTooManyRequests
|
||||
if i < limit {
|
||||
@@ -784,7 +785,7 @@ func TestReceiverRateLimit_RejectedRequestsCountTowardAggregate(
|
||||
)
|
||||
}
|
||||
|
||||
w := receiverPost(handler, ip, "/webhook/never-used")
|
||||
w := receiverPost(handler, ip, "/h/never-used")
|
||||
assert.Equal(
|
||||
t, http.StatusTooManyRequests, w.Code,
|
||||
"requests rejected per entrypoint must still count "+
|
||||
@@ -823,7 +824,7 @@ func TestReceiverRateLimit_IgnoresForwardedFromUntrustedPeer(
|
||||
const (
|
||||
limit = 3
|
||||
peer = "203.0.113.10:44444"
|
||||
path = "/webhook/uuid-d"
|
||||
path = "/h/uuid-d"
|
||||
)
|
||||
|
||||
handler := receiverLimitedHandler(t, limit)
|
||||
@@ -1012,6 +1013,23 @@ func TestRateLimitKey_UnparseablePeerKeepsDistinctBuckets(
|
||||
)
|
||||
}
|
||||
|
||||
// TestRateLimitKey_EmptyPeerSharesOneBucket pins what the fallback
|
||||
// does with an empty RemoteAddr: it keys on the empty string, so every
|
||||
// such request shares one bucket. That is the fail-closed direction
|
||||
// and is kept on purpose; only a hand-built request carries an empty
|
||||
// RemoteAddr.
|
||||
func TestRateLimitKey_EmptyPeerSharesOneBucket(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := rateLimitMiddleware(t, &config.Config{})
|
||||
|
||||
assert.Empty(
|
||||
t, clientKeyFor(t, m, ""),
|
||||
"every peer with an empty RemoteAddr must key on the "+
|
||||
"empty string and so share one bucket",
|
||||
)
|
||||
}
|
||||
|
||||
// TestPostRateLimit_IPv6SharesBucketWithinSlash64 is the behavioural
|
||||
// half, and the regression test for the bypass itself: a client that
|
||||
// rotates source addresses inside its own routed /64 must stay in one
|
||||
@@ -1097,8 +1115,9 @@ func TestPostRateLimit_IPv4IndependentPerAddress(t *testing.T) {
|
||||
// that arrives from trustedPeer — a configured trusted proxy — and
|
||||
// names forwarded as its client in X-Forwarded-For. That is the
|
||||
// production path: a deployment is required to run behind a reverse
|
||||
// proxy with TRUSTED_PROXIES set, so the forwarded address, not the
|
||||
// peer, is what the limiters bucket on there.
|
||||
// proxy that TRUSTED_PROXIES covers, either by the default or by a
|
||||
// set value, so the forwarded address, not the peer, is what the
|
||||
// limiters bucket on there.
|
||||
func forwardedKeyFor(
|
||||
t *testing.T, m *middleware.Middleware, forwarded string,
|
||||
) string {
|
||||
@@ -1178,9 +1197,9 @@ func TestRateLimitKey_ForwardedIPv6BucketsByPrefix(t *testing.T) {
|
||||
//
|
||||
// Every existing test of this fallback uses an IPv4 proxy, where
|
||||
// bucketKey is the identity function, so replacing the call with
|
||||
// peer.String() leaves the whole suite green. Only operator-listed
|
||||
// addresses reach this line and the fallback is fail-closed, so this
|
||||
// pins behaviour rather than fixing a defect.
|
||||
// peer.String() leaves the whole suite green. Only addresses inside
|
||||
// TRUSTED_PROXIES reach this line and the fallback is fail-closed, so
|
||||
// this pins behaviour rather than fixing a defect.
|
||||
func TestRateLimitKey_TrustedPeerUnusableForwardedMasksPeer(
|
||||
t *testing.T,
|
||||
) {
|
||||
|
||||
@@ -109,7 +109,8 @@ func (w *recoverResponseWriter) Unwrap() http.ResponseWriter {
|
||||
|
||||
// Recoverer returns middleware that turns a handler panic into one
|
||||
// structured ERROR record and a 500, rather than a dropped
|
||||
// connection.
|
||||
// connection. The 500 is page when page is not nil, and plain text
|
||||
// when it is nil or when page panics before writing anything.
|
||||
//
|
||||
// It replaces chi's middleware.Recoverer, which does neither on a
|
||||
// current Go release. chi v1.5.5's pretty-printer scans the stack for
|
||||
@@ -136,9 +137,13 @@ func (w *recoverResponseWriter) Unwrap() http.ResponseWriter {
|
||||
//
|
||||
// Unlike http.Error on its own, it deletes any Set-Cookie the handler
|
||||
// set before panicking, because a request that failed must not hand
|
||||
// the client a credential; every other header is left to http.Error.
|
||||
// the client a credential. It touches no other header: when page
|
||||
// answers, every other header the handler set goes out with it, apart
|
||||
// from any page sets itself; otherwise they are left to http.Error.
|
||||
// See https://git.eeqj.de/sneak/webhooker/issues/193.
|
||||
func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
|
||||
func (s *Middleware) Recoverer(
|
||||
page http.Handler,
|
||||
) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(
|
||||
w http.ResponseWriter,
|
||||
@@ -171,6 +176,14 @@ func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
|
||||
|
||||
rw.Header().Del("Set-Cookie")
|
||||
|
||||
if page != nil {
|
||||
s.servePage(rw, r, page)
|
||||
}
|
||||
|
||||
if rw.committed {
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(
|
||||
rw,
|
||||
http.StatusText(
|
||||
@@ -185,6 +198,27 @@ func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// servePage answers with page. A panic in page itself is logged and
|
||||
// recovered here, so the Recoverer can still send its plain 500.
|
||||
func (s *Middleware) servePage(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
page http.Handler,
|
||||
) {
|
||||
defer func() {
|
||||
rvr := recover()
|
||||
if rvr != nil {
|
||||
s.log.Error("error page panic",
|
||||
"panic", logfield.Truncate(
|
||||
fmt.Sprint(rvr), maxPanicValueBytes,
|
||||
),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
page.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// logPanic writes the record. Every field it can grow is truncated to
|
||||
// a fixed budget, so MaxPanicLogLineBytes holds.
|
||||
//
|
||||
|
||||
@@ -76,7 +76,7 @@ func newRecovererProbe(
|
||||
// Logging outside so the recovered 500 is the status it records.
|
||||
router.Use(chimw.RequestID)
|
||||
router.Use(m.Logging())
|
||||
router.Use(m.Recoverer())
|
||||
router.Use(m.Recoverer(nil))
|
||||
router.Get("/probe", handler)
|
||||
|
||||
serverErrors := new(bytes.Buffer)
|
||||
@@ -627,17 +627,15 @@ func TestRecovererIgnoresANonPanickingHandler(t *testing.T) {
|
||||
// net/http's own writer from http.ResponseController, so a handler
|
||||
// that flushes or sets a deadline starts failing.
|
||||
//
|
||||
// The recoverer is the only middleware in the chain here. The access
|
||||
// logger's own wrapper does not implement Unwrap, so a chain
|
||||
// containing it fails this regardless of what the recoverer does;
|
||||
// what is being pinned is that the recoverer adds no such opacity of
|
||||
// its own.
|
||||
// The recoverer is the only middleware in the chain here;
|
||||
// TestResponseControllerThroughProductionRouter in internal/server
|
||||
// covers the shipped chain.
|
||||
func TestRecovererKeepsResponseControllerWorking(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m, _ := capturingMiddleware(t)
|
||||
|
||||
handler := m.Recoverer()(http.HandlerFunc(
|
||||
handler := m.Recoverer(nil)(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("chunk"))
|
||||
|
||||
@@ -672,3 +670,59 @@ func TestRecovererKeepsResponseControllerWorking(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, "chunk", string(body))
|
||||
}
|
||||
|
||||
// TestRecovererAnswersWithThePage covers a recoverer given a page:
|
||||
// the panic is logged as before, and the 500 is that page.
|
||||
func TestRecovererAnswersWithThePage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m, logs := capturingMiddleware(t)
|
||||
|
||||
page := http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("the error page"))
|
||||
},
|
||||
)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
m.Recoverer(page)(http.HandlerFunc(panicProbe)).ServeHTTP(
|
||||
w, httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/", nil,
|
||||
),
|
||||
)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
assert.Equal(t, "the error page", w.Body.String())
|
||||
assert.Contains(t, logs.String(), `"msg":"handler panic"`)
|
||||
assert.Contains(t, logs.String(), panicMarker)
|
||||
}
|
||||
|
||||
// TestRecovererFallsBackWhenThePagePanics covers a page that panics
|
||||
// before writing anything: both panics are logged, and the client
|
||||
// still gets the plain 500.
|
||||
func TestRecovererFallsBackWhenThePagePanics(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m, logs := capturingMiddleware(t)
|
||||
|
||||
const pagePanic = "QQERRORPAGEPANICQQ"
|
||||
|
||||
page := http.HandlerFunc(
|
||||
func(http.ResponseWriter, *http.Request) {
|
||||
panic(pagePanic)
|
||||
},
|
||||
)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
m.Recoverer(page)(http.HandlerFunc(panicProbe)).ServeHTTP(
|
||||
w, httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/", nil,
|
||||
),
|
||||
)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
assert.Equal(t, "Internal Server Error\n", w.Body.String())
|
||||
assert.Contains(t, logs.String(), panicMarker)
|
||||
assert.Contains(t, logs.String(), pagePanic)
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
)
|
||||
|
||||
// NewForTest creates a Middleware with the minimum dependencies
|
||||
// needed for testing. This bypasses the fx lifecycle.
|
||||
func NewForTest(
|
||||
log *slog.Logger,
|
||||
cfg *config.Config,
|
||||
sess *session.Session,
|
||||
) *Middleware {
|
||||
return &Middleware{
|
||||
log: log,
|
||||
params: &MiddlewareParams{
|
||||
Config: cfg,
|
||||
},
|
||||
session: sess,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Timeout returns middleware that gives each request limit to finish:
|
||||
// it cancels the request's context once limit has passed, and answers
|
||||
// 504 when the handler then returns without having started its
|
||||
// response.
|
||||
//
|
||||
// It replaces chi's middleware.Timeout, which writes that 504 even
|
||||
// after the handler has sent its own status. A download that outlasts
|
||||
// the limit has already sent its 200 and the whole file, so the late
|
||||
// 504 changes nothing for the client: the access log and the metrics
|
||||
// would record it in place of the 200, and net/http would complain of
|
||||
// a superfluous WriteHeader.
|
||||
func (s *Middleware) Timeout(
|
||||
limit time.Duration,
|
||||
) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), limit)
|
||||
defer cancel()
|
||||
|
||||
tw := &timeoutResponseWriter{ResponseWriter: w}
|
||||
|
||||
next.ServeHTTP(tw, r.WithContext(ctx))
|
||||
|
||||
if !tw.started &&
|
||||
errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
w.WriteHeader(http.StatusGatewayTimeout)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// timeoutResponseWriter records whether the handler has started its
|
||||
// response.
|
||||
type timeoutResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
|
||||
started bool
|
||||
}
|
||||
|
||||
func (w *timeoutResponseWriter) WriteHeader(code int) {
|
||||
w.started = true
|
||||
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (w *timeoutResponseWriter) Write(b []byte) (int, error) {
|
||||
// A Write without a WriteHeader starts the response too: net/http
|
||||
// sends 200 in front of it.
|
||||
w.started = true
|
||||
|
||||
//nolint:wrapcheck // Pass the writer's own error through unchanged.
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// Unwrap lets http.ResponseController reach the writer underneath, so
|
||||
// a handler can still set a write deadline through this wrapper.
|
||||
func (w *timeoutResponseWriter) Unwrap() http.ResponseWriter {
|
||||
return w.ResponseWriter
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package middleware_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestTimeout proves the request limit answers 504 to a handler that
|
||||
// outlasts it without starting its response, and leaves a response the
|
||||
// handler has started with the status it sent. Both are what the
|
||||
// access log records.
|
||||
func TestTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const limit = 10 * time.Millisecond
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
sent int // the status the handler sends, or 0 for none
|
||||
want int
|
||||
}{
|
||||
{name: "not started", sent: 0, want: http.StatusGatewayTimeout},
|
||||
{name: "started", sent: http.StatusOK, want: http.StatusOK},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m, buf := capturingMiddleware(t)
|
||||
handler := m.Logging()(m.Timeout(limit)(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
if tc.sent != 0 {
|
||||
w.WriteHeader(tc.sent)
|
||||
}
|
||||
|
||||
<-r.Context().Done()
|
||||
},
|
||||
)))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/", nil,
|
||||
))
|
||||
|
||||
assert.Equal(t, tc.want, w.Code)
|
||||
|
||||
entries := accessLogEntries(t, buf)
|
||||
require.Len(t, entries, 1)
|
||||
assert.EqualValues(t, tc.want, entries[0]["status"])
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user