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
+1 -1
View File
@@ -24,7 +24,7 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
export const api = {
get: <T>(path: string) => request<T>('GET', path),
post: <T>(path: string, body: unknown) => request<T>('POST', path, body),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
put: <T>(path: string, body: unknown) => request<T>('PUT', path, body),
delete: (path: string) => request<void>('DELETE', path),
};
+2 -2
View File
@@ -4,7 +4,7 @@ import type { IncomeDto, Frequency } from '../types/index';
interface CreateIncomeRequest { name: string; frequency: Frequency; amount: number; }
interface UpdateIncomeRequest { name: string; frequency: Frequency; amount: number; }
interface ReorderRequest { orderedIds: string[]; }
interface MoveRequest { id: string; newIndex: number; }
export function useIncomes(budgetId: string) {
return useQuery({
@@ -44,7 +44,7 @@ export function useDeleteIncome(budgetId: string) {
export function useReorderIncomes(budgetId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (req: ReorderRequest) => api.put<void>(`/api/budgets/${budgetId}/incomes/order`, req),
mutationFn: (req: MoveRequest) => api.put<void>(`/api/budgets/${budgetId}/incomes/order`, req),
onSuccess: () => qc.invalidateQueries({ queryKey: ['budgets', budgetId, 'incomes'] }),
meta: { errorMessage: 'Failed to save order' },
});
+2 -2
View File
@@ -7,7 +7,7 @@ interface CreateOutgoRequest {
frequency: Frequency; amount: number; paymentSource: string | null; notes: string | null;
}
interface UpdateOutgoRequest extends CreateOutgoRequest { id: string; }
interface ReorderRequest { orderedIds: string[]; }
interface MoveRequest { id: string; newIndex: number; }
export function useOutgos(budgetId: string) {
return useQuery({
@@ -61,7 +61,7 @@ export function useDeleteOutgo(budgetId: string) {
export function useReorderOutgos(budgetId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (req: ReorderRequest) => api.put<void>(`/api/budgets/${budgetId}/outgos/order`, req),
mutationFn: (req: MoveRequest) => api.put<void>(`/api/budgets/${budgetId}/outgos/order`, req),
onSuccess: () => qc.invalidateQueries({ queryKey: ['budgets', budgetId, 'outgos'] }),
meta: { errorMessage: 'Failed to save order' },
});
+6 -1
View File
@@ -1,11 +1,16 @@
import { useEffect } from 'react';
import { useAuth } from 'react-oidc-context';
import { setTokenProvider } from '../api/client';
import { setTokenProvider, api } from '../api/client';
export function TokenSync() {
const auth = useAuth();
useEffect(() => {
setTokenProvider(() => auth.user?.access_token ?? null);
if (auth.user?.access_token) {
api.post<void>('/api/users/me', undefined).catch(() => {});
}
}, [auth.user]);
return null;
}
+2 -3
View File
@@ -238,9 +238,8 @@ export function IncomePage() {
if (!over || active.id === over.id) return;
const oldIdx = displayItems.findIndex(i => i.id === active.id);
const newIdx = displayItems.findIndex(i => i.id === over.id);
const reordered = arrayMove(displayItems, oldIdx, newIdx);
setDisplayItems(reordered);
reorderIncomes.mutate({ orderedIds: reordered.map(i => i.id) });
setDisplayItems(items => arrayMove(items, oldIdx, newIdx));
reorderIncomes.mutate({ id: active.id as string, newIndex: newIdx });
};
if (isLoading) return <><BudgetNav /><LoadingSkeleton rows={5} cols={6} /></>;
+2 -3
View File
@@ -384,9 +384,8 @@ export function OutgoPage() {
if (!over || active.id === over.id) return;
const oldIdx = displayItems.findIndex(o => o.id === active.id);
const newIdx = displayItems.findIndex(o => o.id === over.id);
const reordered = arrayMove(displayItems, oldIdx, newIdx);
setDisplayItems(reordered);
reorderOutgos.mutate({ orderedIds: reordered.map(o => o.id) });
setDisplayItems(items => arrayMove(items, oldIdx, newIdx));
reorderOutgos.mutate({ id: active.id as string, newIndex: newIdx });
};
if (isLoading) return <><BudgetNav /><LoadingSkeleton rows={5} cols={9} /></>;