SECURITY (merge-blocker class): cross-user matrix halts on dev, 20 {id} routes unclassified #472

Closed
opened 2026-09-30 00:49:23 +00:00 by kayg · 4 comments
Owner

MERGE-BLOCKER CLASS: cross-user isolation (#331 matrix). On current dev (06b1b5c73), tests/adversarial/xuser_matrix.py stops before it sends any request. Twenty OpenAPI operations with a generic {id} path slot have no ROUTE_ID_FIELDS classification:

RuntimeError: OpenAPI path ID has no resource classification: DELETE /api/v1/admin/jobs/{id} (admin_stop_job)

Until this is fixed, no cross-user replay runs for any route. New routes and regressions are therefore unguarded. I found this during #462 (Money); the Money routes are classified and are not in this list.

Operations without classification (and the job that added them)

Operation Route Added by
admin_stop_job DELETE /api/v1/admin/jobs/{id} job/jobs-page (#315)
admin_get_job GET /api/v1/admin/jobs/{id}/detail job/jobs-page (#315)
get_my_job GET /api/v1/jobs/{id} job/jobs-page (#315)
cancel_my_job DELETE /api/v1/jobs/{id} job/jobs-page (#315)
update_quota PATCH /api/v1/auth/users/{id}/quota job/quota (#333)
mail_account GET /api/v1/mail/accounts/{id} job/mail-m1 (#313)
mail_remove_account DELETE /api/v1/mail/accounts/{id} job/mail-m1 (#313)
mail_update_account PATCH /api/v1/mail/accounts/{id} job/mail-m1 (#313)
mail_folders GET /api/v1/mail/accounts/{id}/folders job/mail-m1 (#313)
mail_sync_status GET /api/v1/mail/accounts/{id}/sync job/mail-m1 (#313)
mail_sync_now POST /api/v1/mail/accounts/{id}/sync job/mail-m1 (#313)
mail_messages GET /api/v1/mail/folders/{id}/messages job/mail-m1 / mail-m2 (#313, #396)
mail_message GET /api/v1/mail/messages/{id} job/mail-m2 (#396)
mail_download_attachment GET /api/v1/mail/messages/{id}/attachments/{section_id} job/mail-m2 (#396)
mail_correct_sender_category POST /api/v1/mail/messages/{id}/category Mail (#396 or later)
mail_change_read_state POST /api/v1/mail/messages/{id}/read-state job/mail-m2 (#396)
mail_remote_content_choice POST /api/v1/mail/messages/{id}/remote-content job/mail-m2 (#396)
mail_thread_attachments GET /api/v1/mail/threads/{id}/attachments job/mail-m2 (#396)
mail_thread_messages GET /api/v1/mail/threads/{id}/messages job/mail-m2 (#396)
mail_thread_oldest_unread GET /api/v1/mail/threads/{id}/oldest-unread job/mail-m2 (#396)

The "Added by" column is my best reading of git log -S. Please check it.

Work

  1. Add each operation to ROUTE_ID_FIELDS with a resource kind (for example job_id, user_id, mail_account_id, mail_folder_id, mail_message_id, mail_thread_id). Add section_id to FIELD_CLASSES.
  2. Seed User A fixtures for each kind in create_fixtures. For Mail, insert inert rows (as seed_provider_backed_rows does for Calendar), because the matrix must not contact a provider.
  3. Run XUSER_MATRIX_ONLY=1 tests/adversarial/run.sh to a clean result, and paste the output.
  4. Process fix. Merges into dev must run the matrix, or at least operation_rows(), so a new unclassified route fails the merge instead of silently disabling the whole probe. A cheap test is enough: import xuser_matrix.operation_rows with the checked-in contract.
**MERGE-BLOCKER CLASS: cross-user isolation (#331 matrix).** On current `dev` (06b1b5c73), `tests/adversarial/xuser_matrix.py` stops before it sends any request. Twenty OpenAPI operations with a generic `{id}` path slot have no `ROUTE_ID_FIELDS` classification: ``` RuntimeError: OpenAPI path ID has no resource classification: DELETE /api/v1/admin/jobs/{id} (admin_stop_job) ``` Until this is fixed, **no cross-user replay runs for any route**. New routes and regressions are therefore unguarded. I found this during #462 (Money); the Money routes are classified and are not in this list. ## Operations without classification (and the job that added them) | Operation | Route | Added by | |---|---|---| | admin_stop_job | DELETE /api/v1/admin/jobs/{id} | job/jobs-page (#315) | | admin_get_job | GET /api/v1/admin/jobs/{id}/detail | job/jobs-page (#315) | | get_my_job | GET /api/v1/jobs/{id} | job/jobs-page (#315) | | cancel_my_job | DELETE /api/v1/jobs/{id} | job/jobs-page (#315) | | update_quota | PATCH /api/v1/auth/users/{id}/quota | job/quota (#333) | | mail_account | GET /api/v1/mail/accounts/{id} | job/mail-m1 (#313) | | mail_remove_account | DELETE /api/v1/mail/accounts/{id} | job/mail-m1 (#313) | | mail_update_account | PATCH /api/v1/mail/accounts/{id} | job/mail-m1 (#313) | | mail_folders | GET /api/v1/mail/accounts/{id}/folders | job/mail-m1 (#313) | | mail_sync_status | GET /api/v1/mail/accounts/{id}/sync | job/mail-m1 (#313) | | mail_sync_now | POST /api/v1/mail/accounts/{id}/sync | job/mail-m1 (#313) | | mail_messages | GET /api/v1/mail/folders/{id}/messages | job/mail-m1 / mail-m2 (#313, #396) | | mail_message | GET /api/v1/mail/messages/{id} | job/mail-m2 (#396) | | mail_download_attachment | GET /api/v1/mail/messages/{id}/attachments/{section_id} | job/mail-m2 (#396) | | mail_correct_sender_category | POST /api/v1/mail/messages/{id}/category | Mail (#396 or later) | | mail_change_read_state | POST /api/v1/mail/messages/{id}/read-state | job/mail-m2 (#396) | | mail_remote_content_choice | POST /api/v1/mail/messages/{id}/remote-content | job/mail-m2 (#396) | | mail_thread_attachments | GET /api/v1/mail/threads/{id}/attachments | job/mail-m2 (#396) | | mail_thread_messages | GET /api/v1/mail/threads/{id}/messages | job/mail-m2 (#396) | | mail_thread_oldest_unread | GET /api/v1/mail/threads/{id}/oldest-unread | job/mail-m2 (#396) | The "Added by" column is my best reading of `git log -S`. Please check it. ## Work 1. Add each operation to `ROUTE_ID_FIELDS` with a resource kind (for example `job_id`, `user_id`, `mail_account_id`, `mail_folder_id`, `mail_message_id`, `mail_thread_id`). Add `section_id` to `FIELD_CLASSES`. 2. Seed User A fixtures for each kind in `create_fixtures`. For Mail, insert inert rows (as `seed_provider_backed_rows` does for Calendar), because the matrix must not contact a provider. 3. Run `XUSER_MATRIX_ONLY=1 tests/adversarial/run.sh` to a clean result, and paste the output. 4. **Process fix.** Merges into `dev` must run the matrix, or at least `operation_rows()`, so a new unclassified route fails the merge instead of silently disabling the whole probe. A cheap test is enough: import `xuser_matrix.operation_rows` with the checked-in contract.
Author
Owner

Started #472 on job/xuser-472 at 55a2f90feb. Read repository rules, DESIGN, glossary, #331 and #435 with comments. Current branch already classifies 13 of the 20 operations but does not seed Jobs or Mail folder/message/thread identities. Seven Mail operation mappings remain missing. I will add an offline fail-closed CI gate, inert fixtures and explicit denial/state checks, then merge origin/dev once and run the real local matrix. The #461 assertion remains unchanged.

Started #472 on job/xuser-472 at 55a2f90feb8a52d91f8bbc1347889b509af02260. Read repository rules, DESIGN, glossary, #331 and #435 with comments. Current branch already classifies 13 of the 20 operations but does not seed Jobs or Mail folder/message/thread identities. Seven Mail operation mappings remain missing. I will add an offline fail-closed CI gate, inert fixtures and explicit denial/state checks, then merge origin/dev once and run the real local matrix. The #461 assertion remains unchanged.
Author
Owner

Handler classification is recorded in docs/audits/xuser-472.md. The 13 pre-existing mappings lacked seeded Job/folder/message/thread objects; seven Mail mappings were still missing. New fixtures use a disabled Mail account with live memberships and a future pending Job. B/C normal-User admin denial is now asserted explicitly, as are 404 responses for all private Mail and Job operations. Mail and Job state is compared before/after. Admin Job read is a positive control; admin stop and quota are Instance administration and are not replayed as destructive D writes. No handler hole is confirmed by static review.

The offline guard first failed on GET /api/v1/apps/surfaces (app_surfaces_get), an additional missing role policy. Handler app_surface_user requires account scope, so both Apps surface operations now have explicit account policy. Offline output: Cross-User classification gate: 294 operations classified. Three regression tests pass, including synthetic new-route and new-field failures. Commits c936699a5 and 698557269 contain the guard and fixture slice. Fetch/merge origin/dev completed once: Already up to date. The production-build matrix is running. #461 remains unchanged and pending.

Handler classification is recorded in docs/audits/xuser-472.md. The 13 pre-existing mappings lacked seeded Job/folder/message/thread objects; seven Mail mappings were still missing. New fixtures use a disabled Mail account with live memberships and a future pending Job. B/C normal-User admin denial is now asserted explicitly, as are 404 responses for all private Mail and Job operations. Mail and Job state is compared before/after. Admin Job read is a positive control; admin stop and quota are Instance administration and are not replayed as destructive D writes. No handler hole is confirmed by static review. The offline guard first failed on GET /api/v1/apps/surfaces (app_surfaces_get), an additional missing role policy. Handler app_surface_user requires account scope, so both Apps surface operations now have explicit account policy. Offline output: `Cross-User classification gate: 294 operations classified`. Three regression tests pass, including synthetic new-route and new-field failures. Commits c936699a5 and 698557269 contain the guard and fixture slice. Fetch/merge origin/dev completed once: `Already up to date.` The production-build matrix is running. #461 remains unchanged and pending.
Author
Owner

#472 finished (Claude took over after the Codex run hit a usage limit)

Branch job/xuser-472, head 3cfe40a40a4d6d8ba59ac942f6513382b1af85aa (base 55a2f90fe; origin/dev has not moved). Not pushed or merged.

Classification (each handler read, not guessed from the path)

Operation {id} names Owner/admin check
admin_stop_job Instance Job stop_admin_job: fresh admin() before lookup and cancel
admin_get_job Instance Job get_admin_job: admin() before lookup
get_my_job User Job get_user_job: account scope, then owner_user_id == caller, else 404
cancel_my_job User Job cancel_user_job: same owner filter, then cancel_for_user, else 404
update_quota Quota subject User RequireScope<FreshAdmin>; set_quota_override checks actor role again
mail_account Mail account get_account(owner,id) (WHERE owner_id=? AND id=?)
mail_remove_account Mail account delete_account WHERE owner_id=? AND id=?; 0 rows is 404
mail_update_account Mail account get_account(owner,id) before decrypt or provider access
mail_folders Mail account get_account(owner,id) before list_folders(owner,…)
mail_sync_status Mail account get_account(owner,id) first
mail_sync_now Mail account get_account(owner,id) before enqueue
mail_messages Mail folder get_folder(owner,id)
mail_message Mail message get_message: joins a caller-owned folder in its live generation
mail_download_attachment Mail message (section_id = MIME section selector) caller-owned message before section lookup or provider access
mail_correct_sender_category Mail message caller-owned message before sender-rule write
mail_change_read_state Mail message caller-owned message before provider write
mail_remote_content_choice Mail message caller-owned message before sender-rule write
mail_thread_attachments Mail thread owner-scoped alias resolution and query; empty is 404
mail_thread_messages Mail thread owner-scoped (m, a, f all owner_id=?); empty first page is 404
mail_thread_oldest_unread Mail thread owner-scoped; extra existence check, so a foreign thread is 404, not message:null

Details are in docs/audits/xuser-472.md.

Process fix

  • tests/adversarial/run.sh and CI (.forgejo/workflows/ci.yml) run XUSER_CLASSIFY_ONLY=1 python3 tests/adversarial/xuser_matrix.py before any build. It needs no server and no credentials. A new unclassified {id} route or identity field fails with its method, path and operation name.
  • tests/adversarial/test_xuser_classification.py has 3 tests: the checked-in contract passes, a synthetic new {id} route fails, and a synthetic new identity field fails.
  • The offline guard also caught app_surfaces_get and app_surfaces_put, which had no role policy. They now have explicit account policy (handler app_surface_user requires account scope).

Fixtures and assertions added

  • Inert Mail account, live folder generation, unread message, thread and MIME section, plus a pending Job one day in the future. Nothing contacts a provider: Mail sync runs only from Jobs that create, update or sync-now enqueue.
  • Before replay, positive reads prove the IDs name real objects. A reads the Job, folder, message and thread routes, and D reads the admin Job detail.
  • Strict denial: B/C get 403/404 on admin routes and 404 on Job and Mail routes. D gets 404 on personal Job and Mail routes. Anonymous gets 401. This applies to both the A-owned ID and the missing ID.
  • After replay, the probe checks that A's Mail tables, sender rules, memberships and Job row did not change.
  • Fix I made while taking over: the Codex fixture set up a disabled Mail account. The thread-messages and oldest-unread queries join only enabled accounts, so A's positive reads would get 404 and the probe would stop. The account is now enabled but still inert, because no sync Job exists for it.

Matrix run: real local server built from this branch

XUSER_MATRIX_ONLY=1 tests/adversarial/run.sh, exit 0:

Cross-User classification gate: 294 operations classified
finish 200
login web 200 installation 200
cookies [ "__Host-calternal_session secure=true httpOnly=true sameSite=Lax" ]
invite 200
second user finish 200
fixture passkey login requests: 30/30
Two-User OpenAPI matrix: 294 operations classified; 142 operations replayed; 505 A-ID vs missing-ID comparisons across B, C, D and anonymous; 15 identifier routes classified with no local fixture factory; median absolute timing delta 1.9 ms
Job/Mail/quota ownership checks: 77 comparisons; 0 denial failures
Identifier routes without a seeded User-owned object: PUT /api/v1/admin/config (put_config), PUT /api/v1/admin/plugins/{id} (set_instance_plugin), GET /api/v1/appearance/unsplash/images/{photo_id} (appearance_unsplash_thumbnail), POST /api/v1/appearance/unsplash/{photo_id} (appearance_unsplash_select), GET /api/v1/auth/app-password-profiles/{token} (download_app_password_profile), POST /api/v1/auth/app-passwords (create_app_password), POST /api/v1/auth/invites/start (invite_start), DELETE /api/v1/auth/invites/{id} (revoke_invite), POST /api/v1/auth/passkeys/reenrol/start (reenrol_start), POST /api/v1/auth/setup/start (setup_start), GET /api/v1/mail/inbox/messages (mail_unified_inbox), POST /api/v1/notes/from-template (notes_template_create), POST /api/v1/notes/journal/{date}/lines/{hash}/fix (notes_journal_fix_line), PUT /api/v1/notes/templates/default (notes_templates_default_set), PUT /api/v1/plugins/{id}/me (set_user_plugin)

Holes found and fixed

None. All 20 handlers check the owner (or admin) before they look anything up. The live replay confirms this: 0 leaks, 0 denial failures, and no change to A's state. Because no hole was found, no handler regression test was needed. The guard regression tests are listed above.

Gates

No Rust or web code changed; only tests/adversarial/*, CI YAML and one audit doc. There are no crate gates to run.

$ python3 tests/adversarial/test_xuser_classification.py
...
Ran 3 tests in 0.046s

OK
$ XUSER_CLASSIFY_ONLY=1 python3 tests/adversarial/xuser_matrix.py
Cross-User classification gate: 294 operations classified

Pending

  • #461 (owner decision): this job does not change that assertion. The assertion (verify_derived_isolation and D's replay of a Shared Item) is on job/iso-435, not on dev, so it does not run in this matrix. It applies again when #435 merges.
  • Admin stop and quota writes are tested with normal Users only. They are not run with admin authority against A, because that is intended Instance administration.
  • 15 identifier routes still have no seeded fixture (list above). They are classified but not replayed with a real A object.
## #472 finished (Claude took over after the Codex run hit a usage limit) Branch `job/xuser-472`, head `3cfe40a40a4d6d8ba59ac942f6513382b1af85aa` (base 55a2f90fe; `origin/dev` has not moved). Not pushed or merged. ### Classification (each handler read, not guessed from the path) | Operation | `{id}` names | Owner/admin check | |---|---|---| | admin_stop_job | Instance Job | `stop_admin_job`: fresh `admin()` before lookup and cancel | | admin_get_job | Instance Job | `get_admin_job`: `admin()` before lookup | | get_my_job | User Job | `get_user_job`: account scope, then `owner_user_id == caller`, else 404 | | cancel_my_job | User Job | `cancel_user_job`: same owner filter, then `cancel_for_user`, else 404 | | update_quota | Quota subject User | `RequireScope<FreshAdmin>`; `set_quota_override` checks actor role again | | mail_account | Mail account | `get_account(owner,id)` (`WHERE owner_id=? AND id=?`) | | mail_remove_account | Mail account | `delete_account` `WHERE owner_id=? AND id=?`; 0 rows is 404 | | mail_update_account | Mail account | `get_account(owner,id)` before decrypt or provider access | | mail_folders | Mail account | `get_account(owner,id)` before `list_folders(owner,…)` | | mail_sync_status | Mail account | `get_account(owner,id)` first | | mail_sync_now | Mail account | `get_account(owner,id)` before enqueue | | mail_messages | Mail folder | `get_folder(owner,id)` | | mail_message | Mail message | `get_message`: joins a caller-owned folder in its live generation | | mail_download_attachment | Mail message (`section_id` = MIME section selector) | caller-owned message before section lookup or provider access | | mail_correct_sender_category | Mail message | caller-owned message before sender-rule write | | mail_change_read_state | Mail message | caller-owned message before provider write | | mail_remote_content_choice | Mail message | caller-owned message before sender-rule write | | mail_thread_attachments | Mail thread | owner-scoped alias resolution and query; empty is 404 | | mail_thread_messages | Mail thread | owner-scoped (`m`, `a`, `f` all `owner_id=?`); empty first page is 404 | | mail_thread_oldest_unread | Mail thread | owner-scoped; extra existence check, so a foreign thread is 404, not `message:null` | Details are in `docs/audits/xuser-472.md`. ### Process fix - `tests/adversarial/run.sh` and CI (`.forgejo/workflows/ci.yml`) run `XUSER_CLASSIFY_ONLY=1 python3 tests/adversarial/xuser_matrix.py` before any build. It needs no server and no credentials. A new unclassified `{id}` route or identity field fails with its method, path and operation name. - `tests/adversarial/test_xuser_classification.py` has 3 tests: the checked-in contract passes, a synthetic new `{id}` route fails, and a synthetic new identity field fails. - The offline guard also caught `app_surfaces_get` and `app_surfaces_put`, which had no role policy. They now have explicit account policy (handler `app_surface_user` requires account scope). ### Fixtures and assertions added - Inert Mail account, live folder generation, unread message, thread and MIME section, plus a pending Job one day in the future. Nothing contacts a provider: Mail sync runs only from Jobs that create, update or sync-now enqueue. - Before replay, positive reads prove the IDs name real objects. A reads the Job, folder, message and thread routes, and D reads the admin Job detail. - Strict denial: B/C get 403/404 on admin routes and 404 on Job and Mail routes. D gets 404 on personal Job and Mail routes. Anonymous gets 401. This applies to both the A-owned ID and the missing ID. - After replay, the probe checks that A's Mail tables, sender rules, memberships and Job row did not change. - Fix I made while taking over: the Codex fixture set up a *disabled* Mail account. The thread-messages and oldest-unread queries join only enabled accounts, so A's positive reads would get 404 and the probe would stop. The account is now enabled but still inert, because no sync Job exists for it. ### Matrix run: real local server built from this branch `XUSER_MATRIX_ONLY=1 tests/adversarial/run.sh`, exit 0: ``` Cross-User classification gate: 294 operations classified finish 200 login web 200 installation 200 cookies [ "__Host-calternal_session secure=true httpOnly=true sameSite=Lax" ] invite 200 second user finish 200 fixture passkey login requests: 30/30 Two-User OpenAPI matrix: 294 operations classified; 142 operations replayed; 505 A-ID vs missing-ID comparisons across B, C, D and anonymous; 15 identifier routes classified with no local fixture factory; median absolute timing delta 1.9 ms Job/Mail/quota ownership checks: 77 comparisons; 0 denial failures Identifier routes without a seeded User-owned object: PUT /api/v1/admin/config (put_config), PUT /api/v1/admin/plugins/{id} (set_instance_plugin), GET /api/v1/appearance/unsplash/images/{photo_id} (appearance_unsplash_thumbnail), POST /api/v1/appearance/unsplash/{photo_id} (appearance_unsplash_select), GET /api/v1/auth/app-password-profiles/{token} (download_app_password_profile), POST /api/v1/auth/app-passwords (create_app_password), POST /api/v1/auth/invites/start (invite_start), DELETE /api/v1/auth/invites/{id} (revoke_invite), POST /api/v1/auth/passkeys/reenrol/start (reenrol_start), POST /api/v1/auth/setup/start (setup_start), GET /api/v1/mail/inbox/messages (mail_unified_inbox), POST /api/v1/notes/from-template (notes_template_create), POST /api/v1/notes/journal/{date}/lines/{hash}/fix (notes_journal_fix_line), PUT /api/v1/notes/templates/default (notes_templates_default_set), PUT /api/v1/plugins/{id}/me (set_user_plugin) ``` ### Holes found and fixed None. All 20 handlers check the owner (or admin) before they look anything up. The live replay confirms this: 0 leaks, 0 denial failures, and no change to A's state. Because no hole was found, no handler regression test was needed. The guard regression tests are listed above. ### Gates No Rust or web code changed; only `tests/adversarial/*`, CI YAML and one audit doc. There are no crate gates to run. ``` $ python3 tests/adversarial/test_xuser_classification.py ... Ran 3 tests in 0.046s OK $ XUSER_CLASSIFY_ONLY=1 python3 tests/adversarial/xuser_matrix.py Cross-User classification gate: 294 operations classified ``` ### Pending - **#461 (owner decision):** this job does not change that assertion. The assertion (`verify_derived_isolation` and D's replay of a Shared Item) is on `job/iso-435`, not on `dev`, so it does not run in this matrix. It applies again when #435 merges. - Admin stop and quota writes are tested with normal Users only. They are not run with admin authority against A, because that is intended Instance administration. - 15 identifier routes still have no seeded fixture (list above). They are classified but not replayed with a real A object.
Author
Owner

Merged into dev at cefff9134 and pushed. The 15 identifier routes without a seeded fixture remain a follow-up.

Merged into dev at cefff9134 and pushed. The 15 identifier routes without a seeded fixture remain a follow-up.
kayg closed this issue 2026-09-30 02:02:50 +00:00
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal#472
No description provided.