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:
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user