Merge SEC-05: body size limit and max password length

This commit is contained in:
2026-03-13 00:49:39 -07:00
4 changed files with 54 additions and 2 deletions

View File

@@ -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. // decodeJSON decodes a JSON request body into v.
// Returns false and writes a 400 response if decoding fails. // 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 { 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 := json.NewDecoder(r.Body)
dec.DisallowUnknownFields() dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil { if err := dec.Decode(v); err != nil {

View File

@@ -594,3 +594,21 @@ func TestRenewToken(t *testing.T) {
t.Error("old token should be revoked after renewal") 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)
}
}

View File

@@ -45,11 +45,22 @@ func Username(username string) error {
// password. // password.
const MinPasswordLen = 12 const MinPasswordLen = 12
// Password returns nil if the plaintext password meets the minimum length // MaxPasswordLen is the maximum acceptable plaintext password length.
// requirement, or a descriptive error if not. //
// 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 { func Password(password string) error {
if len(password) < MinPasswordLen { if len(password) < MinPasswordLen {
return fmt.Errorf("password must be at least %d characters", 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 return nil
} }

View File

@@ -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) { func TestUsernameValid(t *testing.T) {
valid := []string{ valid := []string{
"alice", "alice",