Skip to content

P0: CoreSync HTTP server leaks cross-tenant data — overhaul plan #204

@davidortinau

Description

@davidortinau

Severity: P0 (CRITICAL — cross-tenant data leak)

The CoreSync HTTP endpoints registered by SentenceStudio.Api currently expose every user's per-row data to any authenticated user. This was identified by the API security audit (finding C1) and is the only audit finding deferred from the quick-win remediation series (commits 479b0d719a53bf81 on main).

Root cause

  1. SharedSyncRegistration.ConfigureSyncTables on the Api side registers user-owned tables (e.g. Conversation, Challenge, DailyPlan, LearningResource ownership rows, etc.) without any selectIncrementalQuery / customSnapshotQuery filter. CoreSync therefore generates default SELECT * FROM "Table" WHERE __CT > @since — no per-user predicate.
  2. CoreSync.Http.Server.SyncAgentController.GetBulkChangeSetAsync calls:
    _syncProvider.GetChangesAsync(storeId, SyncDirection.DownloadOnly, syncFilterParameters: Array.Empty<SyncFilterParameter>());
    It does not forward filter parameters from the caller, so even if we added a WHERE "UserProfileId" = @UserProfileId to the registration, the controller never binds a value for @UserProfileId and the query either errors or — depending on provider — returns all rows.
  3. ApplyChangesAsync (upload path) does not validate that incoming SyncItem's UserProfileId column matches the caller's claim, so a malicious client can upload rows with another user's UserProfileId.
  4. ISyncProvider is registered as a singleton, which makes it impossible to flow per-request identity through standard DI.

Remediation design

(Lifted from the in-session plan; the audit timestamp is in checkpoint 005.)

  1. Add filtered queries to SharedSyncRegistration.ConfigureSyncTables for every user-owned table in the PostgreSQL builder:
    tableBuilder
        .WithFilter("UserProfileId", DbType.Guid)
        .WithSelectIncrementalQuery("SELECT * FROM \"Conversation\" WHERE \"UserProfileId\" = @UserProfileId AND __CT > @since")
        .WithCustomSnapshotQuery("SELECT * FROM \"Conversation\" WHERE \"UserProfileId\" = @UserProfileId");
  2. Lock pure-shared tables (LearningResource, VocabularyWord, PhraseConstituent, ResourceVocabularyMapping) to SyncDirection.DownloadOnly and skip the user filter — these are catalog tables every user is allowed to download.
  3. Three sync tables currently lack any ownership column (flagged in docs/database-schema.md with ⚠️): Challenge, Conversation, VocabularyList. Add UserProfileId columns with EF migrations before the wrapper lands, otherwise the filter has nothing to bind to.
  4. Implement TenantScopedSyncProvider : ISyncProvider as a thin decorator that:
    • Resolves IHttpContextAccessor for the current UserProfileId claim,
    • Delegates to PostgreSQLSyncProvider, injecting syncFilterParameters: new[] { new SyncFilterParameter("UserProfileId", currentUserProfileId) } on every GetChangesAsync / GetIncrementalChangesAsync call,
    • In ApplyChangesAsync, rejects (or overwrites) any SyncItem whose UserProfileId column ≠ caller's claim.
  5. Re-register ISyncProvider as Scoped. Provisioning (ApplyProvisionAsync) is one-shot and runs at startup with a separate provider instance constructed manually, so it does not need the scoped lifetime.
  6. Integration tests: two authenticated users sharing the same store id; assert
    • User B's GET /sync/changes does not return rows owned by User A,
    • User B's POST /sync/apply with a payload spoofing User A's UserProfileId is rejected.

Migration risk

  • Mobile clients have already downloaded cross-tenant rows in their local SQLite stores. After the wrapper lands, the server's first GetChangesAsync response will not re-issue deletes for those rows; clients will retain stale data from other users until they reset their local store.
  • Recommendation: gate the wrapper behind a feature flag, ship a mobile-side "reset sync" affordance, then flip the flag in production. Coordinate with checkpoint 005's existing audit-deploy plan.

Pre-conditions before code lands

  • Migration: add UserProfileId to Challenge, Conversation, VocabularyList, backfill existing rows from OwnerUserId / first-creator if any, add FK + index.
  • Verify CoreSync.PostgreSQL.PostgreSQLSyncProvider actually honors selectIncrementalQuery parameters when called via GetChangesAsync(... SyncFilterParameter[]) — if not, escalate upstream to CoreSync.
  • Feature flag wiring in Program.cs.

Tracking

This issue replaces five in-session todos that were marked blocked pending external scope:

  • coresync-sql-filters — add WHERE UserProfileId = @UserProfileId to all user-owned tables.
  • coresync-wrapperTenantScopedSyncProvider decorator.
  • coresync-di — switch ISyncProvider registration to scoped + reconcile startup provisioning.
  • coresync-test — two-user integration tests for upload + download isolation.
  • rubber-duck-design — circulate this design with rubber-duck before implementation.

References

  • docs/database-schema.md — sync table registration matrix and ownership-column gaps.
  • Audit checkpoint: 005-api-security-audit-schema-mark.md.
  • Quick-win remediation commits already on main: d6dd4edc..9a53bf81 (13 commits).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workinggo:needs-researchNeeds investigationrelease:backlogNot yet targetedsquadSquad triage inbox — Lead will assign to a membersquad:washAssigned to Wash (Backend Dev)type:bugSomething broken

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions