init
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user