🐛 Restrict update-profile-props to documented keys only (#10992)

Close the profile props schema to reject undocumented keys and add a
denylist for system-managed props like :subscription that should not be
user-writable via RPC.

Changes:
- Add system-managed-props denylist (#{:subscription})
- Close schema:props with :closed true
- Add tests for subscription rejection and valid key acceptance

AI-assisted-by: qwen3.7-plus
This commit is contained in:
Andrey Antukh 2026-08-03 17:27:09 +02:00 committed by GitHub
parent 49119e0339
commit 1136e5eda5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 48 additions and 2 deletions

View File

@ -45,8 +45,12 @@
[:email-comments [::sm/one-of #{:all :partial :none}]]
[:email-invites [::sm/one-of #{:all :none}]]])
(def system-managed-props
"Props keys managed by the system (not user-writable via RPC)."
#{:subscription})
(def schema:props
[:map {:title "ProfileProps"}
[:map {:title "ProfileProps" :closed true}
[:plugins {:optional true} schema:plugin-registry]
[:renderer {:optional true} [::sm/one-of #{:svg :wasm}]]
[:mcp-enabled {:optional true} ::sm/boolean]
@ -454,7 +458,7 @@
(assoc props k v))
props))
(:props profile)
props)]
(apply dissoc props system-managed-props))]
(db/update! conn :profile
{:props (db/tjson props)}

View File

@ -1160,3 +1160,45 @@
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :validation))
(t/is (th/ex-of-code? error :email-as-password))))
(t/deftest update-profile-props-rejects-subscription
;; N1-16: Mass Assignment — :subscription must not be writable via RPC
;; The closed schema rejects :subscription at validation time
(let [profile (th/create-profile* 1)
data {::th/type :update-profile-props
::rpc/profile-id (:id profile)
:props {:subscription {:type "unlimited" :status "active"}}}
out (th/command! data)]
;; The call must fail with validation error
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :params-validation))
;; And :subscription must NOT be persisted
(let [saved (th/db-get :profile {:id (:id profile)})
props (profile/decode-row saved)]
(t/is (nil? (get-in props [:props :subscription]))
":subscription must not be writable via update-profile-props"))))
(t/deftest update-profile-props-accepts-valid-keys
;; Verify that valid props keys still work after closing the schema
(let [profile (th/create-profile* 1)
data {::th/type :update-profile-props
::rpc/profile-id (:id profile)
:props {:onboarding-viewed true
:newsletter-updates false
:renderer :wasm}}
out (th/command! data)]
;; The call should succeed
(t/is (nil? (:error out)))
;; And all valid keys should be persisted
(let [saved (th/db-get :profile {:id (:id profile)})
props (profile/decode-row saved)]
(t/is (true? (get-in props [:props :onboarding-viewed])))
(t/is (false? (get-in props [:props :newsletter-updates])))
(t/is (= :wasm (get-in props [:props :renderer]))))))