From e6a0239436b1d0b3204ab2c562ef31f3584b3cc8 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Wed, 1 Jul 2026 00:20:56 +0200 Subject: [PATCH] fix(security): systemic IDOR safeguard via EF global query filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defense-in-depth tenant isolation: every user-owned entity (Email, Sender, MailThread, MailDomain, Attachment, Label, SyncState, AnalyticsAggregate, WidgetLayout, UnsubscribeItem) gets a global query filter restricting reads to the authenticated user. AppDbContext takes an optional ICurrentUser; CurrentUserId is Guid.Empty for background workers / design-time, which DISABLES the filter so sync and tooling (which already scope by an explicit userId) are unaffected. On the HTTP attack surface a forgotten manual `WHERE UserId ==` can no longer leak another tenant's rows. Phase 1 confirmed no active IDOR; this is preventive, and prioritised now because the upcoming automation engine will add many new queries. Also: moved the Npgsql-only tsvector FTS mapping out of EmailConfiguration into AppDbContext.OnModelCreating, guarded by Database.IsRelational() (Ignored otherwise), so non-relational test providers work — honouring the existing Email.SearchVector comment. Production (Npgsql) model is unchanged; no migration needed. Adds 3 cross-user tenant-isolation integration tests. All 38 tests green. Co-Authored-By: Claude Opus 4.8 --- .../Persistence/AppDbContext.cs | 52 ++++++++++++- .../Configurations/EmailConfiguration.cs | 11 +-- .../TenantIsolationTests.cs | 78 +++++++++++++++++++ 3 files changed, 132 insertions(+), 9 deletions(-) create mode 100644 tests/InboxIntel.IntegrationTests/TenantIsolationTests.cs diff --git a/src/InboxIntel.Infrastructure/Persistence/AppDbContext.cs b/src/InboxIntel.Infrastructure/Persistence/AppDbContext.cs index e4d32ec..47d390a 100644 --- a/src/InboxIntel.Infrastructure/Persistence/AppDbContext.cs +++ b/src/InboxIntel.Infrastructure/Persistence/AppDbContext.cs @@ -8,7 +8,22 @@ namespace InboxIntel.Infrastructure.Persistence; public class AppDbContext : DbContext, IAppDbContext { - public AppDbContext(DbContextOptions options) : base(options) { } + private readonly ICurrentUser? _currentUser; + + public AppDbContext(DbContextOptions options, ICurrentUser? currentUser = null) : base(options) + => _currentUser = currentUser; + + /// + /// Tenant id used by the global query filters below. Resolves to the + /// authenticated user during an HTTP request. It is + /// when there is no current user (background workers, design-time tooling, + /// startup migration) — in which case filtering is DISABLED, because those + /// paths are trusted server code that already scope their own queries by a + /// userId passed in explicitly. The security value is on the HTTP attack + /// surface: a forgotten manual WHERE UserId == can no longer leak + /// another tenant's rows, since the filter restricts to the caller. + /// + public Guid CurrentUserId => _currentUser?.UserId ?? Guid.Empty; public DbSet Users => Set(); public DbSet Emails => Set(); @@ -26,6 +41,41 @@ public class AppDbContext : DbContext, IAppDbContext protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); + + // Defense-in-depth tenant isolation (systemic IDOR safeguard). Every + // user-owned entity is filtered to the current user so a query that forgets + // its manual `WHERE UserId ==` clause cannot leak across tenants. Applied + // uniformly to all user-scoped entities so EF sees no filtered/unfiltered + // navigation mismatch. Bypassed when CurrentUserId is Guid.Empty (workers). + modelBuilder.Entity().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId); + modelBuilder.Entity().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId); + modelBuilder.Entity().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId); + modelBuilder.Entity().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId); + modelBuilder.Entity().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId); + modelBuilder.Entity