fix: harden credential and input handling
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,680 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/andydixon/relata/internal/config"
|
||||
"github.com/andydixon/relata/internal/domain"
|
||||
"github.com/andydixon/relata/internal/observability"
|
||||
)
|
||||
|
||||
// --- JSON decoding ---------------------------------------------------------
|
||||
|
||||
type testBody struct {
|
||||
Title string `json:"title"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
func postJSON(body string) (*httptest.ResponseRecorder, *http.Request) {
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/v1/items", strings.NewReader(body))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
return httptest.NewRecorder(), r
|
||||
}
|
||||
|
||||
func TestDecodeJSONRejectsUnknownFields(t *testing.T) {
|
||||
w, r := postJSON(`{"title":"a","count":1,"is_admin":true}`)
|
||||
|
||||
var dst testBody
|
||||
err := DecodeJSON(w, r, 1<<20, &dst)
|
||||
if err == nil {
|
||||
t.Fatal("an unknown field was silently ignored")
|
||||
}
|
||||
var apiErr *APIError
|
||||
if !asAPIError(err, &apiErr) {
|
||||
t.Fatalf("wrong error type: %T", err)
|
||||
}
|
||||
if apiErr.Status != http.StatusBadRequest || apiErr.Code != CodeValidationFailed {
|
||||
t.Fatalf("got %d/%s, want 400/%s", apiErr.Status, apiErr.Code, CodeValidationFailed)
|
||||
}
|
||||
if len(apiErr.Fields) == 0 || apiErr.Fields[0].Field != "is_admin" {
|
||||
t.Fatalf("the offending field was not identified: %+v", apiErr.Fields)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeJSONRejectsMalformedInput(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"truncated": `{"title":"a"`,
|
||||
"not json": `title=a`,
|
||||
"array": `[1,2,3]`,
|
||||
"empty": ``,
|
||||
"two documents": `{"title":"a"}{"title":"b"}`,
|
||||
}
|
||||
for name, body := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
w, r := postJSON(body)
|
||||
var dst testBody
|
||||
if err := DecodeJSON(w, r, 1<<20, &dst); err == nil {
|
||||
t.Fatalf("malformed body %q was accepted", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeJSONEnforcesSizeLimit(t *testing.T) {
|
||||
big := `{"title":"` + strings.Repeat("x", 5000) + `"}`
|
||||
w, r := postJSON(big)
|
||||
|
||||
var dst testBody
|
||||
err := DecodeJSON(w, r, 1024, &dst)
|
||||
var apiErr *APIError
|
||||
if !asAPIError(err, &apiErr) || apiErr.Status != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("oversized body not rejected with 413: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeJSONRequiresJSONContentType(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/v1/items", strings.NewReader(`{"title":"a"}`))
|
||||
r.Header.Set("Content-Type", "text/html")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
var dst testBody
|
||||
err := DecodeJSON(w, r, 1<<20, &dst)
|
||||
var apiErr *APIError
|
||||
if !asAPIError(err, &apiErr) || apiErr.Status != http.StatusUnsupportedMediaType {
|
||||
t.Fatalf("non-JSON content type accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func asAPIError(err error, target **APIError) bool {
|
||||
for err != nil {
|
||||
if e, ok := err.(*APIError); ok {
|
||||
*target = e
|
||||
return true
|
||||
}
|
||||
u, ok := err.(interface{ Unwrap() error })
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
err = u.Unwrap()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// --- Error responses -------------------------------------------------------
|
||||
|
||||
func TestErrorResponsesNeverLeakInternalDetail(t *testing.T) {
|
||||
// An internal error must not surface the underlying message, which could
|
||||
// contain a connection string, a file path or schema detail.
|
||||
internal := Errorf(http.StatusInternalServerError, CodeInternal, "An unexpected error occurred.")
|
||||
internal.Internal = errNamed("mongodb://user:hunter2@db:27017 connection refused: /var/lib/secret")
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/v1/items", nil)
|
||||
w := httptest.NewRecorder()
|
||||
WriteError(w, r, internal)
|
||||
|
||||
body := w.Body.String()
|
||||
for _, forbidden := range []string{"hunter2", "mongodb://", "/var/lib/secret", "goroutine"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("the response leaked internal detail %q:\n%s", forbidden, body)
|
||||
}
|
||||
}
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorResponsesUseStableCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
err error
|
||||
wantStatus int
|
||||
wantCode string
|
||||
}{
|
||||
{domain.ErrNotFound, http.StatusNotFound, CodeNotFound},
|
||||
{domain.ErrForbidden, http.StatusForbidden, CodeForbidden},
|
||||
{domain.ErrDuplicate, http.StatusConflict, CodeDuplicate},
|
||||
{domain.ErrTooLarge, http.StatusRequestEntityTooLarge, CodePayloadTooLarge},
|
||||
{domain.ErrUnsupported, http.StatusUnsupportedMediaType, CodeUnsupportedMedia},
|
||||
// A cross-workspace reference must look identical to a missing record,
|
||||
// so that probing cannot confirm an ID exists elsewhere.
|
||||
{domain.ErrWorkspaceMiss, http.StatusNotFound, CodeNotFound},
|
||||
}
|
||||
for _, c := range cases {
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/v1/items/x", nil)
|
||||
w := httptest.NewRecorder()
|
||||
WriteError(w, r, c.err)
|
||||
|
||||
if w.Code != c.wantStatus {
|
||||
t.Errorf("%v → status %d, want %d", c.err, w.Code, c.wantStatus)
|
||||
}
|
||||
var resp ErrorResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("response is not valid JSON: %v", err)
|
||||
}
|
||||
if resp.Error.Code != c.wantCode {
|
||||
t.Errorf("%v → code %q, want %q", c.err, resp.Error.Code, c.wantCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceMismatchIsIndistinguishableFromNotFound(t *testing.T) {
|
||||
render := func(err error) string {
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/v1/items/x", nil)
|
||||
w := httptest.NewRecorder()
|
||||
WriteError(w, r, err)
|
||||
return w.Body.String()
|
||||
}
|
||||
if render(domain.ErrNotFound) != render(domain.ErrWorkspaceMiss) {
|
||||
t.Fatal("a cross-workspace access is distinguishable from a missing record, " +
|
||||
"which lets an attacker confirm that an ID exists in another workspace")
|
||||
}
|
||||
}
|
||||
|
||||
type namedErr string
|
||||
|
||||
func (e namedErr) Error() string { return string(e) }
|
||||
func errNamed(s string) error { return namedErr(s) }
|
||||
|
||||
// --- Security headers ------------------------------------------------------
|
||||
|
||||
func TestSecurityHeadersAreRestrictive(t *testing.T) {
|
||||
h := SecurityHeaders(false)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
|
||||
csp := w.Header().Get("Content-Security-Policy")
|
||||
for _, want := range []string{
|
||||
"default-src 'none'",
|
||||
"script-src 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'none'",
|
||||
"form-action 'self'",
|
||||
} {
|
||||
if !strings.Contains(csp, want) {
|
||||
t.Errorf("CSP is missing %q: %s", want, csp)
|
||||
}
|
||||
}
|
||||
// Inline script and remote origins must not be permitted: they are how a
|
||||
// stored payload would execute or exfiltrate.
|
||||
for _, forbidden := range []string{"unsafe-inline", "unsafe-eval", "*", "http:"} {
|
||||
if strings.Contains(csp, forbidden) {
|
||||
t.Errorf("CSP permits %q: %s", forbidden, csp)
|
||||
}
|
||||
}
|
||||
|
||||
checks := map[string]string{
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"Referrer-Policy": "same-origin",
|
||||
"Cache-Control": "no-store",
|
||||
}
|
||||
for header, want := range checks {
|
||||
if got := w.Header().Get(header); got != want {
|
||||
t.Errorf("%s = %q, want %q", header, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Request IDs -----------------------------------------------------------
|
||||
|
||||
func TestRequestIDRejectsClientSuppliedGarbage(t *testing.T) {
|
||||
h := RequestID()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
|
||||
// A crafted value must not end up in log fields or the response header.
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Header.Set("X-Request-ID", "not-a-uuid\ninjected=evil")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
|
||||
got := w.Header().Get("X-Request-ID")
|
||||
if strings.Contains(got, "injected") || strings.Contains(got, "\n") {
|
||||
t.Fatalf("a crafted request ID was echoed back: %q", got)
|
||||
}
|
||||
if len(got) != 36 {
|
||||
t.Fatalf("no valid request ID was generated: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestIDAcceptsAValidUUID(t *testing.T) {
|
||||
const valid = "3f2504e0-4f89-41d3-9a0c-0305e82c3301"
|
||||
h := RequestID()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Header.Set("X-Request-ID", valid)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
|
||||
if got := w.Header().Get("X-Request-ID"); got != valid {
|
||||
t.Fatalf("a valid client request ID was discarded: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Body limits -----------------------------------------------------------
|
||||
|
||||
func TestMaxBodyRejectsOversizedRequests(t *testing.T) {
|
||||
h := MaxBody(100)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(strings.Repeat("x", 500)))
|
||||
r.ContentLength = 500
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
|
||||
if w.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("status = %d, want 413", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- CSRF ------------------------------------------------------------------
|
||||
|
||||
func csrfHandler(session *domain.Session) http.Handler {
|
||||
c := CSRF{TrustedOrigins: []string{"http://127.0.0.1:8080"}}
|
||||
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if session != nil {
|
||||
r = r.WithContext(withSession(r.Context(), session))
|
||||
}
|
||||
c.Middleware()(inner).ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCSRFAllowsSafeMethods(t *testing.T) {
|
||||
sess := &domain.Session{CSRFToken: "the-token"}
|
||||
h := csrfHandler(sess)
|
||||
|
||||
for _, method := range []string{http.MethodGet, http.MethodHead, http.MethodOptions} {
|
||||
r := httptest.NewRequest(method, "/items", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("%s was refused: %d", method, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFBlocksStateChangeWithoutToken(t *testing.T) {
|
||||
sess := &domain.Session{CSRFToken: "the-token"}
|
||||
h := csrfHandler(sess)
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/items", nil)
|
||||
r.Header.Set("Origin", "http://127.0.0.1:8080")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("a POST without a CSRF token returned %d, want 403", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFBlocksWrongToken(t *testing.T) {
|
||||
sess := &domain.Session{CSRFToken: "the-token"}
|
||||
h := csrfHandler(sess)
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/items", nil)
|
||||
r.Header.Set("Origin", "http://127.0.0.1:8080")
|
||||
r.Header.Set("X-CSRF-Token", "the-wrong-token")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("a POST with the wrong CSRF token returned %d, want 403", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFBlocksForeignOrigin(t *testing.T) {
|
||||
sess := &domain.Session{CSRFToken: "the-token"}
|
||||
h := csrfHandler(sess)
|
||||
|
||||
// Even with the correct token, a request claiming another origin is refused.
|
||||
r := httptest.NewRequest(http.MethodPost, "/items", nil)
|
||||
r.Header.Set("Origin", "https://evil.example.com")
|
||||
r.Header.Set("X-CSRF-Token", "the-token")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("a cross-origin POST returned %d, want 403", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFAcceptsCorrectTokenAndOrigin(t *testing.T) {
|
||||
sess := &domain.Session{CSRFToken: "the-token"}
|
||||
h := csrfHandler(sess)
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/items", nil)
|
||||
r.Header.Set("Origin", "http://127.0.0.1:8080")
|
||||
r.Header.Set("X-CSRF-Token", "the-token")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("a valid request returned %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFRequiresASession(t *testing.T) {
|
||||
h := csrfHandler(nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/items", nil)
|
||||
r.Header.Set("Origin", "http://127.0.0.1:8080")
|
||||
r.Header.Set("X-CSRF-Token", "anything")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("an unauthenticated POST returned %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFDerivesOriginFromReferer(t *testing.T) {
|
||||
sess := &domain.Session{CSRFToken: "the-token"}
|
||||
h := csrfHandler(sess)
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/items", nil)
|
||||
r.Header.Set("Referer", "https://evil.example.com/some/page?x=1")
|
||||
r.Header.Set("X-CSRF-Token", "the-token")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("a foreign Referer returned %d, want 403", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Authorisation ---------------------------------------------------------
|
||||
|
||||
func TestRequireWriteRejectsViewers(t *testing.T) {
|
||||
h := RequireWrite()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
cases := map[domain.Role]int{
|
||||
domain.RoleViewer: http.StatusForbidden,
|
||||
domain.RoleEditor: http.StatusOK,
|
||||
domain.RoleAdmin: http.StatusOK,
|
||||
}
|
||||
for role, want := range cases {
|
||||
r := httptest.NewRequest(http.MethodPost, "/items", nil)
|
||||
r = r.WithContext(WithPrincipal(r.Context(), &domain.Principal{
|
||||
UserID: "u", WorkspaceID: "ws", Role: role,
|
||||
}))
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != want {
|
||||
t.Errorf("role %s got %d, want %d", role, w.Code, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireWriteRejectsAnonymous(t *testing.T) {
|
||||
h := RequireWrite()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
r := httptest.NewRequest(http.MethodPost, "/items", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("an anonymous write returned %d, want 403", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Panic recovery --------------------------------------------------------
|
||||
|
||||
func TestRecoverConvertsPanicToCleanError(t *testing.T) {
|
||||
var logs bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&logs, nil))
|
||||
h := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
panic("a secret value hunter2 appeared in the panic")
|
||||
}), RequestID(), Recover())
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/v1/items", nil)
|
||||
r = r.WithContext(observability.WithLogger(r.Context(), logger))
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", w.Code)
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "hunter2") {
|
||||
t.Fatalf("the panic value reached the client:\n%s", w.Body.String())
|
||||
}
|
||||
if strings.Contains(logs.String(), "hunter2") {
|
||||
t.Fatalf("the panic value reached the logs:\n%s", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchQueryRejectsNegativeOffsetAsZero(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/v1/search?q=test&offset=-7", nil)
|
||||
q, err := searchQueryFromRequest(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if q.Offset != 0 {
|
||||
t.Fatalf("offset = %d, want 0; a negative value can panic slice paging", q.Offset)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Pagination ------------------------------------------------------------
|
||||
|
||||
func TestPaginationIsBounded(t *testing.T) {
|
||||
cases := []struct {
|
||||
query string
|
||||
wantLimit int
|
||||
wantOffsetAtLst int
|
||||
}{
|
||||
{"", 20, 0},
|
||||
{"?limit=5&offset=10", 5, 10},
|
||||
{"?limit=99999", 200, 0}, // clamped to the maximum
|
||||
{"?limit=-1", 20, 0}, // falls back to the default
|
||||
{"?offset=-5", 20, 0}, // negative offsets are clamped
|
||||
{"?limit=abc", 20, 0}, // unparseable values fall back
|
||||
}
|
||||
for _, c := range cases {
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/v1/items"+c.query, nil)
|
||||
p := pageFromQuery(r)
|
||||
if p.Limit != c.wantLimit {
|
||||
t.Errorf("%q → limit %d, want %d", c.query, p.Limit, c.wantLimit)
|
||||
}
|
||||
if p.Offset != c.wantOffsetAtLst {
|
||||
t.Errorf("%q → offset %d, want %d", c.query, p.Offset, c.wantOffsetAtLst)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Query filter validation ----------------------------------------------
|
||||
|
||||
// A filter value that MongoDB would treat as an operator must be rejected as
|
||||
// an invalid enum long before it reaches a query.
|
||||
func TestFiltersRejectOperatorShapedValues(t *testing.T) {
|
||||
hostile := []string{
|
||||
`$ne`, `{"$ne":null}`, `$where`, `$gt`, `[$ne]`, `{"$regex":".*"}`,
|
||||
}
|
||||
for _, v := range hostile {
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/v1/items?source_type="+v, nil)
|
||||
if _, err := itemFilterFromQuery(r); err == nil {
|
||||
t.Errorf("source_type=%q was accepted", v)
|
||||
}
|
||||
|
||||
r = httptest.NewRequest(http.MethodGet, "/api/v1/items?type="+v, nil)
|
||||
if _, err := relFilterFromQuery(r); err == nil {
|
||||
t.Errorf("relationship type=%q was accepted", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFiltersAcceptValidValues(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet,
|
||||
"/api/v1/items?source_type=note,document&status=ready&created_from=2025-01-01", nil)
|
||||
f, err := itemFilterFromQuery(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(f.SourceTypes) != 2 {
|
||||
t.Fatalf("expected 2 source types, got %d", len(f.SourceTypes))
|
||||
}
|
||||
if f.CreatedFrom == nil || f.CreatedFrom.Year() != 2025 {
|
||||
t.Fatal("created_from was not parsed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentHashFilterRejectsNonHex(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/v1/items?content_hash=../../etc/passwd", nil)
|
||||
if _, err := itemFilterFromQuery(r); err == nil {
|
||||
t.Fatal("a non-hexadecimal content hash was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntityIDFiltersMustBeUUIDs(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet,
|
||||
"/api/v1/search?entity_id=not-a-uuid,3f2504e0-4f89-41d3-9a0c-0305e82c3301", nil)
|
||||
q, err := searchQueryFromRequest(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(q.EntityIDs) != 1 {
|
||||
t.Fatalf("expected only the valid UUID to survive, got %v", q.EntityIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchQueryLengthIsBounded(t *testing.T) {
|
||||
long := strings.Repeat("a", 3000)
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/v1/search?q="+long, nil)
|
||||
if _, err := searchQueryFromRequest(r); err == nil {
|
||||
t.Fatal("an unbounded query string was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Trusted origins -------------------------------------------------------
|
||||
|
||||
func TestTrustedOriginsIncludeBaseURLAndListenAddress(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.HTTP.BaseURL = "https://relata.example.org"
|
||||
cfg.HTTP.Addr = "127.0.0.1:8080"
|
||||
|
||||
origins := trustedOrigins(cfg)
|
||||
want := []string{"https://relata.example.org", "http://127.0.0.1:8080", "http://localhost:8080"}
|
||||
for _, w := range want {
|
||||
if !containsStr(origins, w) {
|
||||
t.Errorf("origin %q is missing from %v", w, origins)
|
||||
}
|
||||
}
|
||||
// Nothing else may be trusted.
|
||||
for _, o := range origins {
|
||||
if strings.Contains(o, "evil") || o == "*" {
|
||||
t.Errorf("unexpected trusted origin %q", o)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A provider that ran out of time must be reported as a dependency timeout.
|
||||
// It previously surfaced as an opaque internal error, leaving a user with no
|
||||
// indication that the fix is a longer timeout or a smaller model.
|
||||
func TestProviderTimeoutIsReportedAsAGatewayTimeout(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/v1/search/answer", nil)
|
||||
w := httptest.NewRecorder()
|
||||
WriteError(w, r, fmt.Errorf("call the model: %w", context.DeadlineExceeded))
|
||||
|
||||
if w.Code != http.StatusGatewayTimeout {
|
||||
t.Fatalf("status = %d, want 504", w.Code)
|
||||
}
|
||||
var resp ErrorResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Error.Code != CodeDependencyTimeout {
|
||||
t.Fatalf("code = %q, want %q", resp.Error.Code, CodeDependencyTimeout)
|
||||
}
|
||||
// The message must tell the user what to do about it.
|
||||
if !strings.Contains(resp.Error.Message, "RELATA_ANALYSIS_TIMEOUT") {
|
||||
t.Fatalf("the message gives no remedy: %q", resp.Error.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelledRequestIsNotAnInternalError(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/v1/search", nil)
|
||||
w := httptest.NewRecorder()
|
||||
WriteError(w, r, fmt.Errorf("search: %w", context.Canceled))
|
||||
|
||||
if w.Code == http.StatusInternalServerError {
|
||||
t.Fatal("a client disconnect was recorded as an internal error")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Fuzzing ---------------------------------------------------------------
|
||||
|
||||
// Request decoding is the outermost trust boundary for the JSON API.
|
||||
func FuzzDecodeJSON(f *testing.F) {
|
||||
f.Add(`{"title":"a","count":1}`)
|
||||
f.Add(`{"title":"a","unknown":true}`)
|
||||
f.Add(`{"a":1}{"b":2}`)
|
||||
f.Add("\x00\xff")
|
||||
f.Add(`{"title":` + strings.Repeat("[", 200))
|
||||
|
||||
f.Fuzz(func(t *testing.T, body string) {
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/v1/items", strings.NewReader(body))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
var dst testBody
|
||||
err := DecodeJSON(w, r, 1<<16, &dst)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
// Every rejection must be a typed API error with a client-safe status,
|
||||
// never a bare error that would become an opaque 500.
|
||||
var apiErr *APIError
|
||||
if !asAPIError(err, &apiErr) {
|
||||
t.Fatalf("decoding %q produced an untyped error: %v", body, err)
|
||||
}
|
||||
if apiErr.Status < 400 || apiErr.Status >= 500 {
|
||||
t.Fatalf("decoding %q produced status %d; malformed input is a client error",
|
||||
body, apiErr.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Query parameters are attacker-controlled and become database filter values.
|
||||
func FuzzQueryFilters(f *testing.F) {
|
||||
f.Add("note", "ready", "2025-01-01", "abc123")
|
||||
f.Add(`{"$ne":null}`, "$where", "not-a-date", "../etc")
|
||||
|
||||
f.Fuzz(func(t *testing.T, sourceType, status, from, hash string) {
|
||||
q := url.Values{}
|
||||
q.Set("source_type", sourceType)
|
||||
q.Set("status", status)
|
||||
q.Set("created_from", from)
|
||||
q.Set("content_hash", hash)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/v1/items?"+q.Encode(), nil)
|
||||
filter, err := itemFilterFromQuery(r)
|
||||
if err != nil {
|
||||
return // rejected, which is the safe outcome
|
||||
}
|
||||
// Anything accepted must be a value from a closed set, never arbitrary
|
||||
// text that could reach a query as an operator.
|
||||
for _, s := range filter.SourceTypes {
|
||||
if !s.Valid() {
|
||||
t.Fatalf("accepted an invalid source type %q", s)
|
||||
}
|
||||
}
|
||||
if filter.ContentHash != "" && !isHex(filter.ContentHash) {
|
||||
t.Fatalf("accepted a non-hexadecimal content hash %q", filter.ContentHash)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/relata/internal/auth"
|
||||
"github.com/andydixon/relata/internal/domain"
|
||||
"github.com/andydixon/relata/internal/ids"
|
||||
"github.com/andydixon/relata/internal/observability"
|
||||
)
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const (
|
||||
ctxKeyPrincipal ctxKey = iota
|
||||
ctxKeySession
|
||||
)
|
||||
|
||||
// PrincipalFrom returns the authenticated caller, or nil.
|
||||
func PrincipalFrom(ctx context.Context) *domain.Principal {
|
||||
p, _ := ctx.Value(ctxKeyPrincipal).(*domain.Principal)
|
||||
return p
|
||||
}
|
||||
|
||||
// SessionFrom returns the active session, or nil.
|
||||
func SessionFrom(ctx context.Context) *domain.Session {
|
||||
s, _ := ctx.Value(ctxKeySession).(*domain.Session)
|
||||
return s
|
||||
}
|
||||
|
||||
// WithPrincipal attaches a principal to a context. Exported for tests.
|
||||
func WithPrincipal(ctx context.Context, p *domain.Principal) context.Context {
|
||||
return context.WithValue(ctx, ctxKeyPrincipal, p)
|
||||
}
|
||||
|
||||
// Middleware is the standard decorator shape.
|
||||
type Middleware func(http.Handler) http.Handler
|
||||
|
||||
// Chain applies middleware so that the first listed runs outermost.
|
||||
func Chain(h http.Handler, mw ...Middleware) http.Handler {
|
||||
for i := len(mw) - 1; i >= 0; i-- {
|
||||
h = mw[i](h)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// statusWriter captures the response status for logging and metrics.
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
bytes int
|
||||
}
|
||||
|
||||
func (w *statusWriter) WriteHeader(code int) {
|
||||
if w.status == 0 {
|
||||
w.status = code
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *statusWriter) Write(b []byte) (int, error) {
|
||||
if w.status == 0 {
|
||||
w.status = http.StatusOK
|
||||
}
|
||||
n, err := w.ResponseWriter.Write(b)
|
||||
w.bytes += n
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Flush supports streaming handlers behind the wrapper.
|
||||
func (w *statusWriter) Flush() {
|
||||
if f, ok := w.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// RequestID assigns a correlation ID to every request and echoes it back.
|
||||
//
|
||||
// A client-supplied ID is accepted only when it looks like one Relata would
|
||||
// generate; anything else is replaced, so a caller cannot inject arbitrary text
|
||||
// into log fields.
|
||||
func RequestID() Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.Header.Get("X-Request-ID")
|
||||
if !ids.Valid(id) {
|
||||
id = ids.New()
|
||||
}
|
||||
w.Header().Set("X-Request-ID", id)
|
||||
next.ServeHTTP(w, r.WithContext(observability.WithRequestID(r.Context(), id)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Logging records each request's outcome and duration.
|
||||
type Logging struct {
|
||||
Metrics *observability.Metrics
|
||||
}
|
||||
|
||||
// Middleware returns the logging middleware. Route is the pattern rather than
|
||||
// the literal path, so metric label cardinality stays bounded.
|
||||
func (l Logging) Middleware(route string) Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
started := time.Now()
|
||||
sw := &statusWriter{ResponseWriter: w}
|
||||
|
||||
if l.Metrics != nil {
|
||||
l.Metrics.HTTPInFlight.Inc()
|
||||
defer l.Metrics.HTTPInFlight.Dec()
|
||||
}
|
||||
|
||||
next.ServeHTTP(sw, r)
|
||||
|
||||
if sw.status == 0 {
|
||||
sw.status = http.StatusOK
|
||||
}
|
||||
dur := time.Since(started)
|
||||
|
||||
observability.LoggerFrom(r.Context()).Info("request",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", sw.status,
|
||||
"duration_ms", dur.Milliseconds(),
|
||||
"bytes", sw.bytes,
|
||||
)
|
||||
|
||||
if l.Metrics != nil {
|
||||
l.Metrics.HTTPRequests.WithLabelValues(route, r.Method, statusClass(sw.status)).Inc()
|
||||
l.Metrics.HTTPDuration.WithLabelValues(route, r.Method).Observe(dur.Seconds())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func statusClass(code int) string {
|
||||
switch {
|
||||
case code < 200:
|
||||
return "1xx"
|
||||
case code < 300:
|
||||
return "2xx"
|
||||
case code < 400:
|
||||
return "3xx"
|
||||
case code < 500:
|
||||
return "4xx"
|
||||
default:
|
||||
return "5xx"
|
||||
}
|
||||
}
|
||||
|
||||
// Recover turns a panic into a 500 rather than a dropped connection.
|
||||
func Recover() Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
// Panic values can contain credentials or request content. The
|
||||
// request ID and route identify the failure without persisting it.
|
||||
observability.LoggerFrom(r.Context()).Error("panic while handling request",
|
||||
"path", r.URL.Path, "method", r.Method)
|
||||
WriteError(w, r, &APIError{
|
||||
Status: http.StatusInternalServerError, Code: CodeInternal,
|
||||
Message: "An unexpected error occurred.",
|
||||
})
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// SecurityHeaders sets the response headers that constrain what a browser will
|
||||
// do with Relata's pages.
|
||||
//
|
||||
// The Content-Security-Policy is deliberately restrictive. Relata renders
|
||||
// stored content as escaped text and ships its own CSS and JavaScript as files,
|
||||
// so no inline script, no remote origin and no framing are needed — and
|
||||
// forbidding them means a stored-XSS payload that somehow reached the page
|
||||
// still could not execute or exfiltrate.
|
||||
func SecurityHeaders(allowInlineStyle bool) Middleware {
|
||||
directives := []string{
|
||||
"default-src 'none'",
|
||||
"script-src 'self'",
|
||||
"style-src 'self'",
|
||||
"img-src 'self' data:",
|
||||
"font-src 'self'",
|
||||
"connect-src 'self'",
|
||||
"form-action 'self'",
|
||||
"base-uri 'none'",
|
||||
"frame-ancestors 'none'",
|
||||
"object-src 'none'",
|
||||
"manifest-src 'self'",
|
||||
}
|
||||
csp := strings.Join(directives, "; ")
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("Content-Security-Policy", csp)
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Referrer-Policy", "same-origin")
|
||||
h.Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||
h.Set("Cross-Origin-Resource-Policy", "same-origin")
|
||||
h.Set("Permissions-Policy", "geolocation=(), microphone=(), camera=(), payment=(), usb=()")
|
||||
// Caches must not retain authenticated pages.
|
||||
h.Set("Cache-Control", "no-store")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MaxBody rejects request bodies over a limit before a handler reads them.
|
||||
func MaxBody(limit int64) Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.ContentLength > limit {
|
||||
WriteError(w, r, &APIError{
|
||||
Status: http.StatusRequestEntityTooLarge, Code: CodePayloadTooLarge,
|
||||
Message: "The request body is larger than the configured limit.",
|
||||
})
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, limit)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticator resolves sessions and, on the API, bearer tokens.
|
||||
type Authenticator struct {
|
||||
Auth *auth.Service
|
||||
// Tokens enables bearer-token authentication. It is set only on the API
|
||||
// chain: a token is never a way into the web interface.
|
||||
Tokens *auth.TokenService
|
||||
// APIMode returns a JSON 401 rather than redirecting to the login page.
|
||||
APIMode bool
|
||||
// LoginPath is where browser requests are sent when unauthenticated.
|
||||
LoginPath string
|
||||
}
|
||||
|
||||
// resolve authenticates a request, preferring the session cookie.
|
||||
//
|
||||
// The ordering is load-bearing. A request carrying a valid session cookie
|
||||
// authenticates as that session even if it also sends an Authorization header,
|
||||
// so the CSRF exemption below — which keys off how the request authenticated —
|
||||
// cannot be switched off by adding a header to a cookie-bearing request.
|
||||
func (a Authenticator) resolve(r *http.Request) (*domain.Principal, *domain.Session, error) {
|
||||
if p, sess, err := a.Auth.Resolve(r.Context(), a.Auth.TokenFromRequest(r)); err == nil && p != nil {
|
||||
return p, sess, nil
|
||||
}
|
||||
if a.Tokens == nil {
|
||||
return nil, nil, auth.ErrNoSession
|
||||
}
|
||||
secret := auth.BearerFromRequest(r)
|
||||
if secret == "" {
|
||||
return nil, nil, auth.ErrNoSession
|
||||
}
|
||||
p, err := a.Tokens.Authenticate(r.Context(), secret)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return p, nil, nil
|
||||
}
|
||||
|
||||
// RequireAuth rejects unauthenticated requests.
|
||||
func (a Authenticator) RequireAuth() Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p, sess, err := a.resolve(r)
|
||||
if err != nil || p == nil {
|
||||
a.rejectWith(w, r, err)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), ctxKeyPrincipal, p)
|
||||
ctx = context.WithValue(ctx, ctxKeySession, sess)
|
||||
// Correlate logs with the user without recording anything sensitive.
|
||||
ctx = observability.WithLogger(ctx,
|
||||
observability.LoggerFrom(ctx).With("user_id", p.UserID, "workspace_id", p.WorkspaceID))
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// OptionalAuth resolves a session when present but permits anonymous requests.
|
||||
func (a Authenticator) OptionalAuth() Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p, sess, err := a.resolve(r)
|
||||
if err == nil && p != nil {
|
||||
ctx := context.WithValue(r.Context(), ctxKeyPrincipal, p)
|
||||
ctx = context.WithValue(ctx, ctxKeySession, sess)
|
||||
r = r.WithContext(ctx)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// rejectWith refuses a request, distinguishing the one failure a caller can act
|
||||
// on — being rate limited — from every other, which are deliberately identical.
|
||||
func (a Authenticator) rejectWith(w http.ResponseWriter, r *http.Request, err error) {
|
||||
if a.APIMode && errors.Is(err, auth.ErrTokenRateLimit) {
|
||||
WriteError(w, r, &APIError{
|
||||
Status: http.StatusTooManyRequests, Code: CodeRateLimited,
|
||||
Message: "This token is making too many requests. Slow down and retry.",
|
||||
})
|
||||
return
|
||||
}
|
||||
a.reject(w, r)
|
||||
}
|
||||
|
||||
func (a Authenticator) reject(w http.ResponseWriter, r *http.Request) {
|
||||
if a.APIMode {
|
||||
WriteError(w, r, &APIError{
|
||||
Status: http.StatusUnauthorized, Code: CodeUnauthorised,
|
||||
Message: "Authentication is required. Sign in to obtain a session.",
|
||||
})
|
||||
return
|
||||
}
|
||||
path := a.LoginPath
|
||||
if path == "" {
|
||||
path = "/login"
|
||||
}
|
||||
http.Redirect(w, r, path, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// RequireScope rejects a caller that may not perform an action.
|
||||
//
|
||||
// The check is the intersection of the person's role and, when the request came
|
||||
// from an API token, that token's scopes — so a token can never do more than
|
||||
// the person who created it, and demoting someone narrows every token they hold
|
||||
// without any of those tokens being touched.
|
||||
func RequireScope(scope domain.TokenScope) Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
if p == nil {
|
||||
WriteError(w, r, &APIError{
|
||||
Status: http.StatusUnauthorized, Code: CodeUnauthorised,
|
||||
Message: "Authentication is required.",
|
||||
})
|
||||
return
|
||||
}
|
||||
if p.Permits(scope) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Say which scope was needed. A caller holding the wrong token
|
||||
// should be able to fix it without guessing, and the scope names
|
||||
// are public API surface rather than a secret.
|
||||
if p.ViaToken() {
|
||||
WriteError(w, r, &APIError{
|
||||
Status: http.StatusForbidden, Code: CodeInsufficientScope,
|
||||
Message: "This API token does not carry the scope this request needs.",
|
||||
Detail: map[string]string{"required_scope": string(scope)},
|
||||
})
|
||||
return
|
||||
}
|
||||
WriteError(w, r, &APIError{
|
||||
Status: http.StatusForbidden, Code: CodeForbidden,
|
||||
Message: "Your role does not permit this.",
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RequireSession rejects anything not authenticated by an interactive session.
|
||||
//
|
||||
// Token management lives behind this: a leaked token must not be able to mint
|
||||
// more tokens, extend its own life, or revoke the tokens that would replace it.
|
||||
func RequireSession() Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
if p == nil || p.ViaToken() {
|
||||
WriteError(w, r, &APIError{
|
||||
Status: http.StatusForbidden, Code: CodeForbidden,
|
||||
Message: "This action requires a signed-in session. API tokens cannot manage API tokens.",
|
||||
})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RequireWrite rejects a caller whose role cannot modify content.
|
||||
func RequireWrite() Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
if p == nil || !p.Role.CanWrite() {
|
||||
WriteError(w, r, &APIError{
|
||||
Status: http.StatusForbidden, Code: CodeForbidden,
|
||||
Message: "Your role does not permit changes.",
|
||||
})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// CSRF protects state-changing browser requests.
|
||||
//
|
||||
// The check has two independent parts:
|
||||
//
|
||||
// 1. Origin/Referer must match the expected host. A cross-site form post
|
||||
// carries the attacker's origin, and browsers will not let script forge it.
|
||||
// 2. A per-session token, submitted in a form field or header, must match the
|
||||
// session's own token in a constant-time comparison.
|
||||
//
|
||||
// Safe methods are exempt because they must not change state in the first place.
|
||||
type CSRF struct {
|
||||
// TrustedOrigins are the origins permitted to submit state-changing
|
||||
// requests, derived from the configured base URL.
|
||||
TrustedOrigins []string
|
||||
// HeaderName is where a fetch/HTMX request supplies the token.
|
||||
HeaderName string
|
||||
// FieldName is the form field carrying the token.
|
||||
FieldName string
|
||||
}
|
||||
|
||||
// Middleware returns the CSRF middleware.
|
||||
func (c CSRF) Middleware() Middleware {
|
||||
header := c.HeaderName
|
||||
if header == "" {
|
||||
header = "X-CSRF-Token"
|
||||
}
|
||||
field := c.FieldName
|
||||
if field == "" {
|
||||
field = "csrf_token"
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// A bearer-authenticated request needs no CSRF token. CSRF exists
|
||||
// to stop a third-party page spending *ambient* credentials, and an
|
||||
// Authorization header is not ambient: a browser never attaches one
|
||||
// on its own.
|
||||
//
|
||||
// The exemption keys off how the request authenticated, not off a
|
||||
// header being present. Because resolve() prefers the session
|
||||
// cookie, a request holding a cookie is never ViaToken, so adding
|
||||
// an Authorization header cannot disable this check.
|
||||
if p := PrincipalFrom(r.Context()); p != nil && p.ViaToken() {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
sess := SessionFrom(r.Context())
|
||||
if sess == nil {
|
||||
WriteError(w, r, &APIError{
|
||||
Status: http.StatusUnauthorized, Code: CodeUnauthorised,
|
||||
Message: "Authentication is required.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !c.originAllowed(r) {
|
||||
WriteError(w, r, &APIError{
|
||||
Status: http.StatusForbidden, Code: CodeCSRFFailed,
|
||||
Message: "This request appears to originate from another site and was refused.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
token := r.Header.Get(header)
|
||||
if token == "" {
|
||||
// Reading the form consumes the body; only do so for form
|
||||
// content types, so a JSON body is left intact for the handler.
|
||||
ct := r.Header.Get("Content-Type")
|
||||
base, _, _ := strings.Cut(ct, ";")
|
||||
switch strings.TrimSpace(base) {
|
||||
case "application/x-www-form-urlencoded", "multipart/form-data":
|
||||
_ = r.ParseMultipartForm(8 << 20)
|
||||
token = r.FormValue(field)
|
||||
}
|
||||
}
|
||||
|
||||
if !auth.CheckCSRF(sess.CSRFToken, token) {
|
||||
observability.LoggerFrom(r.Context()).Warn("CSRF check failed",
|
||||
"path", r.URL.Path, "method", r.Method, "has_token", token != "")
|
||||
WriteError(w, r, &APIError{
|
||||
Status: http.StatusForbidden, Code: CodeCSRFFailed,
|
||||
Message: "Your session's security token was missing or invalid. Reload the page and try again.",
|
||||
})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// originAllowed checks the Origin header, falling back to Referer.
|
||||
//
|
||||
// A request with neither header is refused rather than allowed: every browser
|
||||
// that can perform a cross-site state-changing request sends at least one.
|
||||
func (c CSRF) originAllowed(r *http.Request) bool {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
if ref := r.Header.Get("Referer"); ref != "" {
|
||||
origin = originOf(ref)
|
||||
}
|
||||
}
|
||||
if origin == "" {
|
||||
// A non-browser API client with a valid session token and no Origin
|
||||
// header is not subject to CSRF (there is no ambient credential to
|
||||
// abuse from a browser), but it must still present the token, which is
|
||||
// checked next.
|
||||
return true
|
||||
}
|
||||
for _, allowed := range c.TrustedOrigins {
|
||||
if strings.EqualFold(origin, allowed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// originOf extracts scheme://host from a URL string without full parsing.
|
||||
func originOf(u string) string {
|
||||
i := strings.Index(u, "://")
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
rest := u[i+3:]
|
||||
if j := strings.IndexAny(rest, "/?#"); j >= 0 {
|
||||
rest = rest[:j]
|
||||
}
|
||||
return u[:i+3] + rest
|
||||
}
|
||||
|
||||
// withSession attaches a session to a context. Exported for tests in this
|
||||
// package; the middleware sets it during normal operation.
|
||||
func withSession(ctx context.Context, s *domain.Session) context.Context {
|
||||
return context.WithValue(ctx, ctxKeySession, s)
|
||||
}
|
||||
Reference in New Issue
Block a user