fix(web-next): enforce manifest rate limits on the production path

bindAllProduction injected NoopRateLimit, so the sign-in budgets the
auth manifest declares were never enforced in production (audit finding
A4/B3). The production ctx now binds InMemoryRateLimit seeded from the
manifest's rateLimit budgets (manifest stays the source of truth);
dev-seed intentionally keeps Noop so local iteration never throttles.
A regression test drives sign-in through the REAL auth production
binder + app router and asserts the 6th failed attempt returns
TOO_MANY_REQUESTS while other IPs stay unaffected. In-memory counters
are per-process; multi-instance deployments need a shared IRateLimit
backend.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 17:30:24 +02:00
parent 88ac2649b7
commit dec24feaa0
3 changed files with 152 additions and 2 deletions

View File

@@ -16,7 +16,12 @@ import {
PayloadJobQueue,
type IJobQueue,
} from "@repo/core-shared/jobs";
import { NoopRateLimit } from "@repo/core-shared/rate-limit";
import {
InMemoryRateLimit,
NoopRateLimit,
type RateLimitBudget,
} from "@repo/core-shared/rate-limit";
import { authManifest } from "@repo/auth";
import { bindProductionBlog } from "@repo/blog/di/bind-production";
import { bindProductionAuth } from "@repo/auth/di/bind-production";
import { bindProductionMarketingPages } from "@repo/marketing-pages/di/bind-production";
@@ -81,6 +86,18 @@ function resolveJobsDevSeed(): { queue: IJobQueue } {
return { queue };
}
/**
* Collect every per-use-case rate-limit budget declared in the feature
* manifests. Budgets are the manifests' source of truth (auth declares
* signIn ip/account budgets today); add further manifests here as features
* declare `rateLimit` entries.
*/
function collectManifestRateLimitBudgets(): RateLimitBudget[] {
return Object.values(authManifest.useCases).flatMap((useCase) =>
"rateLimit" in useCase && useCase.rateLimit ? [...useCase.rateLimit] : [],
);
}
/**
* Production path: swap each feature's mock repository binding for the real
* Payload-backed one. Constructs `new XRepository(config, tracer, logger)` per
@@ -96,7 +113,11 @@ export async function bindAllProduction(): Promise<void> {
tracer,
logger,
queue,
rateLimit: new NoopRateLimit(),
// Real limiter in production (audit finding A4/B3): budgets come from the
// feature manifests, so manifest edits change enforcement without touching
// this file. In-memory ⇒ per-process counters; multi-instance deployments
// need a shared backend behind IRateLimit.
rateLimit: new InMemoryRateLimit(collectManifestRateLimitBudgets()),
};
bindProductionAuth(ctx);
@@ -119,6 +140,8 @@ export async function bindAllDevSeed(): Promise<void> {
tracer,
logger,
queue,
// Dev seed intentionally keeps the no-op limiter so local iteration and
// seeded demos are never throttled; production binds InMemoryRateLimit.
rateLimit: new NoopRateLimit(),
};