Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions .kiro/specs/rate-limiter-tier-policies/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,43 +138,43 @@ The core middleware (`src/middleware/rateLimit.ts` and `src/middleware/startupAu
- Tag comment: `// Feature: rate-limiter-tier-policies, Property 9: IP key derivation is consistent and namespaced`
- Generate IPv4 addresses; use a spy/mock on `store.increment` to capture the scoped key; assert key starts with the tier prefix, contains `'ip:'`, and contains the IP string

- [ ] 7. Checkpoint — run full property-based test suite
- [x] 7. Checkpoint — run full property-based test suite
- Run `npx jest --testPathPattern="middleware/__tests__" --coverage --coverageReporters=text`
- Confirm all property tests pass and no flakiness is observed.
- Ensure all tests pass, ask the user if questions arise.

- [ ] 8. Harden security documentation in `docs/rate-limiter-tier-policies.md`
- [ ] 8.1 Expand Security Assumptions section to cover all seven design assumptions
- [x] 8. Harden security documentation in `docs/rate-limiter-tier-policies.md`
- [x] 8.1 Expand Security Assumptions section to cover all seven design assumptions
- Add: "`x-revora-rate-tier` is treated as untrusted client input; it is never trusted without a matching secret" (covers Requirement 10.1)
- Add: "Elevated tiers require a valid `x-revora-tier-secret` header matching `process.env.STARTUP_AUTH_TIER_SECRET`" (covers Requirement 10.2)
- Add: "Missing or invalid secret results in silent downgrade to standard tier; no error is returned to the client" (covers Requirement 10.3)
- Add: "The application must be deployed with `app.set('trust proxy', 1)` for stable IP-based keying behind a reverse proxy" (covers Requirement 10.4)
- Add: "The in-memory store is process-local; multi-instance deployments require a shared store implementing `RateLimitStore`" (covers Requirement 10.5)
- _Requirements: 10.1, 10.2, 10.3, 10.4, 10.5_

- [ ] 8.2 Add Abuse Scenarios and Failure Paths sections
- [x] 8.2 Add Abuse Scenarios and Failure Paths sections
- Add Abuse Scenarios subsection: header spoofing (mitigated by secret validation), invalid tier names (silently downgraded), cross-tier counter exhaustion (prevented by key isolation) (covers Requirement 10.6)
- Add Failure Paths subsection: store errors propagate as unhandled exceptions; missing IP falls back to `'unknown'`; missing env var defaults all requests to standard tier (covers Requirement 10.7)
- _Requirements: 10.6, 10.7_

- [ ] 8.3 Add `RateLimitStore` interface documentation for distributed deployments
- [x] 8.3 Add `RateLimitStore` interface documentation for distributed deployments
- Document the `RateLimitStore` interface contract (`increment`, `reset`, `clear?`)
- Note that implementors should catch internal errors and either re-throw as `AppError` or fail-open
- _Requirements: 11.2, 11.3, 11.4, 11.6_

- [ ] 9. Verify middleware is wired into the application
- [ ] 9.1 Confirm `createStartupAuthTierLimiter` is applied to the startup registration route
- [x] 9. Verify middleware is wired into the application
- [x] 9.1 Confirm `createStartupAuthTierLimiter` is applied to the startup registration route
- Search `src/` for the route that handles `POST /startup/register` (or equivalent)
- If the limiter is not yet applied, import `createStartupAuthTierLimiter` from `./middleware/startupAuthRateTierPolicy` and mount it before the route handler
- Ensure `app.set('trust proxy', 1)` is present in the Express bootstrap (covers Requirement 10.4)
- _Requirements: 7.1, 7.2, 7.5_

- [ ] 9.2 Confirm `/health` route is not behind the tier limiter
- [x] 9.2 Confirm `/health` route is not behind the tier limiter
- Verify the health endpoint is registered before or outside the rate-limited router
- Add or confirm an integration test that hits `/health` after exhausting the startup register limit and asserts a 200 response
- _Requirements: 7.5, 9.7_

- [ ] 10. Final coverage check and cleanup
- [x] 10. Final coverage check and cleanup
- Run `npm run test:coverage:backend-011` (or the equivalent Jest coverage command for the middleware files)
- Confirm ≥ 95% statements, branches, functions, and lines for `src/middleware/rateLimit.ts` and `src/middleware/startupAuthRateTierPolicy.ts`
- Remove any temporary debug logs or `console.log` statements introduced during development
Expand Down
65 changes: 58 additions & 7 deletions docs/rate-limiter-tier-policies.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,51 @@ Located in [`src/middleware/rateLimit.ts`](../src/middleware/rateLimit.ts).

---

## Pluggable Store (`RateLimitStore`)

The limiter accepts an optional `store` implementing the `RateLimitStore`
interface, so the in-memory default can be swapped for a shared store (e.g.
Redis) in multi-instance deployments.

### Interface contract

```typescript
interface RateLimitStore {
/** Increment the counter for `key` and return the updated state. */
increment(key: string, windowMs: number): { count: number; resetAt: number };
/** Reset the counter for `key` (useful in tests). */
reset(key: string): void;
/** Clear all counters (test helper). */
clear?(): void;
}
```

Semantics the middleware relies on:

- `increment(key, windowMs)` MUST return `{ count: 1, resetAt: now + windowMs }`
when no active window exists for `key`, and MUST return the **existing**
`resetAt` (not a new one) while the window is still active. This is what
makes the fixed-window counter deterministic.
- `resetAt` is an epoch-milliseconds timestamp; the middleware derives the
`X-RateLimit-Reset` header (epoch seconds) and `Retry-After` from it.
- The middleware never inspects store internals beyond this contract, so a
Redis, Memcached, or Postgres-backed implementation can be dropped in
without changes to tier logic.

### Implementor guidance

- **Shared state**: Use atomic increment + expiry, e.g. Redis `INCR` +
`EXPIRE` (or `SET key 1 EX windowMs NX`) keyed by the full scoped key the
middleware passes (already namespaced with the tier prefix).
- **Failure mode**: Catch internal store errors and either re-throw as an
`AppError` or **fail open** (skip enforcement and log). A dead store must
never crash the request path with an opaque 500; if you prefer strict
fail-closed behavior, document it in the deployment runbook.
- **Clock safety**: `resetAt` should be computed from the store's own clock
(or a monotonic source) to avoid skew between app instances.

---

## Request Headers

| Header | Required for tier | Description |
Expand All @@ -105,7 +150,7 @@ Located in [`src/middleware/rateLimit.ts`](../src/middleware/rateLimit.ts).

```
resolveTier(req):
tier ← lowercase(header("x-revora-rate-tier")) or ""
tier ← trim(lowercase(header("x-revora-rate-tier"))) or ""
if tier not in ["trusted", "internal"]:
return "standard"
secret ← env("STARTUP_AUTH_TIER_SECRET").trim()
Expand Down Expand Up @@ -143,7 +188,12 @@ These headers are set on **every** request, including those that are blocked:

## Security Assumptions

1. **Identity Assertion**: Tier elevation is gated solely on the `x-revora-tier-secret`
1. **Untrusted Tier Header**: `x-revora-rate-tier` is treated as **untrusted
client input**. It is never trusted on its own; elevation to `trusted` or
`internal` always requires a matching secret. Spoofing the header alone
yields no tier privilege.

2. **Identity Assertion**: Tier elevation is gated solely on the `x-revora-tier-secret`
header. This is a **shared secret** pattern — it is not a substitute for
request-level authentication. Protect the secret with the same care as a
signing key.
Expand All @@ -152,22 +202,23 @@ These headers are set on **every** request, including those that are blocked:
in `standard` tier resolution. The server never returns an error that
distinguishes "wrong secret" from "no secret", preventing oracle attacks.

3. **IP-Based Tracking**: Rate limits are tracked per resolved client IP
4. **IP-Based Tracking**: Rate limits are tracked per resolved client IP
(`req.ip`, with `trust proxy = 1`). Ensure the Express app is configured
correctly behind a load-balancer so `req.ip` reflects the real client IP.
A misconfigured proxy could allow a single client to appear as many IPs,
bypassing the limit.

4. **In-Memory Store**: The current `InMemoryRateLimitStore` is **process-local**.
5. **In-Memory Store**: The current `InMemoryRateLimitStore` is **process-local**.
In a multi-instance deployment, counters are not shared between instances,
so effective limits are `numInstances × limit`. Replace the store with a
Redis-backed implementation (using `INCR`/`EXPIRE`) before horizontal scale-out.
Redis-backed implementation (see [Pluggable Store](#pluggable-store-ratelimitstore))
before horizontal scale-out.

5. **Secret Rotation**: Rotating `STARTUP_AUTH_TIER_SECRET` requires a
6. **Secret Rotation**: Rotating `STARTUP_AUTH_TIER_SECRET` requires a
coordinated rolling deploy. During the rotation window, requests with the
old secret will be downgraded to `standard`; plan accordingly.

6. **No Per-User Isolation**: The limiter keys by IP, not by user identity.
7. **No Per-User Isolation**: The limiter keys by IP, not by user identity.
Authenticated user IDs should be layered on top if per-account isolation is
required in future tiers.

Expand Down
Loading
Loading