package httpx import ( "bytes" "encoding/json" "fmt" "log/slog" "net/http" "net/http/httptest" "net/netip" "strings" "testing" "github.com/iceBear67/simplepages/api" ) func prefixes(t *testing.T, ss ...string) []netip.Prefix { t.Helper() out := make([]netip.Prefix, 0, len(ss)) for _, s := range ss { p, err := netip.ParsePrefix(s) if err != nil { t.Fatalf("ParsePrefix(%q): %v", s, err) } out = append(out, p) } return out } // TestLogNeverContainsCredentials is the load-bearing test of this package: // an access log that leaks a bearer token turns every log shipper, backup and // support ticket into a credential store. func TestLogNeverContainsCredentials(t *testing.T) { const token = "pgs_k7m2q4x9v0zt3b8w_S3cr3tVa1ueThatMustNeverBeLogged00000000" var buf bytes.Buffer log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) h := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // A handler that legitimately annotates the log must still not be able to // smuggle the secret in: it logs the key id, which is public. LogAttr(r.Context(), "key_id", "k7m2q4x9v0zt3b8w") LogAttr(r.Context(), "project", "demo") w.WriteHeader(http.StatusNoContent) }), WithRequestID(nil), AccessLog(log, nil), Recover(log)) r := httptest.NewRequest(http.MethodGet, "/api/v1/whoami?access_token="+token, nil) r.Header.Set("Authorization", "Bearer "+token) r.Header.Set("Cookie", "session="+token) h.ServeHTTP(httptest.NewRecorder(), r) out := buf.String() if out == "" { t.Fatal("no log output produced") } for _, needle := range []string{token, "S3cr3tVa1ue", "Bearer", "Authorization", "session="} { if strings.Contains(out, needle) { t.Errorf("log contains %q\nlog: %s", needle, out) } } for _, want := range []string{`"key_id":"k7m2q4x9v0zt3b8w"`, `"project":"demo"`, `"status":204`} { if !strings.Contains(out, want) { t.Errorf("log missing %s\nlog: %s", want, out) } } // The raw query string is not logged either: tokens end up there when someone // ignores the docs, and we would rather drop the field than log the secret. if strings.Contains(out, "access_token") { t.Errorf("log contains the query string\nlog: %s", out) } } func TestAccessLogRecordsStatusAndBytes(t *testing.T) { var buf bytes.Buffer log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) h := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTeapot) fmt.Fprint(w, "hello") }), AccessLog(log, nil)) h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/x", nil)) var rec map[string]any if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &rec); err != nil { t.Fatalf("log line is not JSON: %v (%s)", err, buf.String()) } if got := rec["status"]; got != float64(http.StatusTeapot) { t.Errorf("status = %v, want 418", got) } if got := rec["bytes"]; got != float64(5) { t.Errorf("bytes = %v, want 5", got) } if rec["level"] != "WARN" { t.Errorf("level = %v, want WARN for a 4xx", rec["level"]) } } func TestRecoverReturnsOpaque500(t *testing.T) { var buf bytes.Buffer log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) h := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { panic("database password is hunter2") }), WithRequestID(nil), AccessLog(log, nil), Recover(log)) w := httptest.NewRecorder() h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/boom", nil)) if w.Code != http.StatusInternalServerError { t.Fatalf("status = %d, want 500", w.Code) } // The panicking request must still produce an access-log line, which is only // true while Recover runs inside AccessLog. if !strings.Contains(buf.String(), `"msg":"request"`) { t.Errorf("no access-log line for the panicking request\nlog: %s", buf.String()) } if strings.Contains(w.Body.String(), "hunter2") { t.Errorf("panic value leaked to the client: %s", w.Body.String()) } var env api.ErrorEnvelope if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { t.Fatalf("body is not an error envelope: %v (%s)", err, w.Body.String()) } if env.Error.Code != api.CodeInternal { t.Errorf("code = %q, want %q", env.Error.Code, api.CodeInternal) } if env.Error.Details["request_id"] == nil { t.Error("500 body carries no request_id, so the log line cannot be found") } if !strings.Contains(buf.String(), "hunter2") { t.Error("panic value was not logged; it must reach the operator even though it must not reach the client") } } func TestClientIP(t *testing.T) { trusted := prefixes(t, "127.0.0.1/32", "10.0.0.0/8") cases := []struct { name string remote string xff []string want string }{ {"untrusted peer, header ignored", "203.0.113.9:1234", []string{"9.9.9.9"}, "203.0.113.9"}, {"trusted peer, single hop", "127.0.0.1:1234", []string{"198.51.100.7"}, "198.51.100.7"}, {"trusted peer, chain", "10.1.2.3:1234", []string{"198.51.100.7, 10.4.5.6"}, "198.51.100.7"}, {"forged prefix ignored", "127.0.0.1:1234", []string{"1.2.3.4, 198.51.100.7"}, "198.51.100.7"}, {"multiple headers", "127.0.0.1:1234", []string{"1.1.1.1", "198.51.100.7"}, "198.51.100.7"}, {"garbage entries skipped", "127.0.0.1:1234", []string{"not-an-ip, 198.51.100.7, 10.0.0.1"}, "198.51.100.7"}, {"all trusted, falls back to peer", "127.0.0.1:1234", []string{"10.0.0.1"}, "127.0.0.1"}, {"no header", "127.0.0.1:1234", nil, "127.0.0.1"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { r := httptest.NewRequest(http.MethodGet, "/", nil) r.RemoteAddr = tc.remote for _, v := range tc.xff { r.Header.Add("X-Forwarded-For", v) } got, ok := ClientIP(r, trusted) if !ok { t.Fatal("ClientIP reported no address") } if got.String() != tc.want { t.Errorf("ClientIP = %s, want %s", got, tc.want) } }) } } func TestRequestIDAdoption(t *testing.T) { trusted := prefixes(t, "127.0.0.1/32") cases := []struct { name string remote string header string wantSet bool // true = the inbound value is echoed back verbatim }{ {"trusted peer, sane id", "127.0.0.1:1", "deadbeef-42", true}, {"untrusted peer", "203.0.113.9:1", "deadbeef-42", false}, {"trusted peer, space injected", "127.0.0.1:1", "id with space", false}, {"trusted peer, newline injected", "127.0.0.1:1", "id\nlevel=INFO", false}, {"trusted peer, over-long", "127.0.0.1:1", strings.Repeat("a", 65), false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { var seen string h := WithRequestID(trusted)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seen = RequestIDFrom(r.Context()) })) r := httptest.NewRequest(http.MethodGet, "/", nil) r.RemoteAddr = tc.remote r.Header.Set("X-Request-Id", tc.header) w := httptest.NewRecorder() h.ServeHTTP(w, r) if tc.wantSet { if seen != tc.header { t.Errorf("request id = %q, want the inbound %q", seen, tc.header) } } else if seen == tc.header { t.Errorf("adopted an untrustworthy inbound id %q", tc.header) } if seen == "" { t.Error("no request id assigned") } if got := w.Header().Get("X-Request-Id"); got != seen { t.Errorf("echoed %q but handler saw %q", got, seen) } }) } } func TestWriteErrorMapsCodes(t *testing.T) { cases := []struct { err error want int }{ {api.Errorf(api.CodeNotFound, "nope"), http.StatusNotFound}, {api.Errorf(api.CodeDeploymentActive, "still active"), http.StatusConflict}, {api.Errorf(api.CodeInvalidPath, "bad"), http.StatusBadRequest}, {api.Errorf(api.CodeLimitExceeded, "too big"), http.StatusRequestEntityTooLarge}, {fmt.Errorf("wrapped: %w", api.Errorf(api.CodeForbidden, "no")), http.StatusForbidden}, {fmt.Errorf("open /var/lib/pages-server/secret: permission denied"), http.StatusInternalServerError}, } log := slog.New(slog.NewJSONHandler(&bytes.Buffer{}, nil)) for _, tc := range cases { w := httptest.NewRecorder() WriteError(w, httptest.NewRequest(http.MethodGet, "/", nil), log, tc.err) if w.Code != tc.want { t.Errorf("WriteError(%v) = %d, want %d", tc.err, w.Code, tc.want) } if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { t.Errorf("Content-Type = %q", ct) } } } // A non-api error must not put internal detail in the response body. func TestWriteErrorHidesInternalDetail(t *testing.T) { log := slog.New(slog.NewJSONHandler(&bytes.Buffer{}, nil)) w := httptest.NewRecorder() WriteError(w, httptest.NewRequest(http.MethodGet, "/", nil), log, fmt.Errorf("sql: no rows in /var/lib/pages-server/pages.db")) if strings.Contains(w.Body.String(), "pages.db") { t.Errorf("internal detail leaked: %s", w.Body.String()) } } func TestDecodeJSON(t *testing.T) { type payload struct { Name string `json:"name"` } cases := []struct { name string body string max int64 want api.Code }{ {"ok", `{"name":"demo"}`, 1024, ""}, {"unknown field", `{"name":"demo","nmae":"typo"}`, 1024, api.CodeBadRequest}, {"wrong type", `{"name":42}`, 1024, api.CodeBadRequest}, {"malformed", `{"name":`, 1024, api.CodeBadRequest}, {"trailing content", `{"name":"a"} {"name":"b"}`, 1024, api.CodeBadRequest}, {"too large", `{"name":"` + strings.Repeat("x", 200) + `"}`, 32, api.CodePayloadTooLarge}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(tc.body)) var v payload err := DecodeJSON(httptest.NewRecorder(), r, tc.max, &v) if tc.want == "" { if err != nil { t.Fatalf("unexpected error: %v", err) } return } if err == nil { t.Fatal("expected an error") } if got := api.CodeOf(err); got != tc.want { t.Errorf("code = %q, want %q (%v)", got, tc.want, err) } }) } }