Files
summercms/modules/surf/accesslog_test.go
Jakub Zych fbcfaaf05f fix(14.2): WR-01 access log never records the URL path
An unmatched request logged its path. A token route reached with another
method (405) or with a trailing slash (404) is answered by ServeMux before
any route handler records the pattern, so the share or invitation token in
the path reached the log. The record now carries method, pattern, status
and duration only.
2026-10-12 11:17:51 +02:00

351 lines
12 KiB
Go

package surf
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
// blogPostsPlugin serves GET /blog/posts/{id}.
type blogPostsPlugin struct{}
func (blogPostsPlugin) ID() string { return "acme.blog" }
func (blogPostsPlugin) Requires() []string { return nil }
func (blogPostsPlugin) Register(*backpack.App) error { return nil }
func (blogPostsPlugin) Boot(*backpack.App) error { return nil }
func (blogPostsPlugin) Routes(r pact.Router) error {
r.Group("/blog", nil, func(g pact.Router) {
g.Get("/posts/{id}", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":7}`))
})
})
return nil
}
// accessLogRouter assembles the blog plugin with http.access_log set to on
// and a published JSON logger writing into the returned buffer.
func accessLogRouter(t *testing.T, on bool) (http.Handler, *bytes.Buffer) {
t.Helper()
y := "body_limits:\n default_bytes: 1024\n upload_bytes: 1024\n"
if on {
y += "access_log: true\n"
}
app := backpack.New(writeHTTPConfig(t, y))
var buf bytes.Buffer
if err := app.Publish(slog.New(slog.NewJSONHandler(&buf, nil))); err != nil {
t.Fatal(err)
}
h, err := Assemble(app, []party.Plugin{blogPostsPlugin{}})
if err != nil {
t.Fatal(err)
}
return h, &buf
}
// accessRecords decodes the "http request" records in buf.
func accessRecords(t *testing.T, buf *bytes.Buffer) []map[string]any {
t.Helper()
var out []map[string]any
for _, line := range strings.Split(strings.TrimSpace(buf.String()), "\n") {
if line == "" {
continue
}
var rec map[string]any
if err := json.Unmarshal([]byte(line), &rec); err != nil {
t.Fatalf("log line %q: %v", line, err)
}
if rec["msg"] == accessLogMessage {
out = append(out, rec)
}
}
return out
}
func TestAccessLogSmoke(t *testing.T) {
h, buf := accessLogRouter(t, true)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/blog/posts/7?token=secret", nil))
if rec.Code != http.StatusOK {
t.Fatalf("GET /blog/posts/7 = %d", rec.Code)
}
recs := accessRecords(t, buf)
if len(recs) != 1 {
t.Fatalf("records = %d, want 1:\n%s", len(recs), buf.String())
}
r := recs[0]
if r["method"] != "GET" || r["pattern"] != "GET /blog/posts/{id}" || r["status"] != float64(200) || r["level"] != "INFO" {
t.Fatalf("matched record = %v", r)
}
if _, ok := r["duration_ms"].(float64); !ok {
t.Fatalf("matched record has no duration_ms: %v", r)
}
if _, ok := r["path"]; ok {
t.Fatalf("matched record carries path: %v", r)
}
if strings.Contains(buf.String(), "secret") || strings.Contains(buf.String(), "/blog/posts/7") {
t.Fatalf("matched record leaks the raw path or query:\n%s", buf.String())
}
buf.Reset()
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/acme/missing?token=secret", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("GET /acme/missing = %d", rec.Code)
}
recs = accessRecords(t, buf)
if len(recs) != 1 {
t.Fatalf("records = %d, want 1:\n%s", len(recs), buf.String())
}
r = recs[0]
if r["pattern"] != "" || r["status"] != float64(404) {
t.Fatalf("unmatched record = %v", r)
}
if _, ok := r["path"]; ok {
t.Fatalf("unmatched record carries path: %v", r)
}
if strings.Contains(buf.String(), "secret") || strings.Contains(buf.String(), "/acme/missing") {
t.Fatalf("unmatched record leaks the path or query:\n%s", buf.String())
}
off, offBuf := accessLogRouter(t, false)
for _, target := range []string{"/blog/posts/7", "/acme/missing"} {
off.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, target, nil))
}
if offBuf.Len() != 0 {
t.Fatalf("http.access_log off still logged:\n%s", offBuf.String())
}
}
// A 5xx written after the client cancelled the request is logged as 499;
// a 5xx on a live request keeps its status.
func TestAccessLogClientClosedRequest(t *testing.T) {
for _, tc := range []struct {
name string
cancel bool
want float64
}{
{"cancelled", true, 499},
{"live", false, 500},
} {
t.Run(tc.name, func(t *testing.T) {
app := backpack.New(writeHTTPConfig(t, "access_log: true\n"))
var buf bytes.Buffer
if err := app.Publish(slog.New(slog.NewJSONHandler(&buf, nil))); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
h := accessLog(app, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if tc.cancel {
cancel()
}
w.WriteHeader(http.StatusInternalServerError)
}))
req := httptest.NewRequest(http.MethodGet, "/blog/posts/7", nil).WithContext(ctx)
h.ServeHTTP(httptest.NewRecorder(), req)
recs := accessRecords(t, &buf)
if len(recs) != 1 || recs[0]["status"] != tc.want {
t.Fatalf("records=%v, want one with status %v", recs, tc.want)
}
})
}
}
// blogEdgesPlugin serves the routes TestAccessLog drives: a 200 that
// flushes through http.ResponseController, a handler 404 and a panic.
type blogEdgesPlugin struct{ flushErr *error }
func (blogEdgesPlugin) ID() string { return "acme.blog" }
func (blogEdgesPlugin) Requires() []string { return nil }
func (blogEdgesPlugin) Register(*backpack.App) error { return nil }
func (blogEdgesPlugin) Boot(*backpack.App) error { return nil }
func (p blogEdgesPlugin) Routes(r pact.Router) error {
r.Group("/blog", nil, func(g pact.Router) {
g.Get("/posts/{id}", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":7}`))
*p.flushErr = http.NewResponseController(w).Flush()
})
g.Get("/drafts/{id}", func(w http.ResponseWriter, _ *http.Request) {
http.NotFound(w, nil)
})
g.Get("/broken", func(http.ResponseWriter, *http.Request) {
panic("blog: broken")
})
})
return nil
}
// accessLogEdgesRouter assembles blogEdgesPlugin with http.access_log on
// or off, a JSON logger into the returned buffer and, when locale is set,
// a resolver that strips /pl and /en path prefixes.
func accessLogEdgesRouter(t *testing.T, on, locale bool, flushErr *error) (http.Handler, *bytes.Buffer) {
t.Helper()
y := "body_limits:\n default_bytes: 1024\n upload_bytes: 1024\n"
if on {
y += "access_log: true\n"
}
app := backpack.New(writeHTTPConfig(t, y))
var buf bytes.Buffer
if err := app.Publish(slog.New(slog.NewJSONHandler(&buf, nil))); err != nil {
t.Fatal(err)
}
if locale {
if err := app.Publish[LocaleResolver](pathLocaleResolver{}); err != nil {
t.Fatal(err)
}
}
h, err := Assemble(app, []party.Plugin{blogEdgesPlugin{flushErr: flushErr}})
if err != nil {
t.Fatal(err)
}
return h, &buf
}
// accessRawRecords decodes the "http request" records keeping numbers as
// written, so an integer field can be told from a float.
func accessRawRecords(t *testing.T, buf *bytes.Buffer) []map[string]any {
t.Helper()
var out []map[string]any
for _, line := range strings.Split(strings.TrimSpace(buf.String()), "\n") {
if line == "" {
continue
}
dec := json.NewDecoder(strings.NewReader(line))
dec.UseNumber()
var rec map[string]any
if err := dec.Decode(&rec); err != nil {
t.Fatalf("log line %q: %v", line, err)
}
if rec["msg"] == accessLogMessage {
out = append(out, rec)
}
}
return out
}
// TestAccessLog: off, nothing is logged. On, one record per request with
// the matched pattern and status for a 200, a handler 404 and a recovered
// panic (500); an unmatched request (a 404, a 405 for a route reached with
// another method, a trailing-slash 404) gets an empty pattern and no path;
// a locale-prefixed request still records the route pattern;
// duration_ms is a non-negative integer; no record carries the query
// string, a header name or value or the Authorization value; and a
// handler's http.ResponseController flush keeps working.
func TestAccessLog(t *testing.T) {
type want struct {
pattern string
status int64
}
cases := []struct {
target string
code int
want want
}{
{"/blog/posts/7?token=query-secret", http.StatusOK, want{"GET /blog/posts/{id}", 200}},
{"/blog/drafts/9?token=query-secret", http.StatusNotFound, want{"GET /blog/drafts/{id}", 404}},
{"/blog/broken?token=query-secret", http.StatusInternalServerError, want{"GET /blog/broken", 500}},
{"/acme/missing?token=query-secret", http.StatusNotFound, want{"", 404}},
{"/pl/blog/posts/8?token=query-secret", http.StatusOK, want{"GET /blog/posts/{id}", 200}},
// A token route reached with another method (405) or a trailing
// slash (404) matches no route: ServeMux answers before any route
// handler runs, so the path segment must not reach the log.
{"POST /blog/posts/path-secret-405", http.StatusMethodNotAllowed, want{"", 405}},
{"/blog/posts/path-secret-404/", http.StatusNotFound, want{"", 404}},
}
request := func(target string) *http.Request {
method := http.MethodGet
if m, rest, ok := strings.Cut(target, " "); ok {
method, target = m, rest
}
req := httptest.NewRequest(method, target, nil)
req.Header.Set("Authorization", "Bearer authz-secret-value")
req.Header.Set("X-Blog-Probe", "probe-header-value")
req.Header.Set("Cookie", "session=cookie-secret-value")
return req
}
var flushErr error
off, offBuf := accessLogEdgesRouter(t, false, true, &flushErr)
for _, tc := range cases {
off.ServeHTTP(httptest.NewRecorder(), request(tc.target))
}
if offBuf.Len() != 0 {
t.Fatalf("http.access_log off still logged:\n%s", offBuf.String())
}
h, buf := accessLogEdgesRouter(t, true, true, &flushErr)
for _, tc := range cases {
buf.Reset()
flushErr = context.Canceled
rec := httptest.NewRecorder()
req := request(tc.target)
h.ServeHTTP(rec, req)
if rec.Code != tc.code {
t.Fatalf("GET %s = %d, want %d", tc.target, rec.Code, tc.code)
}
if tc.want.status == 200 && flushErr != nil {
t.Fatalf("GET %s: http.ResponseController flush = %v", tc.target, flushErr)
}
recs := accessRawRecords(t, buf)
if len(recs) != 1 {
t.Fatalf("GET %s: records = %d, want 1:\n%s", tc.target, len(recs), buf.String())
}
r := recs[0]
status, err := r["status"].(json.Number).Int64()
if err != nil || r["method"] != req.Method || r["pattern"] != tc.want.pattern || status != tc.want.status || r["level"] != "INFO" {
t.Fatalf("GET %s: record %v, want pattern %q status %d", tc.target, r, tc.want.pattern, tc.want.status)
}
d, ok := r["duration_ms"].(json.Number)
if !ok {
t.Fatalf("GET %s: duration_ms missing: %v", tc.target, r)
}
if n, err := strconv.ParseInt(d.String(), 10, 64); err != nil || n < 0 {
t.Fatalf("GET %s: duration_ms %q is not a non-negative integer", tc.target, d)
}
if _, hasPath := r["path"]; hasPath {
t.Fatalf("GET %s: the record carries path: %v", tc.target, r)
}
for _, leak := range []string{
"query-secret", "token=", "authz-secret-value", "Authorization", "Bearer",
"X-Blog-Probe", "probe-header-value", "cookie-secret-value", "Cookie",
"/blog/posts/7", "/blog/posts/8", "/pl/", "path-secret", "/acme/missing",
} {
if strings.Contains(buf.String(), leak) {
t.Fatalf("GET %s: the log carries %q:\n%s", tc.target, leak, buf.String())
}
}
}
// Flush passes through the recorder to the client's writer.
app := backpack.New(writeHTTPConfig(t, "access_log: true\n"))
var direct bytes.Buffer
if err := app.Publish(slog.New(slog.NewJSONHandler(&direct, nil))); err != nil {
t.Fatal(err)
}
var directErr error
rec := httptest.NewRecorder()
accessLog(app, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("chunk"))
directErr = http.NewResponseController(w).Flush()
})).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/blog/stream", nil))
if directErr != nil || !rec.Flushed {
t.Fatalf("flush through the access log: err=%v flushed=%t", directErr, rec.Flushed)
}
if recs := accessRecords(t, &direct); len(recs) != 1 || recs[0]["status"] != float64(200) {
t.Fatalf("direct records = %v", recs)
}
}