All checks were successful
check / check (push) Successful in 2m54s
MaxBodySize logged r.URL.Path untruncated at WARN, and routes.go registers it ahead of RequireAuth, so an unauthenticated POST /source/<8 KB>/edit with an oversize declared Content-Length wrote attacker-chosen text of attacker-chosen length into the operator's log, for the cost of a request with no body. The 2,560-byte per-line budget from #146 did not reach it: that budget lives in the access log's field capping and this is a separate slog call. The capping mechanism moves out of internal/middleware into internal/logfield so there is one budget and one implementation rather than a second ad-hoc truncation. Truncate and EncodedBytes are unchanged; the access log now spends logfield.MaxBytes where it spent maxLogFieldBytes. The sweep the issue asked for found five more call sites of the same shape, all reachable unauthenticated, all now capped: the CSRF 403 (also registered ahead of RequireAuth), the rate limiters' 429 (the per-entrypoint receiver limiter is unauthenticated), RequireAuth's own DEBUG line, the unknown-entrypoint DEBUG line on the receiver, and the failed-login DEBUG lines. DEBUG being off by default is not a bound: an operator turning it on to diagnose a flood must not thereby hand the flood an unbounded write. Every other slog call in the tree was read and judged; the PR body lists all of them, including the ones left alone and why. Two further sites arrived in next with #171 after the first sweep was written and are capped here as well: "login failure limit exceeded" in loginguard.go and "password verification capacity exhausted" in handlers/auth.go, both WARN on the unauthenticated login POST. Neither was ever wide: chi routes that POST on a static pattern, so r.URL.Path is the 12-byte constant /pages/login and each line lands near 120 bytes. They are capped because RecordLoginFailure is exported and takes any *http.Request, so the bound rests on a routing invariant nobody wrote down, and because the same message at handlers/profile.go logs no path at all. No request through the mux can widen either line, so their tests call those two entry points directly with the path a caller on a parameterised route would supply; that is what the caps defend against, and an unasserted cap is one a later edit removes for free. MaxBodySize stays ahead of RequireAuth. An oversize body should be refused before the request buys a cookie decrypt and a session load, and rejecting first is what keeps an unauthenticated flood from choosing how much session work the process does. The ordering and what it costs are now written at the registration, on maxFormBodySize. MaxAccessLogLineBytes is restated as the ceiling on every slog line carrying text an UNAUTHENTICATED client supplies, not just the access log's: each of these lines carries strictly fewer client-supplied fields than the access log does, so none can be wider. That is asserted per line under both handlers rather than argued. The claim is qualified rather than universal because three kinds of writer are outside it, and the README and the constant now name all three: lines carrying an authenticated operator's own input, which are not truncated at all (the webhook name on "webhook created" reaches 600 KB on one line from a 100 KB form field, measured; the SSRF-rejection url and the target_name lines are the same shape) and are left uncapped deliberately, since truncating the operator's own configuration echoed back costs debuggability against no adversary; the log delivery target, which exists to emit the whole event; and GORM's default logger, which prints the interpolated SQL to stdout on a record-not-found and is unbounded on the receiver and login lookups. That last one is a real defect this audit turned up and is filed separately as #178, not fixed here. Tests drive client-chosen text at every site capped here, through both handlers internal/logger can install and through seven fills: plain text, plus the quotation mark, backslash, tab, newline, C0 control and astral non-printable. The C0 control is the one that matters most, costing six bytes on the line against the one it cost to send, and is the case a raw-byte budget breaks on first. The fill is 8 KB everywhere except the two lines past the username lookup, where it is 1 KB because a longer stored username overflows the session cookie and answers 500 before the success line is written. Each case holds the encoded line to the ceiling and asserts the markers at the far end of the input are absent, so a value that merely happened to be short cannot pass. Three of the sites go further and bound the whole flood's output, the total bytes a run of distinct invented values wrote: the 413 rejection, the unknown-entrypoint line and "user not found". The other sites carry the per-line bound only, which is what MaxAccessLogLineBytes states; the README names which sites carry which. internal/logfield gains a test that measures the per-rune charge against what the handlers really emit over roughly 3,000 code points on each, so an undercharged rune fails a test instead of quietly falsifying the ceiling. Verified by mutation: reverting the MaxBodySize cap alone fails 28 subtests with a 16,583-byte line against the 2,560 ceiling; reverting the other five fails 70; reverting either login-throttle WARN cap fails 14, through the direct calls those caps exist for; uncapping either of the two login lines past the username lookup fails both handlers on its own, so those two are independently pinned rather than jointly; budgeting raw bytes instead of encoded ones fails 23 across three packages.
1002 lines
22 KiB
Go
1002 lines
22 KiB
Go
package middleware_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gorilla/sessions"
|
|
"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/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.
|
|
func testMiddleware(
|
|
t *testing.T,
|
|
env string,
|
|
) (*middleware.Middleware, *session.Session) {
|
|
t.Helper()
|
|
|
|
m, s, _ := testMiddlewareWithSessionClock(t, env, 0, nil)
|
|
|
|
return m, s
|
|
}
|
|
|
|
// 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(
|
|
t *testing.T,
|
|
env string,
|
|
idleTimeout time.Duration,
|
|
clock *fakeClock,
|
|
) (*middleware.Middleware, *session.Session, *fakeClock) {
|
|
t.Helper()
|
|
|
|
log := slog.New(slog.NewTextHandler(
|
|
os.Stderr,
|
|
&slog.HandlerOptions{Level: slog.LevelDebug},
|
|
))
|
|
|
|
cfg := &config.Config{
|
|
Environment: env,
|
|
SessionIdleTimeout: idleTimeout,
|
|
}
|
|
|
|
sessManager := newTestSessionManager(cfg, log, clock)
|
|
|
|
m := middleware.NewForTest(log, cfg, sessManager)
|
|
|
|
return m, sessManager, clock
|
|
}
|
|
|
|
// 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.
|
|
func newTestSessionManager(
|
|
cfg *config.Config,
|
|
log *slog.Logger,
|
|
clock *fakeClock,
|
|
) *session.Session {
|
|
key := make([]byte, testKeySize)
|
|
|
|
for i := range key {
|
|
key[i] = byte(i)
|
|
}
|
|
|
|
store := sessions.NewCookieStore(key)
|
|
store.Options = &sessions.Options{
|
|
Path: "/",
|
|
MaxAge: 86400 * 7,
|
|
HttpOnly: true,
|
|
Secure: false,
|
|
SameSite: http.SameSiteLaxMode,
|
|
}
|
|
|
|
var now func() time.Time
|
|
|
|
if clock != nil {
|
|
now = clock.Now
|
|
}
|
|
|
|
return session.NewForTest(store, cfg, log, key, now)
|
|
}
|
|
|
|
// fakeClock is a manually advanced clock, so session expiry can be
|
|
// tested without sleeping.
|
|
type fakeClock struct {
|
|
t time.Time
|
|
}
|
|
|
|
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,
|
|
),
|
|
}
|
|
}
|
|
|
|
// --- Logging Middleware Tests ---
|
|
|
|
func TestLogging_SetsStatusCode(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
|
|
|
handler := m.Logging()(http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusCreated)
|
|
|
|
_, err := w.Write([]byte("created"))
|
|
if err != nil {
|
|
return
|
|
}
|
|
},
|
|
))
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/test", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusCreated, w.Code)
|
|
assert.Equal(t, "created", w.Body.String())
|
|
}
|
|
|
|
func TestLogging_DefaultStatusOK(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
|
|
|
handler := m.Logging()(http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
_, err := w.Write([]byte("ok"))
|
|
if err != nil {
|
|
return
|
|
}
|
|
},
|
|
))
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
// When no explicit WriteHeader is called, default is 200
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
}
|
|
|
|
func TestLogging_PassesThroughToNext(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
|
|
|
var called bool
|
|
|
|
handler := m.Logging()(http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
called = true
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
},
|
|
))
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodPost, "/api/webhook", nil,
|
|
)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
assert.True(
|
|
t, called,
|
|
"logging middleware should call the next handler",
|
|
)
|
|
}
|
|
|
|
// --- LoggingResponseWriter Tests ---
|
|
|
|
func TestLoggingResponseWriter_CapturesStatusCode(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := httptest.NewRecorder()
|
|
lrw := middleware.NewLoggingResponseWriterForTest(w)
|
|
|
|
// Default should be 200
|
|
assert.Equal(
|
|
t, http.StatusOK,
|
|
middleware.LoggingResponseWriterStatusCode(lrw),
|
|
)
|
|
|
|
// WriteHeader should capture the status code
|
|
lrw.WriteHeader(http.StatusNotFound)
|
|
|
|
assert.Equal(
|
|
t, http.StatusNotFound,
|
|
middleware.LoggingResponseWriterStatusCode(lrw),
|
|
)
|
|
|
|
// Underlying writer should also get the status code
|
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
|
}
|
|
|
|
func TestLoggingResponseWriter_WriteDelegatesToUnderlying(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
w := httptest.NewRecorder()
|
|
lrw := middleware.NewLoggingResponseWriterForTest(w)
|
|
|
|
n, err := lrw.Write([]byte("hello world"))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 11, n)
|
|
assert.Equal(t, "hello world", w.Body.String())
|
|
}
|
|
|
|
// --- CORS Middleware Tests ---
|
|
|
|
func TestCORS_DevMode_AllowsAnyOrigin(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
|
|
|
handler := m.CORS()(http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
},
|
|
))
|
|
|
|
// Preflight request
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodOptions, "/api/test", nil,
|
|
)
|
|
req.Header.Set("Origin", "http://localhost:3000")
|
|
req.Header.Set("Access-Control-Request-Method", "POST")
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
// In dev mode, CORS should allow any origin
|
|
assert.Equal(
|
|
t, "*",
|
|
w.Header().Get("Access-Control-Allow-Origin"),
|
|
)
|
|
}
|
|
|
|
func TestCORS_ProdMode_NoOp(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m, _ := testMiddleware(t, config.EnvironmentProd)
|
|
|
|
var called bool
|
|
|
|
handler := m.CORS()(http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
called = true
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
},
|
|
))
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodGet, "/api/test", nil,
|
|
)
|
|
req.Header.Set("Origin", "http://evil.com")
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
assert.True(
|
|
t, called,
|
|
"prod CORS middleware should pass through to handler",
|
|
)
|
|
// In prod, no CORS headers should be set (no-op middleware)
|
|
assert.Empty(
|
|
t,
|
|
w.Header().Get("Access-Control-Allow-Origin"),
|
|
"prod mode should not set CORS headers",
|
|
)
|
|
}
|
|
|
|
// --- RequireAuth Middleware Tests ---
|
|
|
|
func TestRequireAuth_NoSession_RedirectsToLogin(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
|
|
|
var called bool
|
|
|
|
handler := m.RequireAuth()(http.HandlerFunc(
|
|
func(_ http.ResponseWriter, _ *http.Request) {
|
|
called = true
|
|
},
|
|
))
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodGet, "/dashboard", nil,
|
|
)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
assert.False(
|
|
t, called,
|
|
"handler should not be called for "+
|
|
"unauthenticated request",
|
|
)
|
|
assert.Equal(t, http.StatusSeeOther, w.Code)
|
|
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
|
}
|
|
|
|
func TestRequireAuth_AuthenticatedSession_PassesThrough(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
m, sessManager := testMiddleware(t, config.EnvironmentDev)
|
|
|
|
var called bool
|
|
|
|
handler := m.RequireAuth()(http.HandlerFunc(
|
|
func(_ http.ResponseWriter, _ *http.Request) {
|
|
called = true
|
|
},
|
|
))
|
|
|
|
// Create an authenticated session by making a request,
|
|
// setting session data, and saving the session cookie
|
|
setupReq := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodGet, "/setup", nil,
|
|
)
|
|
setupW := httptest.NewRecorder()
|
|
|
|
sess, err := sessManager.Get(setupReq)
|
|
require.NoError(t, err)
|
|
sessManager.SetUser(sess, "user-123", "testuser")
|
|
require.NoError(t, sessManager.Save(setupReq, setupW, sess))
|
|
|
|
// Extract the cookie from the setup response
|
|
cookies := setupW.Result().Cookies()
|
|
require.NotEmpty(t, cookies, "session cookie should be set")
|
|
|
|
// Make the actual request with the session cookie
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodGet, "/dashboard", nil,
|
|
)
|
|
|
|
for _, c := range cookies {
|
|
req.AddCookie(c)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
assert.True(
|
|
t, called,
|
|
"handler should be called for authenticated request",
|
|
)
|
|
}
|
|
|
|
func TestRequireAuth_UnauthenticatedSession_RedirectsToLogin(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
m, sessManager := testMiddleware(t, config.EnvironmentDev)
|
|
|
|
var called bool
|
|
|
|
handler := m.RequireAuth()(http.HandlerFunc(
|
|
func(_ http.ResponseWriter, _ *http.Request) {
|
|
called = true
|
|
},
|
|
))
|
|
|
|
// Create a session but don't authenticate it
|
|
setupReq := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodGet, "/setup", nil,
|
|
)
|
|
setupW := httptest.NewRecorder()
|
|
|
|
sess, err := sessManager.Get(setupReq)
|
|
require.NoError(t, err)
|
|
// Don't call SetUser -- session exists but is not
|
|
// authenticated
|
|
require.NoError(t, sessManager.Save(setupReq, setupW, sess))
|
|
|
|
cookies := setupW.Result().Cookies()
|
|
require.NotEmpty(t, cookies)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodGet, "/dashboard", nil,
|
|
)
|
|
|
|
for _, c := range cookies {
|
|
req.AddCookie(c)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
assert.False(
|
|
t, called,
|
|
"handler should not be called for "+
|
|
"unauthenticated session",
|
|
)
|
|
assert.Equal(t, http.StatusSeeOther, w.Code)
|
|
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
|
}
|
|
|
|
// --- RequireAuth Session Expiry Tests ---
|
|
|
|
// loginCookies authenticates a new session and returns the cookies
|
|
// a browser would then send back.
|
|
func loginCookies(
|
|
t *testing.T,
|
|
sessManager *session.Session,
|
|
) []*http.Cookie {
|
|
t.Helper()
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/login", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
sess, err := sessManager.Get(req)
|
|
require.NoError(t, err)
|
|
sessManager.SetUser(sess, "user-123", "testuser")
|
|
require.NoError(t, sessManager.Save(req, w, sess))
|
|
|
|
cookies := w.Result().Cookies()
|
|
require.NotEmpty(t, cookies, "session cookie should be set")
|
|
|
|
return cookies
|
|
}
|
|
|
|
// runAuthed sends a request carrying cookies through RequireAuth
|
|
// and reports whether the protected handler ran, plus the response.
|
|
func runAuthed(
|
|
t *testing.T,
|
|
m *middleware.Middleware,
|
|
cookies []*http.Cookie,
|
|
) (bool, *httptest.ResponseRecorder) {
|
|
t.Helper()
|
|
|
|
var called bool
|
|
|
|
handler := m.RequireAuth()(http.HandlerFunc(
|
|
func(_ http.ResponseWriter, _ *http.Request) {
|
|
called = true
|
|
},
|
|
))
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodGet, "/dashboard", nil,
|
|
)
|
|
|
|
for _, c := range cookies {
|
|
req.AddCookie(c)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
return called, w
|
|
}
|
|
|
|
// sessionCookies filters a response's cookies down to the session
|
|
// cookie, so tests can tell whether the session was re-issued.
|
|
func sessionCookies(
|
|
w *httptest.ResponseRecorder,
|
|
) []*http.Cookie {
|
|
var out []*http.Cookie
|
|
|
|
for _, c := range w.Result().Cookies() {
|
|
if c.Name == session.SessionName {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func TestRequireAuth_IdleExpiredSession_RedirectsToLogin(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
idle := time.Hour
|
|
|
|
m, sessManager, clock := testMiddlewareWithSessionClock(
|
|
t, config.EnvironmentDev, idle, newFakeClock(),
|
|
)
|
|
|
|
cookies := loginCookies(t, sessManager)
|
|
|
|
clock.Advance(idle)
|
|
|
|
called, w := runAuthed(t, m, cookies)
|
|
|
|
assert.False(
|
|
t, called,
|
|
"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.Empty(
|
|
t, sessionCookies(w),
|
|
"an expired session must not be refreshed",
|
|
)
|
|
}
|
|
|
|
func TestRequireAuth_RefreshesIdleDeadlineOnActivity(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
idle := time.Hour
|
|
|
|
m, sessManager, clock := testMiddlewareWithSessionClock(
|
|
t, config.EnvironmentDev, idle, newFakeClock(),
|
|
)
|
|
|
|
cookies := loginCookies(t, sessManager)
|
|
|
|
// Activity halfway through the idle window.
|
|
clock.Advance(idle / 2)
|
|
|
|
called, w := runAuthed(t, m, cookies)
|
|
require.True(t, called, "handler should run while valid")
|
|
|
|
refreshed := sessionCookies(w)
|
|
require.NotEmpty(
|
|
t, refreshed,
|
|
"activity should re-issue the session cookie",
|
|
)
|
|
|
|
// Past the original deadline. The refreshed cookie is still
|
|
// good; the original one is not.
|
|
clock.Advance(idle - time.Second)
|
|
|
|
calledRefreshed, _ := runAuthed(t, m, refreshed)
|
|
assert.True(
|
|
t, calledRefreshed,
|
|
"refreshed session should outlive the original deadline",
|
|
)
|
|
|
|
calledStale, staleW := runAuthed(t, m, cookies)
|
|
assert.False(
|
|
t, calledStale,
|
|
"the pre-refresh cookie carries the old idle deadline",
|
|
)
|
|
assert.Equal(t, http.StatusSeeOther, staleW.Code)
|
|
}
|
|
|
|
func TestRequireAuth_UnauthenticatedRequestDoesNotRefresh(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
m, sessManager, _ := testMiddlewareWithSessionClock(
|
|
t, config.EnvironmentDev, time.Hour, newFakeClock(),
|
|
)
|
|
|
|
// A session cookie that exists but was never authenticated.
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/setup", nil)
|
|
setupW := httptest.NewRecorder()
|
|
|
|
sess, err := sessManager.Get(req)
|
|
require.NoError(t, err)
|
|
require.NoError(t, sessManager.Save(req, setupW, sess))
|
|
|
|
cookies := setupW.Result().Cookies()
|
|
require.NotEmpty(t, cookies)
|
|
|
|
called, w := runAuthed(t, m, cookies)
|
|
|
|
assert.False(t, called)
|
|
assert.Empty(
|
|
t, sessionCookies(w),
|
|
"an unauthenticated request must not stamp the session",
|
|
)
|
|
}
|
|
|
|
// --- NoCache Middleware Tests ---
|
|
|
|
func TestNoCache_SetsHeaders(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
|
|
|
var called bool
|
|
|
|
handler := m.NoCache()(http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
called = true
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
},
|
|
))
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodGet, "/sources", nil,
|
|
)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
assert.True(
|
|
t, called,
|
|
"NoCache middleware should call the next handler",
|
|
)
|
|
assert.Equal(
|
|
t, "no-store",
|
|
w.Header().Get("Cache-Control"),
|
|
)
|
|
assert.Equal(
|
|
t, "no-cache",
|
|
w.Header().Get("Pragma"),
|
|
)
|
|
}
|
|
|
|
// --- 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".
|
|
type maxBodySizeResult struct {
|
|
called bool
|
|
read int
|
|
readErr error
|
|
response *httptest.ResponseRecorder
|
|
}
|
|
|
|
func runMaxBodySize(
|
|
t *testing.T,
|
|
req *http.Request,
|
|
) *maxBodySizeResult {
|
|
t.Helper()
|
|
|
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
|
res := &maxBodySizeResult{response: httptest.NewRecorder()}
|
|
|
|
handler := m.MaxBodySize(testBodyLimit)(http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
res.called = true
|
|
|
|
body, err := io.ReadAll(r.Body)
|
|
res.read = len(body)
|
|
res.readErr = err
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
},
|
|
))
|
|
|
|
handler.ServeHTTP(res.response, req)
|
|
|
|
return res
|
|
}
|
|
|
|
// postWithBody builds a POST request whose Content-Length is
|
|
// accurate for the given payload size.
|
|
func postWithBody(size int) *http.Request {
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodPost, "/pages/login",
|
|
strings.NewReader(strings.Repeat("a", size)),
|
|
)
|
|
req.Header.Set(
|
|
"Content-Type", "application/x-www-form-urlencoded",
|
|
)
|
|
|
|
return req
|
|
}
|
|
|
|
func TestMaxBodySize_DeclaredOversize_413AndHandlerNotReached(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
res := runMaxBodySize(t, postWithBody(int(testBodyLimit)+1))
|
|
|
|
assert.False(
|
|
t, res.called,
|
|
"handler must not be reached for an oversized body",
|
|
)
|
|
assert.Equal(
|
|
t, http.StatusRequestEntityTooLarge, res.response.Code,
|
|
)
|
|
}
|
|
|
|
func TestMaxBodySize_AtLimit_PassesThrough(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
res := runMaxBodySize(t, postWithBody(int(testBodyLimit)))
|
|
|
|
assert.True(
|
|
t, res.called,
|
|
"handler should be reached for a body at the limit",
|
|
)
|
|
require.NoError(t, res.readErr)
|
|
assert.Equal(t, int(testBodyLimit), res.read)
|
|
assert.Equal(t, http.StatusOK, res.response.Code)
|
|
}
|
|
|
|
func TestMaxBodySize_UnderLimit_PassesThrough(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
res := runMaxBodySize(t, postWithBody(1))
|
|
|
|
assert.True(t, res.called)
|
|
require.NoError(t, res.readErr)
|
|
assert.Equal(t, 1, res.read)
|
|
assert.Equal(t, http.StatusOK, res.response.Code)
|
|
}
|
|
|
|
func TestMaxBodySize_GetWithOversizeBody_NotCapped(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodGet, "/pages/login",
|
|
strings.NewReader(
|
|
strings.Repeat("a", int(testBodyLimit)+1),
|
|
),
|
|
)
|
|
|
|
res := runMaxBodySize(t, req)
|
|
|
|
assert.True(
|
|
t, res.called,
|
|
"GET requests are not subject to the POST body cap",
|
|
)
|
|
require.NoError(t, res.readErr)
|
|
assert.Equal(t, int(testBodyLimit)+1, res.read)
|
|
}
|
|
|
|
// TestMaxBodySize_UndeclaredOversize_TruncatedAtCap covers the
|
|
// chunked / lying-Content-Length case: there is nothing to check up
|
|
// front, so the request reaches the handler but MaxBytesReader
|
|
// hard-caps the body and the read fails at the limit.
|
|
func TestMaxBodySize_UndeclaredOversize_TruncatedAtCap(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
req := postWithBody(int(testBodyLimit) + 1)
|
|
// Simulate a chunked request: no declared length.
|
|
req.ContentLength = -1
|
|
|
|
res := runMaxBodySize(t, req)
|
|
|
|
assert.True(
|
|
t, res.called,
|
|
"an undeclared oversize body cannot be rejected up front",
|
|
)
|
|
require.Error(
|
|
t, res.readErr,
|
|
"reading past the cap must fail",
|
|
)
|
|
assert.Equal(
|
|
t, int(testBodyLimit), res.read,
|
|
"the handler must not see more than the cap",
|
|
)
|
|
}
|
|
|
|
// --- Helper Tests ---
|
|
|
|
func TestIpFromHostPort(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
}{
|
|
{"ipv4 with port", "192.168.1.1:8080", "192.168.1.1"},
|
|
{"ipv6 with port", "[::1]:8080", "::1"},
|
|
{"invalid format", "not-a-host-port", ""},
|
|
{"empty string", "", ""},
|
|
{"localhost", "127.0.0.1:80", "127.0.0.1"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
result := middleware.IPFromHostPort(tt.input)
|
|
|
|
assert.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- MetricsAuth Tests ---
|
|
|
|
// metricsAuthMiddleware creates a Middleware configured for
|
|
// metrics auth testing. This helper de-duplicates the setup in
|
|
// metrics auth test functions.
|
|
func metricsAuthMiddleware(
|
|
t *testing.T,
|
|
) *middleware.Middleware {
|
|
t.Helper()
|
|
|
|
log := slog.New(slog.NewTextHandler(
|
|
os.Stderr,
|
|
&slog.HandlerOptions{Level: slog.LevelDebug},
|
|
))
|
|
|
|
cfg := &config.Config{
|
|
Environment: config.EnvironmentDev,
|
|
MetricsUsername: "admin",
|
|
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)
|
|
}
|
|
|
|
// runMetricsAuthRequest sends a GET /metrics request with the
|
|
// given basic-auth password through MetricsAuth and reports
|
|
// whether the wrapped handler ran plus the recorded response.
|
|
func runMetricsAuthRequest(
|
|
t *testing.T, password string,
|
|
) (bool, *httptest.ResponseRecorder) {
|
|
t.Helper()
|
|
|
|
m := metricsAuthMiddleware(t)
|
|
|
|
var called bool
|
|
|
|
handler := m.MetricsAuth()(http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
called = true
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
},
|
|
))
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodGet, "/metrics", nil,
|
|
)
|
|
req.SetBasicAuth("admin", password)
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
return called, w
|
|
}
|
|
|
|
func TestMetricsAuth_ValidCredentials(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
called, w := runMetricsAuthRequest(t, "secret")
|
|
|
|
assert.True(
|
|
t, called,
|
|
"handler should be called with valid basic auth",
|
|
)
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
}
|
|
|
|
func TestMetricsAuth_InvalidCredentials(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
called, w := runMetricsAuthRequest(t, "wrong-password")
|
|
|
|
assert.False(
|
|
t, called,
|
|
"handler should not be called with invalid basic auth",
|
|
)
|
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
|
}
|
|
|
|
func TestMetricsAuth_NoCredentials(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m := metricsAuthMiddleware(t)
|
|
|
|
var called bool
|
|
|
|
handler := m.MetricsAuth()(http.HandlerFunc(
|
|
func(_ http.ResponseWriter, _ *http.Request) {
|
|
called = true
|
|
},
|
|
))
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodGet, "/metrics", nil,
|
|
)
|
|
// No basic auth header
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
assert.False(
|
|
t, called,
|
|
"handler should not be called without credentials",
|
|
)
|
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
|
}
|
|
|
|
// --- CORS Dev Mode Detailed Tests ---
|
|
|
|
func TestCORS_DevMode_AllowsMethods(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
|
|
|
handler := m.CORS()(http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
},
|
|
))
|
|
|
|
// Preflight for POST
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodOptions, "/api/webhooks", nil,
|
|
)
|
|
req.Header.Set("Origin", "http://localhost:5173")
|
|
req.Header.Set("Access-Control-Request-Method", "POST")
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
allowMethods := w.Header().Get("Access-Control-Allow-Methods")
|
|
assert.Contains(t, allowMethods, "POST")
|
|
}
|
|
|
|
// --- Base64 key validation for completeness ---
|
|
|
|
func TestSessionKeyFormat(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Verify that the session initialization correctly validates
|
|
// key format. A proper 32-byte key encoded as base64 should
|
|
// work.
|
|
key := make([]byte, testKeySize)
|
|
|
|
for i := range key {
|
|
key[i] = byte(i + 1)
|
|
}
|
|
|
|
encoded := base64.StdEncoding.EncodeToString(key)
|
|
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
|
require.NoError(t, err)
|
|
assert.Len(t, decoded, testKeySize)
|
|
}
|