Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions extensions/dashboard/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ type Config struct {
// set to false during a rollout window where clients have not yet
// adopted CSRF tokens or the idempotency-key contract.
EnableContractSecurity bool `json:"enable_contract_security" yaml:"enable_contract_security"`
// ContractMaxBodyBytes caps the contract envelope a client may POST.
// Zero or less means transport.DefaultMaxBodyBytes (1 MiB).
ContractMaxBodyBytes int64 `json:"contract_max_body_bytes" yaml:"contract_max_body_bytes"`

// Authentication
EnableAuth bool `json:"enable_auth" yaml:"enable_auth"` // enable auth support
Expand Down Expand Up @@ -387,6 +390,13 @@ func WithContractSecurity(enabled bool) ConfigOption {
return func(c *Config) { c.EnableContractSecurity = enabled }
}

// WithContractMaxBodyBytes caps the contract envelope a client may POST.
// Larger bodies are refused with 413 before they are decoded. Zero or less
// keeps the 1 MiB default.
func WithContractMaxBodyBytes(n int64) ConfigOption {
return func(c *Config) { c.ContractMaxBodyBytes = n }
}

// WithTheme sets the UI theme (light, dark, auto).
func WithTheme(theme string) ConfigOption {
return func(c *Config) { c.Theme = theme }
Expand Down
16 changes: 13 additions & 3 deletions extensions/dashboard/contract/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type Server struct {
reg contract.Registry
dispatch http.Handler
prefix string
handler []transport.HandlerOption
}

// Option configures a Server.
Expand All @@ -50,6 +51,14 @@ func WithPrefix(p string) Option {
}
}

// WithMaxBodyBytes caps the dispatch request envelope at n bytes. The
// default is transport.DefaultMaxBodyBytes; n <= 0 keeps it.
func WithMaxBodyBytes(n int64) Option {
return func(s *Server) {
s.handler = append(s.handler, transport.WithMaxBodyBytes(n))
}
}

// New returns a Server configured to serve the registry + dispatcher
// passed in. The supplied audit emitter is plumbed through to the
// dispatch handler; pass contract.NoopAuditEmitter{} when not needed.
Expand All @@ -61,13 +70,14 @@ func New(
opts ...Option,
) *Server {
s := &Server{
reg: reg,
dispatch: transport.NewHandler(reg, wreg, disp, audit),
prefix: DefaultPrefix,
reg: reg,
prefix: DefaultPrefix,
}
for _, o := range opts {
o(s)
}

s.dispatch = transport.NewHandler(reg, wreg, disp, audit, s.handler...)
return s
}

Expand Down
15 changes: 15 additions & 0 deletions extensions/dashboard/contract/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,18 @@ func TestRoundTrip_HostToUpstream(t *testing.T) {
t.Errorf("round trip data = %+v", got)
}
}

func TestServer_WithMaxBodyBytesReachesDispatch(t *testing.T) {
s := New(contract.NewRegistry(), contract.NewWardenRegistry(), nil, contract.NoopAuditEmitter{}, WithMaxBodyBytes(64))
body, _ := json.Marshal(contract.Request{
Envelope: "v1", Kind: contract.KindQuery,
Contributor: "things", Intent: "things.list", IntentVersion: 1,
Payload: json.RawMessage(`"` + strings.Repeat("a", 64) + `"`),
})
req := httptest.NewRequest(http.MethodPost, s.DispatchPath(), bytes.NewReader(body))
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want 413; body=%s", w.Code, w.Body)
}
}
59 changes: 54 additions & 5 deletions extensions/dashboard/contract/transport/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ package transport
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"time"

dashauth "github.com/xraph/forge/extensions/dashboard/auth"
Expand All @@ -31,23 +33,49 @@ func (NilDispatcher) Dispatch(_ context.Context, _ contract.Request, _ contract.
// supportedEnvelopes is the set this slice's handler understands.
var supportedEnvelopes = map[string]bool{"v1": true}

// DefaultMaxBodyBytes caps the request envelope when no WithMaxBodyBytes
// option is given. The whole envelope is decoded before the intent's
// Requires predicate can run, so this cap is what bounds the work an
// unauthorized caller can make the server do.
const DefaultMaxBodyBytes int64 = 1 << 20

// HandlerOption configures the handler returned by NewHandler.
type HandlerOption func(*handler)

// WithMaxBodyBytes sets the largest request envelope the handler will read.
// A larger body is refused with 413 and CodeBadRequest. n <= 0 keeps
// DefaultMaxBodyBytes; there is no way to switch the cap off.
func WithMaxBodyBytes(n int64) HandlerOption {
return func(h *handler) {
if n > 0 {
h.maxBody = n
}
}
}

// NewHandler returns the POST /api/dashboard/{envelope} handler.
func NewHandler(reg contract.Registry, wreg contract.WardenRegistry, disp Dispatcher, audit contract.AuditEmitter) http.Handler {
func NewHandler(reg contract.Registry, wreg contract.WardenRegistry, disp Dispatcher, audit contract.AuditEmitter, opts ...HandlerOption) http.Handler {
if disp == nil {
disp = NilDispatcher{}
}
if audit == nil {
audit = contract.NoopAuditEmitter{}
}
return &handler{reg: reg, wreg: wreg, disp: disp, audit: audit}

h := &handler{reg: reg, wreg: wreg, disp: disp, audit: audit, maxBody: DefaultMaxBodyBytes}
for _, o := range opts {
o(h)
}

return h
}

// NewHandlerWithCSRF is NewHandler plus a CSRFManager for command validation.
// When mgr is non-nil, command envelopes whose CSRF token does not validate
// return CodeUnauthenticated. Pass nil to skip CSRF (preserves the slice-(a)
// behaviour for tests and rollout opt-out).
func NewHandlerWithCSRF(reg contract.Registry, wreg contract.WardenRegistry, disp Dispatcher, audit contract.AuditEmitter, mgr *security.CSRFManager) http.Handler {
h := NewHandler(reg, wreg, disp, audit).(*handler)
func NewHandlerWithCSRF(reg contract.Registry, wreg contract.WardenRegistry, disp Dispatcher, audit contract.AuditEmitter, mgr *security.CSRFManager, opts ...HandlerOption) http.Handler {
h := NewHandler(reg, wreg, disp, audit, opts...).(*handler)
h.csrfMgr = mgr
return h
}
Expand All @@ -58,6 +86,7 @@ type handler struct {
disp Dispatcher
audit contract.AuditEmitter
csrfMgr *security.CSRFManager // optional; nil disables CSRF validation
maxBody int64 // envelope size cap; always > 0
}

func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
Expand All @@ -66,8 +95,21 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
defer r.Body.Close()
// The intent name lives inside the envelope, so its Requires predicate
// cannot run until the body is decoded. Cap the body first: any caller,
// authorized or not, can otherwise make us read and decode without bound.
// A declared length over the cap is refused without reading a byte.
if r.ContentLength > h.maxBody {
writeBodyTooLarge(w, h.maxBody)
return
}
var req contract.Request
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, h.maxBody)).Decode(&req); err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
writeBodyTooLarge(w, h.maxBody)
return
}
writeError(w, http.StatusBadRequest, &contract.Error{Code: contract.CodeBadRequest, Message: "invalid JSON: " + err.Error()})
return
}
Expand Down Expand Up @@ -234,6 +276,13 @@ func writeOK(w http.ResponseWriter, r contract.Response) {
_ = json.NewEncoder(w).Encode(r)
}

func writeBodyTooLarge(w http.ResponseWriter, limit int64) {
writeError(w, http.StatusRequestEntityTooLarge, &contract.Error{
Code: contract.CodeBadRequest,
Message: "request body exceeds " + strconv.FormatInt(limit, 10) + " bytes",
})
}

func writeError(w http.ResponseWriter, status int, e *contract.Error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
Expand Down
171 changes: 171 additions & 0 deletions extensions/dashboard/contract/transport/http_limit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// http_limit_test.go
package transport

import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/xraph/forge/extensions/dashboard/contract"
)

// countingReader records how many bytes the handler pulled off the wire, so
// a test can tell "refused after reading 1 KiB" from "refused after reading
// all 4 MiB".
type countingReader struct {
r io.Reader
n int64
}

func (c *countingReader) Read(p []byte) (int, error) {
n, err := c.r.Read(p)
c.n += int64(n)
return n, err
}

// envelopeOfSize returns a valid users.list query envelope that is exactly
// size bytes long, padded through its payload.
func envelopeOfSize(t *testing.T, size int64) []byte {
t.Helper()
mk := func(pad int64) []byte {
b, err := json.Marshal(contract.Request{
Envelope: "v1", Kind: contract.KindQuery, Contributor: "users", Intent: "users.list", IntentVersion: 1,
Payload: json.RawMessage(`"` + strings.Repeat("a", int(pad)) + `"`),
})
if err != nil {
t.Fatal(err)
}
return b
}
base := int64(len(mk(0)))
if size < base {
t.Fatalf("size %d is below the minimum envelope size %d", size, base)
}
b := mk(size - base)
if int64(len(b)) != size {
t.Fatalf("envelope is %d bytes, want %d", len(b), size)
}
return b
}

// postBody sends body with no declared length, the way a chunked upload
// arrives, so the handler cannot refuse it from the header alone.
func postBody(h http.Handler, body []byte) (*httptest.ResponseRecorder, *countingReader) {
cr := &countingReader{r: bytes.NewReader(body)}
req := httptest.NewRequest(http.MethodPost, "/api/dashboard/v1", cr)
req.ContentLength = -1
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
return w, cr
}

func assertTooLarge(t *testing.T, w *httptest.ResponseRecorder, disp *stubDispatcher) {
t.Helper()
if w.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want 413; body=%.200s", w.Code, w.Body)
}
var resp contract.ErrorResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode error response: %v", err)
}
if resp.Error == nil || resp.Error.Code != contract.CodeBadRequest {
t.Errorf("error = %+v, want code %s", resp.Error, contract.CodeBadRequest)
}
if disp.called != "" {
t.Errorf("dispatcher ran for an oversized body: %s", disp.called)
}
}

func TestHandler_RefusesOversizedBodyWithoutReadingIt(t *testing.T) {
reg, wreg := setupRegistry(t)
disp := &stubDispatcher{response: json.RawMessage(`{}`)}
h := NewHandler(reg, wreg, disp, contract.NoopAuditEmitter{})

body := envelopeOfSize(t, 4*DefaultMaxBodyBytes)
w, cr := postBody(h, body)

assertTooLarge(t, w, disp)
// http.MaxBytesReader stops one byte past the cap. Anything more means
// the handler kept reading after it knew the answer.
if cr.n > DefaultMaxBodyBytes+1 {
t.Errorf("handler read %d of %d bytes; want at most %d", cr.n, len(body), DefaultMaxBodyBytes+1)
}
}

func TestHandler_RefusesOversizedDeclaredLengthBeforeReading(t *testing.T) {
reg, wreg := setupRegistry(t)
disp := &stubDispatcher{response: json.RawMessage(`{}`)}
h := NewHandler(reg, wreg, disp, contract.NoopAuditEmitter{}, WithMaxBodyBytes(1024))

body := envelopeOfSize(t, 2048)
cr := &countingReader{r: bytes.NewReader(body)}
req := httptest.NewRequest(http.MethodPost, "/api/dashboard/v1", cr)
req.ContentLength = int64(len(body))
w := httptest.NewRecorder()
h.ServeHTTP(w, req)

assertTooLarge(t, w, disp)
if cr.n != 0 {
t.Errorf("handler read %d bytes of a body whose declared length was already over the cap", cr.n)
}
}

func TestHandler_AcceptsBodyAtDefaultLimit(t *testing.T) {
reg, wreg := setupRegistry(t)
disp := &stubDispatcher{response: json.RawMessage(`{}`)}
h := NewHandler(reg, wreg, disp, contract.NoopAuditEmitter{})

w, _ := postBody(h, envelopeOfSize(t, DefaultMaxBodyBytes))
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%.200s", w.Code, w.Body)
}
if disp.called != "query:users.list" {
t.Errorf("dispatcher not called: %q", disp.called)
}
}

func TestHandler_HonoursConfiguredLimit(t *testing.T) {
const limit = 4096
reg, wreg := setupRegistry(t)

t.Run("at limit", func(t *testing.T) {
disp := &stubDispatcher{response: json.RawMessage(`{}`)}
h := NewHandler(reg, wreg, disp, contract.NoopAuditEmitter{}, WithMaxBodyBytes(limit))
w, _ := postBody(h, envelopeOfSize(t, limit))
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%.200s", w.Code, w.Body)
}
})

// One byte over a configured cap that sits far below the default, so a
// handler that ignored the option would accept it.
t.Run("one byte over", func(t *testing.T) {
disp := &stubDispatcher{response: json.RawMessage(`{}`)}
h := NewHandler(reg, wreg, disp, contract.NoopAuditEmitter{}, WithMaxBodyBytes(limit))
w, cr := postBody(h, envelopeOfSize(t, limit+1))
assertTooLarge(t, w, disp)
if cr.n > limit+1 {
t.Errorf("handler read %d bytes; want at most %d", cr.n, limit+1)
}
})

t.Run("raised above default", func(t *testing.T) {
disp := &stubDispatcher{response: json.RawMessage(`{}`)}
h := NewHandler(reg, wreg, disp, contract.NoopAuditEmitter{}, WithMaxBodyBytes(2*DefaultMaxBodyBytes))
w, _ := postBody(h, envelopeOfSize(t, DefaultMaxBodyBytes+1))
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%.200s", w.Code, w.Body)
}
})

t.Run("non-positive keeps default", func(t *testing.T) {
disp := &stubDispatcher{response: json.RawMessage(`{}`)}
h := NewHandler(reg, wreg, disp, contract.NoopAuditEmitter{}, WithMaxBodyBytes(0))
w, _ := postBody(h, envelopeOfSize(t, DefaultMaxBodyBytes+1))
assertTooLarge(t, w, disp)
})
}
4 changes: 3 additions & 1 deletion extensions/dashboard/extension.go
Original file line number Diff line number Diff line change
Expand Up @@ -1856,7 +1856,9 @@ func (e *Extension) handleContractPOST() http.HandlerFunc {
if e.config.EnableContractSecurity && e.csrfMgr != nil {
mgr = e.csrfMgr
}
h := transport.NewHandlerWithCSRF(e.contractRegistry, e.wardenRegistry, e.dispatcher, e.auditEmitter, mgr)

h := transport.NewHandlerWithCSRF(e.contractRegistry, e.wardenRegistry, e.dispatcher, e.auditEmitter, mgr,
transport.WithMaxBodyBytes(e.config.ContractMaxBodyBytes))
return h.ServeHTTP
}

Expand Down
Loading