diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..5f8f4d5 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,63 @@ +# InboxIntel — Security & Data Posture + +The deliberate security posture of this application, so operators know exactly what is and +isn't protected. Complements [AUDIT_REPORT.md](AUDIT_REPORT.md) (point-in-time audit) and +`docs/discovery/multi-provider/06-security-model.md` (future multi-user design). + +## Deployment model this posture assumes +Self-hosted, **single-operator** instance: the person running the server is the person whose +mailbox is synced. All services (API, Postgres, frontend) run in Docker on the operator's own +machine; Postgres and the API bind to loopback/compose-internal only; TLS terminates at the +reverse proxy. + +## What is protected, and how +| Asset | Protection | +|---|---| +| Google OAuth refresh/access tokens | Encrypted at rest (ASP.NET Data Protection, AES); never logged; never sent to the browser | +| Data Protection key ring | Optionally encrypted with an operator-supplied X.509 certificate — set `DataProtection:CertificatePath`/`CertificatePassword`. **Without it, keys sit in plaintext on the `/keys` volume** and anyone with volume access can decrypt stored tokens. Recommended for any shared host. | +| App session | HttpOnly/SameSite=Lax/Secure cookie · sliding 7 d **with an absolute 30 d cap** (`Auth:AbsoluteSessionDays`) | +| Login/abuse | Rate limiting: global 300 req/min per user (or IP when anonymous); `auth` 10/min; export/unsubscribe/AI 20/min (`RateLimiting:*`) | +| Cross-user access | EF global query filters on every tenant-scoped entity (incl. the EmailLabel join) — tested | +| Outbound fetches (unsubscribe etc.) | `SafeHttpGuard` SSRF allowlisting (DNS-rebinding-safe) + redirects disabled | +| Untrusted email content in the UI | Rendered only as escaped React text (no `dangerouslySetInnerHTML`); search highlights use non-HTML sentinels; SPA ships CSP with `script-src 'self'` | +| Secrets | Never committed (`deploy/.env*` git-ignored; pre-commit + CI gitleaks scans); no passwords stored at all (OAuth-only login) | + +## What is deliberately NOT protected (accepted risks — read this) +1. **Email bodies are stored in plaintext in Postgres.** Full-text and semantic search index + the body; encrypted columns cannot be indexed this way. On the assumed single-operator + deployment, the database lives on the operator's own disk, so the threat this would + mitigate (a third party reading the DB files) reduces to "someone with access to your + machine" — mitigate it at the layer that actually works: + - **Use full-disk or volume encryption** on the host (BitLocker/LUKS) — strongly recommended. + - **Encrypt backups** of the `pgdata` volume the same way. + - Before any **multi-user** deployment, revisit per the multi-provider security design + (host admins must not be able to read members' mail — plaintext bodies break that promise). +2. **DB connection is not TLS** — Postgres is only reachable on the compose-internal network / + loopback. If you ever move Postgres to another host, add `SSL Mode=Require` to the + connection string (audit L-4). +3. **No CSRF tokens** — SameSite=Lax cookies + strict CORS + JSON-only bodies make classic + CSRF impractical; revisit if either changes (audit L-1). + +## Data retention (opt-in) +By default the local mailbox copy is kept indefinitely. Two knobs enable automatic purging of +the **local copy only** (your actual Gmail is never touched): +```json +"DataRetention": { + "PurgeTrashedAfterDays": 0, // e.g. 30 — purge local copies of trashed mail after 30 days + "PurgeAllAfterDays": 0 // e.g. 730 — keep at most ~2 years of mail locally +} +``` +`0` disables a knob. A daily background worker applies them. For a full "delete my data" +operation: stop the stack and remove the `pgdata` + `keys` volumes +(`docker compose down -v`), and revoke the app's access in your Google account. + +## Production checklist (beyond compose defaults) +- Set `AllowedHosts` to your real hostname(s) (audit L-2). +- Terminate TLS at the proxy; HSTS is enabled automatically outside Development. +- Provide `DataProtection:CertificatePath` to encrypt the key ring. +- Keep `deploy/.env` readable only by the service user; rotate the DB password if exposed. +- Dependency + secret scanning run in CI on every PR (required checks). + +## Reporting +Single-operator project — if you find a vulnerability, open a private issue or contact the +repository owner directly. diff --git a/src/InboxIntel.Api/Program.cs b/src/InboxIntel.Api/Program.cs index c3bd352..bb50ae0 100644 --- a/src/InboxIntel.Api/Program.cs +++ b/src/InboxIntel.Api/Program.cs @@ -27,9 +27,20 @@ builder.Host.UseSerilog((ctx, cfg) => cfg .WriteTo.Console()); // Persist Data Protection keys so encrypted refresh tokens survive restarts. -builder.Services.AddDataProtection() +// AUDIT M-3: optionally encrypt the Data Protection key ring with an X.509 certificate so +// the keys are not readable in plaintext from the /keys volume (which would otherwise let +// anyone with volume access decrypt all stored refresh tokens). Configure +// DataProtection:CertificatePath (+ CertificatePassword) to enable; without it, keys are +// persisted unprotected and a startup warning documents the residual risk. +var dp = builder.Services.AddDataProtection() .PersistKeysToFileSystem(new DirectoryInfo(builder.Configuration["DataProtection:KeyPath"] ?? "/keys")) .SetApplicationName("InboxIntel"); +var dpCertPath = builder.Configuration["DataProtection:CertificatePath"]; +if (!string.IsNullOrWhiteSpace(dpCertPath)) +{ + dp.ProtectKeysWithCertificate(new System.Security.Cryptography.X509Certificates.X509Certificate2( + dpCertPath, builder.Configuration["DataProtection:CertificatePassword"])); +} builder.Services.AddApplication(); builder.Services.AddInfrastructure(builder.Configuration); diff --git a/src/InboxIntel.Api/appsettings.json b/src/InboxIntel.Api/appsettings.json index edbcb56..abe8b91 100644 --- a/src/InboxIntel.Api/appsettings.json +++ b/src/InboxIntel.Api/appsettings.json @@ -6,7 +6,13 @@ "AutoMigrate": true }, "DataProtection": { - "KeyPath": "/keys" + "KeyPath": "/keys", + "CertificatePath": "", + "CertificatePassword": "" + }, + "DataRetention": { + "PurgeTrashedAfterDays": 0, + "PurgeAllAfterDays": 0 }, "GoogleOAuth": { "ClientId": "", diff --git a/src/InboxIntel.Infrastructure/Configuration/Options.cs b/src/InboxIntel.Infrastructure/Configuration/Options.cs index 3d155e9..1864637 100644 --- a/src/InboxIntel.Infrastructure/Configuration/Options.cs +++ b/src/InboxIntel.Infrastructure/Configuration/Options.cs @@ -72,3 +72,13 @@ public class DigestOptions /// Hour (UTC) the background worker checks for due digests. public int SendHourUtc { get; set; } = 8; } + +/// AUDIT H-3: opt-in local data retention. 0 = disabled (keep forever). +public class DataRetentionOptions +{ + public const string SectionName = "DataRetention"; + /// Purge locally stored emails flagged Trashed older than this many days. + public int PurgeTrashedAfterDays { get; set; } = 0; + /// Purge ALL locally stored emails older than this many days (local copy only). + public int PurgeAllAfterDays { get; set; } = 0; +} diff --git a/src/InboxIntel.Infrastructure/DependencyInjection.cs b/src/InboxIntel.Infrastructure/DependencyInjection.cs index 3ee8c99..51fe403 100644 --- a/src/InboxIntel.Infrastructure/DependencyInjection.cs +++ b/src/InboxIntel.Infrastructure/DependencyInjection.cs @@ -60,6 +60,11 @@ public static class DependencyInjection services.AddScoped(); services.AddHostedService(); + // AUDIT H-3: opt-in local data retention (worker no-ops while disabled). + services.Configure(config.GetSection(DataRetentionOptions.SectionName)); + services.AddScoped(); + services.AddHostedService(); + // HTTP clients // V-01: do NOT follow redirects — a validated external URL must not be able to // 3xx-redirect into an internal target after SafeHttpGuard has checked it. diff --git a/src/InboxIntel.Infrastructure/Retention/RetentionService.cs b/src/InboxIntel.Infrastructure/Retention/RetentionService.cs new file mode 100644 index 0000000..d247411 --- /dev/null +++ b/src/InboxIntel.Infrastructure/Retention/RetentionService.cs @@ -0,0 +1,63 @@ +using InboxIntel.Infrastructure.Configuration; +using InboxIntel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace InboxIntel.Infrastructure.Retention; + +/// +/// AUDIT H-3 (retention): purges locally stored email data past the configured age so the +/// local mailbox copy is not kept forever by default-of-omission. This deletes ONLY the +/// local Postgres rows — the user's actual Gmail is never touched. Both knobs default to 0 +/// (disabled) so existing deployments are unchanged until the operator opts in. +/// +public class RetentionService +{ + private readonly AppDbContext _db; + private readonly DataRetentionOptions _options; + private readonly ILogger _logger; + + public RetentionService(AppDbContext db, IOptions options, ILogger logger) + { + _db = db; + _options = options.Value; + _logger = logger; + } + + /// Runs the configured purges. Returns the number of emails removed. + public async Task PurgeAsync(CancellationToken ct = default) + { + var removed = 0; + var now = DateTimeOffset.UtcNow; + + if (_options.PurgeTrashedAfterDays > 0) + { + var cutoff = now.AddDays(-_options.PurgeTrashedAfterDays); + removed += await PurgeWhereAsync(e => e.IsTrashed && e.SentAtUtc < cutoff, ct); + } + + if (_options.PurgeAllAfterDays > 0) + { + var cutoff = now.AddDays(-_options.PurgeAllAfterDays); + removed += await PurgeWhereAsync(e => e.SentAtUtc < cutoff, ct); + } + + if (removed > 0) + _logger.LogInformation("Retention purge removed {Count} locally stored emails", removed); + return removed; + } + + private async Task PurgeWhereAsync( + System.Linq.Expressions.Expression> predicate, + CancellationToken ct) + { + // RemoveRange (not ExecuteDelete) so dependent rows (labels/attachments) cascade via + // the model on every provider, and the InMemory test provider works identically. + var doomed = await _db.Emails.Where(predicate).ToListAsync(ct); + if (doomed.Count == 0) return 0; + _db.Emails.RemoveRange(doomed); + await _db.SaveChangesAsync(ct); + return doomed.Count; + } +} diff --git a/src/InboxIntel.Infrastructure/Retention/RetentionWorker.cs b/src/InboxIntel.Infrastructure/Retention/RetentionWorker.cs new file mode 100644 index 0000000..0dfa5dd --- /dev/null +++ b/src/InboxIntel.Infrastructure/Retention/RetentionWorker.cs @@ -0,0 +1,53 @@ +using InboxIntel.Infrastructure.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace InboxIntel.Infrastructure.Retention; + +/// +/// Daily driver for (AUDIT H-3). Exits immediately when both +/// retention knobs are 0 (the default) so existing deployments see no behaviour change. +/// +public class RetentionWorker : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly DataRetentionOptions _options; + private readonly ILogger _logger; + + public RetentionWorker(IServiceScopeFactory scopeFactory, IOptions options, ILogger logger) + { + _scopeFactory = scopeFactory; + _options = options.Value; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (_options.PurgeTrashedAfterDays <= 0 && _options.PurgeAllAfterDays <= 0) + { + _logger.LogDebug("RetentionWorker idle: retention is disabled (all knobs 0)."); + return; + } + + _logger.LogInformation("RetentionWorker started; trashed>{Trashed}d, all>{All}d", + _options.PurgeTrashedAfterDays, _options.PurgeAllAfterDays); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + using var scope = _scopeFactory.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + await service.PurgeAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } + catch (Exception ex) + { + _logger.LogError(ex, "Retention purge failed; will retry next cycle."); + } + await Task.Delay(TimeSpan.FromHours(24), stoppingToken); + } + } +} diff --git a/tests/InboxIntel.IntegrationTests/RetentionServiceTests.cs b/tests/InboxIntel.IntegrationTests/RetentionServiceTests.cs new file mode 100644 index 0000000..bbd50ce --- /dev/null +++ b/tests/InboxIntel.IntegrationTests/RetentionServiceTests.cs @@ -0,0 +1,73 @@ +using FluentAssertions; +using InboxIntel.Application.Abstractions; +using InboxIntel.Domain.Entities; +using InboxIntel.Infrastructure.Configuration; +using InboxIntel.Infrastructure.Persistence; +using InboxIntel.Infrastructure.Retention; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Xunit; + +namespace InboxIntel.IntegrationTests; + +/// +/// AUDIT H-3 (retention): the purge removes only what the configuration targets and is a +/// no-op while disabled — locking the "no behaviour change until opted in" guarantee. +/// +public class RetentionServiceTests +{ + private sealed class FakeCurrentUser : ICurrentUser + { + public Guid UserId => Guid.Empty; // worker scope + public bool IsAuthenticated => false; + } + + private static AppDbContext NewDb(string name) => + new(new DbContextOptionsBuilder().UseInMemoryDatabase(name).Options, new FakeCurrentUser()); + + private static async Task SeedAsync(AppDbContext db) + { + var user = Guid.NewGuid(); + db.Emails.AddRange( + new Email { UserId = user, GmailMessageId = "old-trashed", IsTrashed = true, SentAtUtc = DateTimeOffset.UtcNow.AddDays(-100) }, + new Email { UserId = user, GmailMessageId = "new-trashed", IsTrashed = true, SentAtUtc = DateTimeOffset.UtcNow.AddDays(-5) }, + new Email { UserId = user, GmailMessageId = "old-kept", IsTrashed = false, SentAtUtc = DateTimeOffset.UtcNow.AddDays(-100) }); + await db.SaveChangesAsync(); + } + + private static RetentionService Sut(AppDbContext db, int trashedDays = 0, int allDays = 0) => + new(db, Options.Create(new DataRetentionOptions + { + PurgeTrashedAfterDays = trashedDays, + PurgeAllAfterDays = allDays + }), NullLogger.Instance); + + [Fact] + public async Task Disabled_retention_purges_nothing() + { + using var db = NewDb(nameof(Disabled_retention_purges_nothing)); + await SeedAsync(db); + (await Sut(db).PurgeAsync()).Should().Be(0); + (await db.Emails.CountAsync()).Should().Be(3); + } + + [Fact] + public async Task Trashed_purge_removes_only_old_trashed_emails() + { + using var db = NewDb(nameof(Trashed_purge_removes_only_old_trashed_emails)); + await SeedAsync(db); + (await Sut(db, trashedDays: 30).PurgeAsync()).Should().Be(1); + var remaining = await db.Emails.Select(e => e.GmailMessageId).ToListAsync(); + remaining.Should().BeEquivalentTo(new[] { "new-trashed", "old-kept" }); + } + + [Fact] + public async Task Age_purge_removes_everything_past_the_cutoff() + { + using var db = NewDb(nameof(Age_purge_removes_everything_past_the_cutoff)); + await SeedAsync(db); + (await Sut(db, allDays: 30).PurgeAsync()).Should().Be(2); // both 100-day-old emails + (await db.Emails.SingleAsync()).GmailMessageId.Should().Be("new-trashed"); + } +}