288 lines
9.7 KiB
Go
288 lines
9.7 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|