diff --git a/internal/server/server.go b/internal/server/server.go index aa135a1..6866f83 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1269,9 +1269,21 @@ func writeJSON(w http.ResponseWriter, status int, v interface{}) { } } +// maxJSONBytes limits the size of JSON request bodies (1 MiB). +// +// Security (SEC-05): without a size limit an attacker could send a +// multi-gigabyte body and exhaust server memory. The UI layer already +// applies http.MaxBytesReader; this constant gives the REST API the +// same protection. +const maxJSONBytes = 1 << 20 + // decodeJSON decodes a JSON request body into v. // Returns false and writes a 400 response if decoding fails. +// +// Security (SEC-05): the body is wrapped with http.MaxBytesReader so +// that oversized payloads are rejected before they are fully read. func decodeJSON(w http.ResponseWriter, r *http.Request, v interface{}) bool { + r.Body = http.MaxBytesReader(w, r.Body, maxJSONBytes) dec := json.NewDecoder(r.Body) dec.DisallowUnknownFields() if err := dec.Decode(v); err != nil { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index f7325fe..82037d1 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -594,3 +594,21 @@ func TestRenewToken(t *testing.T) { t.Error("old token should be revoked after renewal") } } + +func TestOversizedJSONBodyRejected(t *testing.T) { + srv, _, _, _ := newTestServer(t) + handler := srv.Handler() + + // Build a JSON body larger than 1 MiB. + oversized := bytes.Repeat([]byte("A"), (1<<20)+1) + body := []byte(`{"username":"admin","password":"` + string(oversized) + `"}`) + + req := httptest.NewRequest("POST", "/v1/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Errorf("expected 400 for oversized body, got %d", rr.Code) + } +} diff --git a/internal/validate/validate.go b/internal/validate/validate.go index 65a179a..9756288 100644 --- a/internal/validate/validate.go +++ b/internal/validate/validate.go @@ -45,11 +45,22 @@ func Username(username string) error { // password. const MinPasswordLen = 12 -// Password returns nil if the plaintext password meets the minimum length -// requirement, or a descriptive error if not. +// MaxPasswordLen is the maximum acceptable plaintext password length. +// +// Security (SEC-05): Argon2id processes the full password input. Without +// an upper bound an attacker could submit a multi-megabyte password and +// force expensive hashing. 128 characters is generous for any real +// password or passphrase while capping the cost. +const MaxPasswordLen = 128 + +// Password returns nil if the plaintext password meets the length +// requirements, or a descriptive error if not. func Password(password string) error { if len(password) < MinPasswordLen { return fmt.Errorf("password must be at least %d characters", MinPasswordLen) } + if len(password) > MaxPasswordLen { + return fmt.Errorf("password must be at most %d characters", MaxPasswordLen) + } return nil } diff --git a/internal/validate/validate_test.go b/internal/validate/validate_test.go index 106dacd..8f51a41 100644 --- a/internal/validate/validate_test.go +++ b/internal/validate/validate_test.go @@ -32,6 +32,17 @@ func TestPasswordTooShort(t *testing.T) { } } +func TestPasswordTooLong(t *testing.T) { + // Exactly MaxPasswordLen should be accepted. + if err := Password(strings.Repeat("a", MaxPasswordLen)); err != nil { + t.Errorf("Password(len=%d) = %v, want nil", MaxPasswordLen, err) + } + // One over the limit should be rejected. + if err := Password(strings.Repeat("a", MaxPasswordLen+1)); err == nil { + t.Errorf("Password(len=%d) = nil, want error", MaxPasswordLen+1) + } +} + func TestUsernameValid(t *testing.T) { valid := []string{ "alice",