diff --git a/internal/api/settings.go b/internal/api/settings.go index f44b949..3d2224c 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "io" "net/http" "sort" "strings" @@ -49,7 +50,15 @@ func (s *Server) HandlePutSettings(w http.ResponseWriter, r *http.Request) { } `json:"settings"` } max := s.Cfg.Ops.MaxBodyBytes - if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, max)).Decode(&body); err != nil { + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, max)) + if err := dec.Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body") + return + } + // Decode stops at the end of the first value, so without this a + // second document, trailing rubbish, or padding past the body limit + // would be stored and answered 200 as if the request were clean. + if _, err := dec.Token(); err != io.EOF { writeError(w, http.StatusBadRequest, "invalid JSON body") return } diff --git a/internal/api/settings_test.go b/internal/api/settings_test.go index aa6e60f..8b258fb 100644 --- a/internal/api/settings_test.go +++ b/internal/api/settings_test.go @@ -389,3 +389,22 @@ func TestSettingsRejectsOversizedBatch(t *testing.T) { t.Fatalf("want 400 for a batch larger than the account cap, got %d %v", code, body) } } + +func TestSettingsRejectsTrailingContent(t *testing.T) { + f := newFolderFixture(t) + + put := `{"settings":{"reader.font":{"value":"literata","updated_at":"2026-06-01T12:00:00Z"}}}{"settings":{}}` + code, body := putJSONReq(t, f.ts.URL+"/v1/me/settings", f.token, put) + if code != http.StatusBadRequest { + t.Fatalf("want 400 for a body with a second document, got %d %v", code, body) + } + + // And nothing was stored on the way to refusing it. + code, body = getJSON(t, f.ts.URL+"/v1/me/settings", f.token) + if code != http.StatusOK { + t.Fatalf("get: %d %v", code, body) + } + if settings := body["settings"].(map[string]any); len(settings) != 0 { + t.Fatalf("a refused request stored something: %v", settings) + } +} diff --git a/internal/store/postgres/settings.go b/internal/store/postgres/settings.go index e930ab5..8148e87 100644 --- a/internal/store/postgres/settings.go +++ b/internal/store/postgres/settings.go @@ -93,7 +93,11 @@ func checkSettingsQuota(ctx context.Context, tx *sql.Tx, userID string, settings added++ } } - if existing+added > maxPerAccount { + // Only a request that adds a key can be over the limit. An account + // already above it — because an operator lowered the cap — must + // still be able to replace what it has, or every key it holds is + // frozen with no delete route to get back under. + if added > 0 && existing+added > maxPerAccount { return store.ErrQuotaExceeded } return nil