SECURITY: cross-user isolation campaign — find ANY way one user can read, change, infer, slow or break another user's data #331

Open
opened 2026-09-28 11:21:04 +00:00 by kayg · 32 comments
Owner

Owner (2026-09-28): 'please find any and all cross-user issues, that's just the worst kind of nightmare for a cloud provider to have.' calternal.cloud is a multi-user instance with open accounts coming (managed accounts, DESIGN §43).

Scope: every path by which user B could affect or learn about user A. Split into (1) a systematic automated probe and (2) a surface-by-surface audit.

1. Automated two-user matrix (new, permanent, in tests/adversarial): create users A (normal), B (normal), C (member of a shared Group), D (admin), and an anonymous client. From the OpenAPI contract, enumerate every route and method. For each route that takes an identifier (item ID, path, share token, note ID, block ID, calendar item, job ID, upload ID, thumbnail hash, CAS hash, collab room, SSE channel, DAV href, app password ID, session ID, invite, public link…), create the resource as A and replay the call as B, C (without a share), anonymous, and with A's IDs in B's paths/bodies/query params/headers. Expected: 404 (not 403, where 403 would confirm existence) or 401/403 per the DESIGN rules, never 200 with A's data, never a side effect on A's data, and identical timing/size profiles for 'exists but not yours' vs 'does not exist' where practical. This must run in the gates' adversarial round and fail on any leak.

2. Audit these surfaces (static review + targeted probes), and report each with a verdict:

  • Authz on every route (IDOR), the admin/step-up rules (#268/#276), agent tokens (data scope).
  • Paths: calternal-fs RESOLVE_BENEATH on every path, symlinks and hard links inside Home, Unicode/NFC/NFD tricks, case folding, .. encodings, per-user roots.
  • Dedup/CAS (.cas): the hash as an existence oracle (does uploading a known file reveal that someone else has it? via timing, quota, response, or 'instant upload'); overwriting a linked file (copy-on-write); refcount races; scrub/repair; quota accounting of shared blobs.
  • Search: the Tantivy index and the semantic (embeddings) index filter by user on every query path, including suggestions, frecency, the 'recent' list, highlights/snippets, counts, facets and see-all; a rebuild never mixes users.
  • Change feed, SSE/live events, collab (Yjs) rooms and awareness, notifications, WebMCP/MCP tools, CalDAV/WebDAV hrefs, sync (calternald) cursors, jobs and job status (#315), uploads (tus IDs), thumbnails and previews (cache keys), exports/backups, trash and versions, public links and Shares (share tokens, Group Shares, revocation, sibling-file exposure).
  • Caching: HTTP cache headers on private responses (Cache-Control: private/no-store), ETags as cross-user oracles, the CDN/Traefik layer, the service worker cache on shared devices.
  • Logs, errors and metrics: no user paths, names or content in errors shown to other users; logs never mix secrets.
  • Resource isolation / noisy neighbour (DoS class): one user's upload storm, search storm, huge note, huge import, purge (#330), or reindex must not degrade others beyond a stated budget. Recommend per-user rate limits, concurrency caps, queue fairness and quotas, and implement the cheap ones.
  • The agent container (DESIGN: per-user rootless container) and AI tools: isolation from other users' Homes.
    Output: docs/audits/cross-user-YYYY-MM-DD.md with each surface, its evidence and a verdict (safe / fixed / issue #). Fix every confirmed leak with a regression test (merge blocker). File anything larger as its own issue with 'SECURITY' in the title.
Owner (2026-09-28): 'please find any and all cross-user issues, that's just the worst kind of nightmare for a cloud provider to have.' calternal.cloud is a multi-user instance with open accounts coming (managed accounts, DESIGN §43). **Scope: every path by which user B could affect or learn about user A.** Split into (1) a systematic automated probe and (2) a surface-by-surface audit. **1. Automated two-user matrix (new, permanent, in tests/adversarial):** create users A (normal), B (normal), C (member of a shared Group), D (admin), and an anonymous client. From the OpenAPI contract, enumerate **every route and method**. For each route that takes an identifier (item ID, path, share token, note ID, block ID, calendar item, job ID, upload ID, thumbnail hash, CAS hash, collab room, SSE channel, DAV href, app password ID, session ID, invite, public link…), create the resource as A and replay the call as B, C (without a share), anonymous, and with A's IDs in B's paths/bodies/query params/headers. Expected: 404 (not 403, where 403 would confirm existence) or 401/403 per the DESIGN rules, never 200 with A's data, never a side effect on A's data, and **identical timing/size profiles** for 'exists but not yours' vs 'does not exist' where practical. This must run in the gates' adversarial round and fail on any leak. **2. Audit these surfaces (static review + targeted probes), and report each with a verdict:** - Authz on every route (IDOR), the admin/step-up rules (#268/#276), agent tokens (data scope). - Paths: calternal-fs RESOLVE_BENEATH on every path, symlinks and hard links inside Home, Unicode/NFC/NFD tricks, case folding, `..` encodings, per-user roots. - **Dedup/CAS (.cas):** the hash as an existence oracle (does uploading a known file reveal that someone else has it? via timing, quota, response, or 'instant upload'); overwriting a linked file (copy-on-write); refcount races; scrub/repair; quota accounting of shared blobs. - Search: the Tantivy index and the semantic (embeddings) index filter by user on every query path, including suggestions, frecency, the 'recent' list, highlights/snippets, counts, facets and see-all; a rebuild never mixes users. - Change feed, SSE/live events, collab (Yjs) rooms and awareness, notifications, WebMCP/MCP tools, CalDAV/WebDAV hrefs, sync (calternald) cursors, jobs and job status (#315), uploads (tus IDs), thumbnails and previews (cache keys), exports/backups, trash and versions, public links and Shares (share tokens, Group Shares, revocation, sibling-file exposure). - Caching: HTTP cache headers on private responses (Cache-Control: private/no-store), ETags as cross-user oracles, the CDN/Traefik layer, the service worker cache on shared devices. - Logs, errors and metrics: no user paths, names or content in errors shown to other users; logs never mix secrets. - Resource isolation / noisy neighbour (DoS class): one user's upload storm, search storm, huge note, huge import, purge (#330), or reindex must not degrade others beyond a stated budget. Recommend per-user rate limits, concurrency caps, queue fairness and quotas, and implement the cheap ones. - The agent container (DESIGN: per-user rootless container) and AI tools: isolation from other users' Homes. **Output:** docs/audits/cross-user-YYYY-MM-DD.md with each surface, its evidence and a verdict (safe / fixed / issue #). **Fix every confirmed leak with a regression test** (merge blocker). File anything larger as its own issue with 'SECURITY' in the title.
Author
Owner

Starting PART 2 surface audit on job/xuser-audit, based on dev at 5c3068936e55c975f083d8c46b401d42db087036 (HEAD matches). I have read CLAUDE.md, CONTEXT.md, the relevant DESIGN decisions, and the full issue. I will post findings here as I verify them and record evidence and verdicts in docs/audits/cross-user-2026-09-28.md.

Starting PART 2 surface audit on `job/xuser-audit`, based on `dev` at `5c3068936e55c975f083d8c46b401d42db087036` (HEAD matches). I have read CLAUDE.md, CONTEXT.md, the relevant DESIGN decisions, and the full issue. I will post findings here as I verify them and record evidence and verdicts in `docs/audits/cross-user-2026-09-28.md`.
Author
Owner

Starting PART 1 automated matrix probe on branch job/xuser-matrix. Base: 5c3068936e55c975f083d8c46b401d42db087036 (dev). I am reviewing the OpenAPI routes and existing adversarial harness before implementation.

Starting PART 1 automated matrix probe on branch `job/xuser-matrix`. Base: `5c3068936e55c975f083d8c46b401d42db087036` (`dev`). I am reviewing the OpenAPI routes and existing adversarial harness before implementation.
Author
Owner

Finding — private HTTP responses have no default cache policy. Evidence: crates/calternal-server/src/security.rs::baseline adds security headers but no Cache-Control; repository search finds cache directives only on selected file/media/public-link handlers (crates/plugins/video/src/routes.rs, crates/plugins/files/src/public.rs). Ordinary authenticated GETs such as search, note, account, calendar and job responses inherit no policy. The service worker has no fetch handler (apps/web/src/service-worker.ts), but the browser HTTP cache can retain responses after sign-out and serve the same URL after another User signs in. I am adding a default private, no-store for API/DAV/discovery responses while preserving route-specific policies, plus a middleware regression test.

Finding — private HTTP responses have no default cache policy. Evidence: `crates/calternal-server/src/security.rs::baseline` adds security headers but no `Cache-Control`; repository search finds cache directives only on selected file/media/public-link handlers (`crates/plugins/video/src/routes.rs`, `crates/plugins/files/src/public.rs`). Ordinary authenticated GETs such as search, note, account, calendar and job responses inherit no policy. The service worker has no fetch handler (`apps/web/src/service-worker.ts`), but the browser HTTP cache can retain responses after sign-out and serve the same URL after another User signs in. I am adding a default `private, no-store` for API/DAV/discovery responses while preserving route-specific policies, plus a middleware regression test.
Author
Owner

Finding while designing the matrix: the current OpenAPI contract has no Group management or membership routes, and ShareInput accepts only recipient_id (a User ID). The data tree has a groups/ directory, but there is no server Group API/model to create the requested C-as-Group-member fixture. I will still provision C as a separate normal User with no Share and exercise the same cross-User isolation cases; the Group persona remains an explicit coverage gap unless the merged contract adds Group support.

Finding while designing the matrix: the current OpenAPI contract has no Group management or membership routes, and `ShareInput` accepts only `recipient_id` (a User ID). The data tree has a `groups/` directory, but there is no server Group API/model to create the requested C-as-Group-member fixture. I will still provision C as a separate normal User with no Share and exercise the same cross-User isolation cases; the Group persona remains an explicit coverage gap unless the merged contract adds Group support.
Author
Owner

Finding — authorized private thumbnails are cached in a shared browser profile. Evidence: crates/plugins/files/src/thumbnails.rs::serve authorizes the viewer, then returns /api/v1/files/thumb/{hash} with Cache-Control: private, max-age=31536000, immutable. The URL is content-hash based, not User based. A browser can reuse User A's cached image for User B without sending a request, bypassing the handler's grant check. I am changing private thumbnail responses to private, no-store and adding an assertion to the existing thumbnail route test. The public-link thumbnail route is separate and revalidates its grant with private, no-cache plus ETag.

Finding — authorized private thumbnails are cached in a shared browser profile. Evidence: `crates/plugins/files/src/thumbnails.rs::serve` authorizes the viewer, then returns `/api/v1/files/thumb/{hash}` with `Cache-Control: private, max-age=31536000, immutable`. The URL is content-hash based, not User based. A browser can reuse User A's cached image for User B without sending a request, bypassing the handler's grant check. I am changing private thumbnail responses to `private, no-store` and adding an assertion to the existing thumbnail route test. The public-link thumbnail route is separate and revalidates its grant with `private, no-cache` plus ETag.
Author
Owner

Finding — the default Instance config gives every User unlimited Home storage. Evidence: InstanceConfig::default sets default_quota: 0, and sync_homes calls clear_quota when that value is zero. Tus caps one User at 16 live uploads and rejects an individual upload above 1 TiB by default, but a User can repeat completed uploads without a total Home limit. One User can therefore fill the Data directory and make writes fail for every User. I am filing this as a separate SECURITY issue. I did not choose a default quota because the product storage tier and self-hosted default are not set in DESIGN; the fix needs an explicit quota policy.

Finding — the default Instance config gives every User unlimited Home storage. Evidence: `InstanceConfig::default` sets `default_quota: 0`, and `sync_homes` calls `clear_quota` when that value is zero. Tus caps one User at 16 live uploads and rejects an individual upload above 1 TiB by default, but a User can repeat completed uploads without a total Home limit. One User can therefore fill the Data directory and make writes fail for every User. I am filing this as a separate SECURITY issue. I did not choose a default quota because the product storage tier and self-hosted default are not set in DESIGN; the fix needs an explicit quota policy.
Author
Owner

Cross-user audit cross-reference: this is the confirmed noisy-neighbour storage availability issue from PART 2 of #331. The evidence and required quota-policy decision are recorded here. The audit will report this as open issue #333.

Cross-user audit cross-reference: this is the confirmed noisy-neighbour storage availability issue from PART 2 of #331. The evidence and required quota-policy decision are recorded here. The audit will report this as open issue #333.
Author
Owner

Confirmed metadata side channel from the durable Files change feed and SSE cursor sequences is tracked as SECURITY #334. Rows and paths remain filtered to the caller, but Instance-wide SQLite sequence gaps reveal aggregate cross-User file activity and timing. See the issue for the migration and regression scope.

Confirmed metadata side channel from the durable Files change feed and SSE cursor sequences is tracked as SECURITY #334. Rows and paths remain filtered to the caller, but Instance-wide SQLite sequence gaps reveal aggregate cross-User file activity and timing. See the issue for the migration and regression scope.
Author
Owner

Finding from the first local-server run: authz_matrix.py exercises POST /api/v1/auth/sessions/revoke-all and revokes its disposable STANDARD Installation bearer. The new probe then failed during fixture setup with fixture Upload create returned HTTP 401. This is a harness ordering interaction, not a server leak. I moved xuser_matrix.py before authz_matrix.py in both run modes so the real two-User probe runs while its fixture bearer is active. I am rerunning the focused local-server campaign.

Finding from the first local-server run: `authz_matrix.py` exercises `POST /api/v1/auth/sessions/revoke-all` and revokes its disposable STANDARD Installation bearer. The new probe then failed during fixture setup with `fixture Upload create returned HTTP 401`. This is a harness ordering interaction, not a server leak. I moved `xuser_matrix.py` before `authz_matrix.py` in both run modes so the real two-User probe runs while its fixture bearer is active. I am rerunning the focused local-server campaign.
Author
Owner

Fixture finding from the second local-server run: all four Upload create/finish calls completed, but the new File was not visible in GET /api/v1/files/entries?path=<fixture-folder> after the current 10-second poll. The probe stopped before issuing cross-User requests, so this is not a security finding. I now require the Folder endpoint's documented 204 and include a bounded listing summary in the failure; I will repeat with the work directory retained to inspect the server-side fixture state.

Fixture finding from the second local-server run: all four Upload create/finish calls completed, but the new File was not visible in `GET /api/v1/files/entries?path=<fixture-folder>` after the current 10-second poll. The probe stopped before issuing cross-User requests, so this is not a security finding. I now require the Folder endpoint's documented 204 and include a bounded listing summary in the failure; I will repeat with the work directory retained to inspect the server-side fixture state.
Author
Owner

Correction to my previous fixture update: the retained run shows that GET /api/v1/files/entries returned HTTP 200 with all four fixture File entries. The probe failed to extract their hashes because it read hash; the OpenAPI field is content_hash. The Files Index is working. I corrected the fixture reader and will rerun the local matrix.

Correction to my previous fixture update: the retained run shows that `GET /api/v1/files/entries` returned HTTP 200 with all four fixture File entries. The probe failed to extract their hashes because it read `hash`; the OpenAPI field is `content_hash`. The Files Index is working. I corrected the fixture reader and will rerun the local matrix.
Author
Owner

Fixture correction from the next local run: Note GET responses carry the ETag in the NoteView.etag JSON field, not an HTTP ETag header. The probe expected a header and stopped before the cross-User requests. I changed the fixture to read the contract's response-body field and use that value for reminder preconditions and cleanup. This was another test-fixture issue, not a confirmed access leak.

Fixture correction from the next local run: Note GET responses carry the ETag in the `NoteView.etag` JSON field, not an HTTP ETag header. The probe expected a header and stopped before the cross-User requests. I changed the fixture to read the contract's response-body field and use that value for reminder preconditions and cleanup. This was another test-fixture issue, not a confirmed access leak.
Author
Owner

Fixture correction from the latest local run: Public Link creation returned HTTP 400 because the fixture used presentation automatic; the Files API accepts auto for a single File. The probe stopped before cross-User requests. I corrected the value to the API enum and will rerun the local campaign.

Fixture correction from the latest local run: Public Link creation returned HTTP 400 because the fixture used presentation `automatic`; the Files API accepts `auto` for a single File. The probe stopped before cross-User requests. I corrected the value to the API enum and will rerun the local campaign.
Author
Owner

Harness correction from the local run: request generation stopped with TypeError: str() got an unexpected keyword argument 'safe' while expanding a parameterized OpenAPI path. The URL-quoting argument was placed on str() instead of urllib.parse.quote(). No cross-User request was sent in this run. I corrected path expansion and am adding an offline pass that builds both request variants for every OpenAPI operation before the next server run.

Harness correction from the local run: request generation stopped with `TypeError: str() got an unexpected keyword argument 'safe'` while expanding a parameterized OpenAPI path. The URL-quoting argument was placed on `str()` instead of `urllib.parse.quote()`. No cross-User request was sent in this run. I corrected path expansion and am adding an offline pass that builds both request variants for every OpenAPI operation before the next server run.
Author
Owner

Finding from the local adversarial run: while /api/v1/admin/search/rebuild was staging a rebuild, live search queries returned HTTP 200 but omitted the unicodenfcsentinel result 64 times. tests/adversarial/search_chaos.py::rebuild_during_queries reported old Index stopped returning a live hit during staged rebuild. This is a search consistency/availability failure, not a cross-User data leak; the probe is still running. I am preserving its existing expectation and will file this separately if the one-round evidence confirms it.

Finding from the local adversarial run: while `/api/v1/admin/search/rebuild` was staging a rebuild, live search queries returned HTTP 200 but omitted the `unicodenfcsentinel` result 64 times. `tests/adversarial/search_chaos.py::rebuild_during_queries` reported `old Index stopped returning a live hit during staged rebuild`. This is a search consistency/availability failure, not a cross-User data leak; the probe is still running. I am preserving its existing expectation and will file this separately if the one-round evidence confirms it.
Author
Owner

More results from the same local adversarial round: authz_matrix.py stopped during fixture setup because its Upload request returned -1 (local adversarial server is unavailable), so its replay cases did not run. The Editor browser probe reported that an undo/redo round trip changed Note text and that a concurrent edit did not reach the saved survivor before timeout; these are content-consistency findings outside the cross-User scope. It also reported 10,000-block collaboration sync at 3,555 ms against a 2,000 ms budget; this is a SLOW-only load result. I will retain the existing expectations and check the server logs and final round result before filing separate issues.

More results from the same local adversarial round: `authz_matrix.py` stopped during fixture setup because its Upload request returned `-1` (`local adversarial server is unavailable`), so its replay cases did not run. The Editor browser probe reported that an undo/redo round trip changed Note text and that a concurrent edit did not reach the saved survivor before timeout; these are content-consistency findings outside the cross-User scope. It also reported 10,000-block collaboration sync at 3,555 ms against a 2,000 ms budget; this is a SLOW-only load result. I will retain the existing expectations and check the server logs and final round result before filing separate issues.
Author
Owner

Fixture finding from the live matrix run: the request loop completed, but A's post-run Photos Stack read failed. The retained server log shows EXIF parser EOF errors for the probe's 68-byte synthetic PNG. The Stack was seeded directly in SQLite while the Photos Index also refreshed that Library root, so the fixture could be replaced during the run. This does not confirm a cross-User leak. I am switching to valid local image/video fixtures, waiting for the real Photos Index refresh, and checking A can read the Stack before replay. I am also making the probe print route findings even if an owner-state check fails.

Fixture finding from the live matrix run: the request loop completed, but A's post-run Photos Stack read failed. The retained server log shows EXIF parser EOF errors for the probe's 68-byte synthetic PNG. The Stack was seeded directly in SQLite while the Photos Index also refreshed that Library root, so the fixture could be replaced during the run. This does not confirm a cross-User leak. I am switching to valid local image/video fixtures, waiting for the real Photos Index refresh, and checking A can read the Stack before replay. I am also making the probe print route findings even if an owner-state check fails.
Author
Owner

Local two-User probe finding (branch job/xuser-matrix, head 5c3a594 plus uncommitted sidecar-fixture change): the OpenAPI-driven matrix exercised 117 identity-bearing operations and flagged three route families for each of B, C, and D when using A's existing identity versus a same-shape missing identity:

  • POST /api/v1/auth/passkeys/remove/start/{id}: HTTP 200 / 298 bytes vs HTTP 400 / 54 bytes.
  • GET /api/v1/files/changes: HTTP 200 / 26 bytes vs HTTP 200 / 694–699 bytes.
  • PUT /api/v1/files/pins: HTTP 200 / 151 bytes vs HTTP 409 / 84 bytes.

The probe has not yet established whether these profile differences expose private data or cause a cross-User mutation. I am inspecting response bodies and route behavior before classifying them as confirmed security leaks.

Local two-User probe finding (branch `job/xuser-matrix`, head `5c3a594` plus uncommitted sidecar-fixture change): the OpenAPI-driven matrix exercised 117 identity-bearing operations and flagged three route families for each of B, C, and D when using A's existing identity versus a same-shape missing identity: - `POST /api/v1/auth/passkeys/remove/start/{id}`: HTTP 200 / 298 bytes vs HTTP 400 / 54 bytes. - `GET /api/v1/files/changes`: HTTP 200 / 26 bytes vs HTTP 200 / 694–699 bytes. - `PUT /api/v1/files/pins`: HTTP 200 / 151 bytes vs HTTP 409 / 84 bytes. The probe has not yet established whether these profile differences expose private data or cause a cross-User mutation. I am inspecting response bodies and route behavior before classifying them as confirmed security leaks.
Author
Owner

Additional local-round result: calendar Log this occurrence 1 first write returned no response before the probe timeout while the concurrent calendar retry storm was still active. This is a request-completion/availability failure under synthetic load, with no cross-User data exposure observed. I am retaining it as a finding for triage; the surrounding retries returned HTTP 200, though they were SLOW.

Additional local-round result: `calendar Log this occurrence 1 first write` returned no response before the probe timeout while the concurrent calendar retry storm was still active. This is a request-completion/availability failure under synthetic load, with no cross-User data exposure observed. I am retaining it as a finding for triage; the surrounding retries returned HTTP 200, though they were SLOW.
Author
Owner

Follow-up to my previous matrix note: the three initial profile differences are harness false positives after route review, with no confirmed leak or cross-User mutation.

  • Passkey removal: /auth/passkeys/remove/start/{id} base64url-decodes the path value and builds a challenge from the authenticated User's own credentials. The probe's same-length replacement (x repeated) is non-canonical base64 for this credential length, so it gets 400 at decoding while A's real credential ID reaches the challenge route (200). I will generate a canonical, decodable absent credential ID.
  • Files changes: crates/plugins/files/src/lib.rs filters the feed by the authenticated user_id; the route's cursor is scoped pagination state. Passing A's high-water cursor to B suppresses B's earlier entries, while cursor 0 returns B's own entries. The response-size difference is expected.
  • Files pins: the first probe PUT changes only the requester's own settings and increments its revision. Reusing revision 0 for the second comparison correctly gets 409. I will read the requester's current revision before each side of this comparison.

The owner-state checks found no change to A's Files, Notes, Calendar, Photos, Search, Share, session, or Notification fixtures. I am correcting these comparison cases and will report the clean matrix result.

Follow-up to my previous matrix note: the three initial profile differences are harness false positives after route review, with no confirmed leak or cross-User mutation. - Passkey removal: `/auth/passkeys/remove/start/{id}` base64url-decodes the path value and builds a challenge from the authenticated User's own credentials. The probe's same-length replacement (`x` repeated) is non-canonical base64 for this credential length, so it gets 400 at decoding while A's real credential ID reaches the challenge route (200). I will generate a canonical, decodable absent credential ID. - Files changes: `crates/plugins/files/src/lib.rs` filters the feed by the authenticated `user_id`; the route's cursor is scoped pagination state. Passing A's high-water cursor to B suppresses B's earlier entries, while cursor 0 returns B's own entries. The response-size difference is expected. - Files pins: the first probe PUT changes only the requester's own settings and increments its revision. Reusing revision 0 for the second comparison correctly gets 409. I will read the requester's current revision before each side of this comparison. The owner-state checks found no change to A's Files, Notes, Calendar, Photos, Search, Share, session, or Notification fixtures. I am correcting these comparison cases and will report the clean matrix result.
Author
Owner

The attack.py summary reports 256 findings. Most are latency warnings under sustained shared-host load. The non-load results are: (1) two appearance validation probes received the editor proxy's 502 local adversarial server is unavailable instead of the expected 4xx, although the server process was alive at the end; (2) the calendar burst expected 120 items but observed 0; (3) after one recurring Log write timed out, a later occurrence check found the same Event identity where it expected a distinct entry. The semantic-recall fixture also did not return the expected Note within 120 seconds. These are not confirmed cross-User leaks; I will file the app consistency/availability cases separately and leave their test expectations intact. The authz_matrix.py fixture-upload failure also prevented its route replays.

The `attack.py` summary reports 256 findings. Most are latency warnings under sustained shared-host load. The non-load results are: (1) two appearance validation probes received the editor proxy's `502 local adversarial server is unavailable` instead of the expected 4xx, although the server process was alive at the end; (2) the calendar burst expected 120 items but observed 0; (3) after one recurring Log write timed out, a later occurrence check found the same Event identity where it expected a distinct entry. The semantic-recall fixture also did not return the expected Note within 120 seconds. These are not confirmed cross-User leaks; I will file the app consistency/availability cases separately and leave their test expectations intact. The `authz_matrix.py` fixture-upload failure also prevented its route replays.
Author
Owner

The focused live rerun now passes against the local server built from this branch. Exact output:

Two-User OpenAPI matrix: 248 operations classified; 117 operations replayed; 408 A-ID vs missing-ID comparisons across B, C, D and anonymous; 12 identifier routes classified with no local fixture factory; median absolute timing delta 8.1 ms
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), 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), 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)

Exit status was 0. The probe found no A-owned response data or state changes. The earlier three route profile differences were comparison false positives, now corrected in the harness. The Live Photo fixture now uses valid indexed media plus normal XMP sidecar uploads.

The focused live rerun now passes against the local server built from this branch. Exact output: ``` Two-User OpenAPI matrix: 248 operations classified; 117 operations replayed; 408 A-ID vs missing-ID comparisons across B, C, D and anonymous; 12 identifier routes classified with no local fixture factory; median absolute timing delta 8.1 ms 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), 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), 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) ``` Exit status was 0. The probe found no A-owned response data or state changes. The earlier three route profile differences were comparison false positives, now corrected in the harness. The Live Photo fixture now uses valid indexed media plus normal XMP sidecar uploads.
Author
Owner

Round-two harness status: the isolation section emitted no finding(...) messages before the script moved to collab. The collaboration section then crashed because note("Doomed note") returned None and the harness indexed doomed['id'] without checking the create error. The collab cases after that fixture are untested in this run. The remaining full-runner phases are still proceeding.

Round-two harness status: the `isolation` section emitted no `finding(...)` messages before the script moved to `collab`. The collaboration section then crashed because `note("Doomed note")` returned `None` and the harness indexed `doomed['id']` without checking the create error. The collab cases after that fixture are untested in this run. The remaining full-runner phases are still proceeding.
Author
Owner

Cross-User availability follow-up: the live search result loss during staged rebuild is filed as SECURITY #345. It is an Indexer availability defect (64 HTTP 200 queries omitted a known live hit), not a cross-User data disclosure. The issue requests a regression with a second User querying while overflow recovery and staged publication run.

Cross-User availability follow-up: the live search result loss during staged rebuild is filed as SECURITY #345. It is an Indexer availability defect (64 HTTP 200 queries omitted a known live hit), not a cross-User data disclosure. The issue requests a regression with a second User querying while overflow recovery and staged publication run.
Author
Owner

The out-of-scope consistency observations are filed for triage without changing their expectations: Editor undo/concurrent-edit data loss is #346; Calendar photo-burst and recurring Log identity mismatches are #347. SECURITY #345 tracks the cross-User search availability finding. These items need reproduction without shared-host load before assigning causes.

The out-of-scope consistency observations are filed for triage without changing their expectations: Editor undo/concurrent-edit data loss is #346; Calendar photo-burst and recurring Log identity mismatches are #347. SECURITY #345 tracks the cross-User search availability finding. These items need reproduction without shared-host load before assigning causes.
Author
Owner

After the required merge of dev, the local server build and focused matrix both passed. I added an A-owned Notes conversion preview through the production API and mapped the merged preview-page, apply, and undo routes to that job identity. The merged contract run reported:

Two-User OpenAPI matrix: 252 operations classified; 120 operations replayed; 420 A-ID vs missing-ID comparisons across B, C, D and anonymous; 12 identifier routes classified with no local fixture factory; median absolute timing delta 7.4 ms

The process exited 0 with no matrix failures. No cross-User data disclosure or mutation was confirmed. The final workspace gates are next.

After the required merge of `dev`, the local server build and focused matrix both passed. I added an A-owned Notes conversion preview through the production API and mapped the merged preview-page, apply, and undo routes to that job identity. The merged contract run reported: ``` Two-User OpenAPI matrix: 252 operations classified; 120 operations replayed; 420 A-ID vs missing-ID comparisons across B, C, D and anonymous; 12 identifier routes classified with no local fixture factory; median absolute timing delta 7.4 ms ``` The process exited 0 with no matrix failures. No cross-User data disclosure or mutation was confirmed. The final workspace gates are next.
Author
Owner

The merged local adversarial run started the authorization matrix, but fixture setup stopped before the matrix checks: create_guest_share() upload setup returned -1 (no response) during the 20,000-file watcher-overflow and Search rebuild load. No cross-user leak was confirmed because the matrix did not reach its probes. The same run observed 1,231 missing committed Search hits during staged rebuild; see open issue #336. The one adversarial run was stopped at its 25-minute limit.

The merged local adversarial run started the authorization matrix, but fixture setup stopped before the matrix checks: `create_guest_share()` upload setup returned `-1` (no response) during the 20,000-file watcher-overflow and Search rebuild load. No cross-user leak was confirmed because the matrix did not reach its probes. The same run observed 1,231 missing committed Search hits during staged rebuild; see open issue #336. The one adversarial run was stopped at its 25-minute limit.
Author
Owner

Finished PART 2 cross-User audit. Branch job/xuser-audit is pushed.

HEAD: 433df45dfad1f8cbd246a11a92bee0c342c140f1

Built: Added default Cache-Control: private, no-store for /api, /dav, and /.well-known responses while preserving explicit route policies. Changed private content-hash thumbnails to private, no-store. Added middleware and thumbnail-header regressions. The surface-by-surface evidence and verdicts are in docs/audits/cross-user-2026-09-28.md.

Confirmed open risks: SECURITY #333 tracks unlimited default Home storage; SECURITY #334 tracks Instance-wide change-feed cursor gaps; SECURITY #345 tracks known search results disappearing during staged rebuild (64 HTTP 200 misses). The search result issue is availability, not cross-User disclosure. Editor and Calendar consistency observations are filed as #346 and #347 for reproduction without shared-host load.

Route matrix: Final local output classified 248 OpenAPI operations, replayed 117 identity-bearing operations, and ran 408 A-ID versus missing-ID comparisons across B, C, D, and anonymous. Median absolute timing delta was 8.1 ms. It found no A-owned response data or state changes. Twelve identity routes had no seeded User-owned object. Group membership is not implemented, so C was tested as a normal User without a Share.

Adversarial round: Ran tests/adversarial/run.sh once. It finished with findings and nonzero status; it was not repeated. The search probe found the #345 misses. attack.py reported 256 findings, mostly SLOW latency under shared-host load; two appearance checks got proxy-generated 502 responses and one request timed out. Editor/Calendar observations are in #346/#347. attack2.py's isolation section printed no finding before the collab fixture failed (note() returned no object); later collaboration cases were not tested. Dedup medians were known=0.887s new=0.781s ratio=1.14. The restart probe reported zero findings. The separate OpenAPI matrix completed successfully.

Final gates: Cargo format, Clippy, full Cargo tests, web check, and web tests passed. Exact output excerpts:

cargo fmt --check: exit 0 (no output)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 18m 01s
Finished `test` profile [unoptimized + debuginfo] target(s) in 8m 34s
test result: ok. 120 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 151.99s
svelte-check found 0 errors and 0 warnings
 Test Files  110 passed (110)
      Tests  710 passed (710)
   Duration  103.82s (transform 58%, environment 17%, import 14%, tests 9%, setup 3%)

cargo test produced 72 result summaries; each reported zero failed tests. cargo clean removed 18,717 files (16.0 GiB). apps/web/build and apps/web/.svelte-kit were removed.

Known gaps: No Group Share fixture exists; 12 identifier routes lack a seeded User-owned resource; CDN/Traefik cache configuration is outside this repository; the Agent peer-network boundary was not attacked; the local authz fixture and collaboration harness did not complete in this round.

Decisions not set in DESIGN: I chose private, no-store as the default for API/DAV/discovery responses and preserved route-specific cache policy. I did not set a Home quota because DESIGN does not define managed-account or self-hosted storage defaults. I did not change the global cursor schema/API contract; SECURITY #334 tracks that migration. I treated C as an unshared normal User because Group membership is not implemented.

Finished PART 2 cross-User audit. Branch `job/xuser-audit` is pushed. **HEAD:** `433df45dfad1f8cbd246a11a92bee0c342c140f1` **Built:** Added default `Cache-Control: private, no-store` for `/api`, `/dav`, and `/.well-known` responses while preserving explicit route policies. Changed private content-hash thumbnails to `private, no-store`. Added middleware and thumbnail-header regressions. The surface-by-surface evidence and verdicts are in `docs/audits/cross-user-2026-09-28.md`. **Confirmed open risks:** SECURITY #333 tracks unlimited default Home storage; SECURITY #334 tracks Instance-wide change-feed cursor gaps; SECURITY #345 tracks known search results disappearing during staged rebuild (64 HTTP 200 misses). The search result issue is availability, not cross-User disclosure. Editor and Calendar consistency observations are filed as #346 and #347 for reproduction without shared-host load. **Route matrix:** Final local output classified 248 OpenAPI operations, replayed 117 identity-bearing operations, and ran 408 A-ID versus missing-ID comparisons across B, C, D, and anonymous. Median absolute timing delta was 8.1 ms. It found no A-owned response data or state changes. Twelve identity routes had no seeded User-owned object. Group membership is not implemented, so C was tested as a normal User without a Share. **Adversarial round:** Ran `tests/adversarial/run.sh` once. It finished with findings and nonzero status; it was not repeated. The search probe found the #345 misses. `attack.py` reported 256 findings, mostly SLOW latency under shared-host load; two appearance checks got proxy-generated 502 responses and one request timed out. Editor/Calendar observations are in #346/#347. `attack2.py`'s isolation section printed no finding before the collab fixture failed (`note()` returned no object); later collaboration cases were not tested. Dedup medians were `known=0.887s new=0.781s ratio=1.14`. The restart probe reported zero findings. The separate OpenAPI matrix completed successfully. **Final gates:** Cargo format, Clippy, full Cargo tests, web check, and web tests passed. Exact output excerpts: ```text cargo fmt --check: exit 0 (no output) Finished `dev` profile [unoptimized + debuginfo] target(s) in 18m 01s Finished `test` profile [unoptimized + debuginfo] target(s) in 8m 34s test result: ok. 120 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 151.99s svelte-check found 0 errors and 0 warnings Test Files 110 passed (110) Tests 710 passed (710) Duration 103.82s (transform 58%, environment 17%, import 14%, tests 9%, setup 3%) ``` `cargo test` produced 72 result summaries; each reported zero failed tests. `cargo clean` removed 18,717 files (16.0 GiB). `apps/web/build` and `apps/web/.svelte-kit` were removed. **Known gaps:** No Group Share fixture exists; 12 identifier routes lack a seeded User-owned resource; CDN/Traefik cache configuration is outside this repository; the Agent peer-network boundary was not attacked; the local authz fixture and collaboration harness did not complete in this round. **Decisions not set in DESIGN:** I chose `private, no-store` as the default for API/DAV/discovery responses and preserved route-specific cache policy. I did not set a Home quota because DESIGN does not define managed-account or self-hosted storage defaults. I did not change the global cursor schema/API contract; SECURITY #334 tracks that migration. I treated C as an unshared normal User because Group membership is not implemented.
Author
Owner

Finished: automated two-User matrix probe

Built: an OpenAPI-driven probe that fails closed when a route has no role, identity-field, generic-ID, or header classification. It replays A-owned IDs and same-shape missing IDs across B, C, D (Admin), and Anonymous. It checks response profiles, private markers and identities, server errors, and A's resource state after the replay. The probe seeds Files, Notes, Tasks, Log entries, Photos, Shares, Public Links, Uploads, Sessions, Calendar, AI, Notifications, and the Notes conversion job through the local production server or test-only provider rows.

tests/adversarial/run.sh runs the probe before authz_matrix.py; XUSER_MATRIX_ONLY=1 supports the focused local run. The current contract has no Groups or Group-member API, so C is an independent User. A's Share goes to a separate User, Dora.

Files: tests/adversarial/xuser_matrix.py, tests/adversarial/run.sh.

Head: 0c5ee2f92cd5264e7a24ab97b78668c64d451e56 (job/xuser-matrix, pushed).

Local live probe output (verbatim):

Two-User OpenAPI matrix: 252 operations classified; 120 operations replayed; 420 A-ID vs missing-ID comparisons across B, C, D and anonymous; 12 identifier routes classified with no local fixture factory; median absolute timing delta 7.4 ms

The process exited 0. It found no cross-User disclosure, server error, or change to A's resources. The initial response-profile anomalies were false positives from a malformed Base64 negative, a User-scoped cursor, and a stale Pins revision; the comparison now uses valid absent IDs and fresh per-User revisions.

Gates

Output excerpts are verbatim. cargo fmt --check produced no output and exited 0.

Finished `dev` profile [unoptimized + debuginfo] target(s) in 15m 19s
Finished `test` profile [unoptimized + debuginfo] target(s) in 7m 27s
test result: ok. 120 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 115.06s
test result: ok. 103 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 63.73s
svelte-check found 0 errors and 0 warnings
 Test Files  112 passed (112)
      Tests  726 passed (726)
   Duration  156.10s (transform 58%, environment 15%, import 15%, tests 9%, setup 3%)

The cargo test log contains 72 per-target summaries: 1,375 passed, 0 failed, 12 ignored.

Known gaps

Twelve identity routes have classification but no local fixture factory: put_config, set_instance_plugin, appearance_unsplash_thumbnail, appearance_unsplash_select, invite_start, revoke_invite, reenrol_start, setup_start, notes_template_create, notes_journal_fix_line, notes_templates_default_set, and set_user_plugin. Provider-backed Calendar, AI, and Notification objects use inert test database rows because those external providers are not available in the local probe. There is no Group API to exercise.

Decisions not covered by DESIGN.md

  • Treat /api/v1/files/changes cursors as User-scoped pagination state. The probe checks A's private markers but does not require the response size to match when B's own feed is paged from a different cursor.
  • Use canonical absent Base64url values for passkey path IDs and refresh each requester's Pins revision before each comparison write.
  • Build the Live Photo Stack from valid indexed JPEG/MP4 Items and XMP sidecars through the normal Files and Photos indexing path.
  • Keep the Groups fixture out until the API and member model exist.
## Finished: automated two-User matrix probe **Built:** an OpenAPI-driven probe that fails closed when a route has no role, identity-field, generic-ID, or header classification. It replays A-owned IDs and same-shape missing IDs across B, C, D (Admin), and Anonymous. It checks response profiles, private markers and identities, server errors, and A's resource state after the replay. The probe seeds Files, Notes, Tasks, Log entries, Photos, Shares, Public Links, Uploads, Sessions, Calendar, AI, Notifications, and the Notes conversion job through the local production server or test-only provider rows. `tests/adversarial/run.sh` runs the probe before `authz_matrix.py`; `XUSER_MATRIX_ONLY=1` supports the focused local run. The current contract has no Groups or Group-member API, so C is an independent User. A's Share goes to a separate User, Dora. **Files:** `tests/adversarial/xuser_matrix.py`, `tests/adversarial/run.sh`. **Head:** `0c5ee2f92cd5264e7a24ab97b78668c64d451e56` (`job/xuser-matrix`, pushed). **Local live probe output (verbatim):** ``` Two-User OpenAPI matrix: 252 operations classified; 120 operations replayed; 420 A-ID vs missing-ID comparisons across B, C, D and anonymous; 12 identifier routes classified with no local fixture factory; median absolute timing delta 7.4 ms ``` The process exited 0. It found no cross-User disclosure, server error, or change to A's resources. The initial response-profile anomalies were false positives from a malformed Base64 negative, a User-scoped cursor, and a stale Pins revision; the comparison now uses valid absent IDs and fresh per-User revisions. ## Gates Output excerpts are verbatim. `cargo fmt --check` produced no output and exited 0. ``` Finished `dev` profile [unoptimized + debuginfo] target(s) in 15m 19s Finished `test` profile [unoptimized + debuginfo] target(s) in 7m 27s test result: ok. 120 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 115.06s test result: ok. 103 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 63.73s svelte-check found 0 errors and 0 warnings Test Files 112 passed (112) Tests 726 passed (726) Duration 156.10s (transform 58%, environment 15%, import 15%, tests 9%, setup 3%) ``` The `cargo test` log contains 72 per-target summaries: 1,375 passed, 0 failed, 12 ignored. ## Known gaps Twelve identity routes have classification but no local fixture factory: `put_config`, `set_instance_plugin`, `appearance_unsplash_thumbnail`, `appearance_unsplash_select`, `invite_start`, `revoke_invite`, `reenrol_start`, `setup_start`, `notes_template_create`, `notes_journal_fix_line`, `notes_templates_default_set`, and `set_user_plugin`. Provider-backed Calendar, AI, and Notification objects use inert test database rows because those external providers are not available in the local probe. There is no Group API to exercise. ## Decisions not covered by DESIGN.md - Treat `/api/v1/files/changes` cursors as User-scoped pagination state. The probe checks A's private markers but does not require the response size to match when B's own feed is paged from a different cursor. - Use canonical absent Base64url values for passkey path IDs and refresh each requester's Pins revision before each comparison write. - Build the Live Photo Stack from valid indexed JPEG/MP4 Items and XMP sidecars through the normal Files and Photos indexing path. - Keep the Groups fixture out until the API and member model exist.
Author
Owner

Both parts merged (orchestrator): the permanent OpenAPI-driven matrix probe (252 operations classified, 120 replayed, 420 comparisons, 0 leaks; unclassified new routes fail the run) and the surface audit (no-store on private responses; private thumbnails no longer long-cached). Follow-ups: #333 (quota policy, owner decision pending), #334 (opaque per-user cursors, job running), #345 (search availability during rebuild, job running); the 12 routes without fixture factories and Group membership (once Groups exist) extend the matrix. Kept open until #333/#334/#345 close.

Both parts merged (orchestrator): the permanent OpenAPI-driven matrix probe (252 operations classified, 120 replayed, 420 comparisons, **0 leaks**; unclassified new routes fail the run) and the surface audit (no-store on private responses; private thumbnails no longer long-cached). Follow-ups: #333 (quota policy, owner decision pending), #334 (opaque per-user cursors, job running), #345 (search availability during rebuild, job running); the 12 routes without fixture factories and Group membership (once Groups exist) extend the matrix. Kept open until #333/#334/#345 close.
Author
Owner

The motion-spring adversarial round found a concurrent dedup data-integrity and cross-User overwrite issue. I filed the detailed report as #375. The same probe also found upload/copy byte mismatches and a missing restored path. This needs controlled reproduction before merge.

The motion-spring adversarial round found a concurrent dedup data-integrity and cross-User overwrite issue. I filed the detailed report as #375. The same probe also found upload/copy byte mismatches and a missing restored path. This needs controlled reproduction before merge.
Author
Owner

Hygiene review: a later adversarial round found a cross-User dedup overwrite, tracked in #375. Keeping the isolation campaign open until that regression is fixed and the cross-User check is repeated.

Hygiene review: a later adversarial round found a cross-User dedup overwrite, tracked in #375. Keeping the isolation campaign open until that regression is fixed and the cross-User check is repeated.
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#331
No description provided.