Security & resource hardening: eliminate CPU/disk attack surface

Addresses production CPU spike incident. Key changes:

- Guard OTel exporter behind OTEL_EXPORTER_OTLP_ENDPOINT env var; filter
  tracing to /api paths only — unconditional export was primary suspect
- Remove /healthz endpoint entirely (unauthenticated, hit DB on every call)
- Replace KnownUserMiddleware with POST /api/users/me called once on login
  from TokenSync — eliminates unconditional DB write on every request
- Add DB indexes: (BudgetId, IsDeleted) on Incomes/Outgos, OwnerUserId on
  Budgets, SharedWithUserId and (IsPending, SharedWithEmail) on BudgetShares
- Move UseRateLimiter() before UseStaticFiles() so all requests are throttled
- Replace full-array reorder with move-by-position (id + newIndex) — bounded
  input, fewer DB writes, better API design
- Lock ForwardedHeaders to 172.20.0.0/16 subnet; fixes KnownNetworks
  deprecation warning (0 warnings in build now)
- Add AsNoTracking() to all read-only queries in Summary/Incomes/OutgosController
- FrequencyCalculator returns 0 for unknown enum values instead of throwing
- Thread.Sleep → await Task.Delay in OIDC startup loop
- AllowedHosts locked to budget.stwaddle.com

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Spencer Twaddle
2026-05-06 22:17:18 -05:00
parent 69ec754775
commit ac3dcc2f31
21 changed files with 623 additions and 119 deletions
@@ -0,0 +1,271 @@
// <auto-generated />
using System;
using Budget.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Budget.Infrastructure.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260507024323_AddHardeningIndexes")]
partial class AddHardeningIndexes
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Budget.Core.Models.Budget", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("OwnerUserId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<uint>("xmin")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.HasKey("Id");
b.HasIndex("OwnerUserId");
b.ToTable("Budgets");
});
modelBuilder.Entity("Budget.Core.Models.BudgetShare", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("BudgetId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<bool>("IsPending")
.HasColumnType("boolean");
b.Property<int>("Permission")
.HasColumnType("integer");
b.Property<string>("SharedWithEmail")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("SharedWithUserId")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.HasKey("Id");
b.HasIndex("SharedWithUserId");
b.HasIndex("BudgetId", "SharedWithEmail")
.IsUnique();
b.HasIndex("IsPending", "SharedWithEmail");
b.ToTable("BudgetShares");
});
modelBuilder.Entity("Budget.Core.Models.Income", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal>("Amount")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<Guid>("BudgetId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Frequency")
.HasColumnType("integer");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("BudgetId", "IsDeleted");
b.ToTable("Incomes");
});
modelBuilder.Entity("Budget.Core.Models.KnownUser", b =>
{
b.Property<string>("Id")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTimeOffset>("LastSeenAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.HasKey("Id");
b.ToTable("KnownUsers");
});
modelBuilder.Entity("Budget.Core.Models.Outgo", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal>("Amount")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<Guid>("BudgetId")
.HasColumnType("uuid");
b.Property<string>("Category")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Frequency")
.HasColumnType("integer");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("PaymentSource")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<int>("Type")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("BudgetId", "IsDeleted");
b.ToTable("Outgos");
});
modelBuilder.Entity("Budget.Core.Models.BudgetShare", b =>
{
b.HasOne("Budget.Core.Models.Budget", "Budget")
.WithMany("Shares")
.HasForeignKey("BudgetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Budget");
});
modelBuilder.Entity("Budget.Core.Models.Income", b =>
{
b.HasOne("Budget.Core.Models.Budget", "Budget")
.WithMany("Incomes")
.HasForeignKey("BudgetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Budget");
});
modelBuilder.Entity("Budget.Core.Models.Outgo", b =>
{
b.HasOne("Budget.Core.Models.Budget", "Budget")
.WithMany("Outgos")
.HasForeignKey("BudgetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Budget");
});
modelBuilder.Entity("Budget.Core.Models.Budget", b =>
{
b.Navigation("Incomes");
b.Navigation("Outgos");
b.Navigation("Shares");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,81 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Budget.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddHardeningIndexes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Outgos_BudgetId",
table: "Outgos");
migrationBuilder.DropIndex(
name: "IX_Incomes_BudgetId",
table: "Incomes");
migrationBuilder.CreateIndex(
name: "IX_Outgos_BudgetId_IsDeleted",
table: "Outgos",
columns: new[] { "BudgetId", "IsDeleted" });
migrationBuilder.CreateIndex(
name: "IX_Incomes_BudgetId_IsDeleted",
table: "Incomes",
columns: new[] { "BudgetId", "IsDeleted" });
migrationBuilder.CreateIndex(
name: "IX_BudgetShares_IsPending_SharedWithEmail",
table: "BudgetShares",
columns: new[] { "IsPending", "SharedWithEmail" });
migrationBuilder.CreateIndex(
name: "IX_BudgetShares_SharedWithUserId",
table: "BudgetShares",
column: "SharedWithUserId");
migrationBuilder.CreateIndex(
name: "IX_Budgets_OwnerUserId",
table: "Budgets",
column: "OwnerUserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Outgos_BudgetId_IsDeleted",
table: "Outgos");
migrationBuilder.DropIndex(
name: "IX_Incomes_BudgetId_IsDeleted",
table: "Incomes");
migrationBuilder.DropIndex(
name: "IX_BudgetShares_IsPending_SharedWithEmail",
table: "BudgetShares");
migrationBuilder.DropIndex(
name: "IX_BudgetShares_SharedWithUserId",
table: "BudgetShares");
migrationBuilder.DropIndex(
name: "IX_Budgets_OwnerUserId",
table: "Budgets");
migrationBuilder.CreateIndex(
name: "IX_Outgos_BudgetId",
table: "Outgos",
column: "BudgetId");
migrationBuilder.CreateIndex(
name: "IX_Incomes_BudgetId",
table: "Incomes",
column: "BudgetId");
}
}
}
@@ -58,6 +58,8 @@ namespace Budget.Infrastructure.Data.Migrations
b.HasKey("Id");
b.HasIndex("OwnerUserId");
b.ToTable("Budgets");
});
@@ -96,9 +98,13 @@ namespace Budget.Infrastructure.Data.Migrations
b.HasKey("Id");
b.HasIndex("SharedWithUserId");
b.HasIndex("BudgetId", "SharedWithEmail")
.IsUnique();
b.HasIndex("IsPending", "SharedWithEmail");
b.ToTable("BudgetShares");
});
@@ -134,7 +140,7 @@ namespace Budget.Infrastructure.Data.Migrations
b.HasKey("Id");
b.HasIndex("BudgetId");
b.HasIndex("BudgetId", "IsDeleted");
b.ToTable("Incomes");
});
@@ -210,7 +216,7 @@ namespace Budget.Infrastructure.Data.Migrations
b.HasKey("Id");
b.HasIndex("BudgetId");
b.HasIndex("BudgetId", "IsDeleted");
b.ToTable("Outgos");
});