Skip to content
Merged
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
54 changes: 54 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,60 @@ curl http://localhost:3000/api/bond/not-an-address

---

## Rate limiting

All `/api/*` routes are rate-limited using fixed-window counters stored in Redis.
Two independent counters are checked per request:

| Counter | Scope | Purpose |
|---------|-------|---------|
| Tenant bucket | Per owner / IP | Enforces the tier ceiling shared across all keys of the same owner |
| Key bucket | Per API key id | Prevents a single noisy key from exhausting the shared tenant budget |

A request is rejected when **either** counter exceeds the limit.

### Tiers

| Tier | Default limit (per window) |
|------|---------------------------|
| `free` | 100 requests / 60 s |
| `pro` | 1 000 requests / 60 s |
| `enterprise` | 10 000 requests / 60 s |

Limits are configurable via environment variables (see [Environment Variables](../README.md#environment-variables)).

### Response headers

Every response includes:

| Header | Description |
|--------|-------------|
| `X-RateLimit-Limit` | Maximum requests allowed in the current window |
| `X-RateLimit-Remaining` | Requests remaining (tighter of tenant vs key budget) |
| `X-RateLimit-Reset` | Unix timestamp when the window resets |
| `Retry-After` | Seconds to wait before retrying (only on `429`) |

### Error response (`429`)

```json
{
"error": "Rate limit exceeded. Try again later.",
"code": "rate_limit_exceeded",
"details": { "retryAfter": 42, "limit": 100, "windowSec": 60 }
}
```

### Redis unavailability

Behaviour when Redis is unreachable is controlled by `RATE_LIMIT_FAIL_OPEN`:

| `RATE_LIMIT_FAIL_OPEN` | Behaviour |
|------------------------|-----------|
| `false` (default in production) | Returns `503 Service Unavailable` — fail-closed |
| `true` (default in dev/test) | Passes the request through — fail-open |

---

## Error format

All errors follow this shape:
Expand Down
49 changes: 48 additions & 1 deletion docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,51 @@ All sensitive evidence actions are written to the immutable audit stream:
- `EVIDENCE_UPLOADED` when evidence is stored
- `EVIDENCE_ACCESSED` when evidence is decrypted and returned

Each event includes actor metadata, action name, timestamp, and evidence resource id, enabling compliance queries by actor, resource, and time range.
Each event includes actor metadata, action name, timestamp, and evidence resource id, enabling compliance queries by actor, resource, and time range.

## Rate Limiting

### Architecture

Rate limiting is enforced in `src/middleware/rateLimit.ts` using Redis fixed-window counters. Two independent counters are maintained per request:

1. **Tenant bucket** — keyed by `ratelimit:<namespace>:tenant:<ownerId>:<windowStart>`. Enforces the tier ceiling shared across all API keys belonging to the same owner.
2. **Per-key bucket** — keyed by `ratelimit:<namespace>:key:<keyId>:<windowStart>`. Enforces the same tier ceiling scoped to a single API key, preventing one noisy key from exhausting the shared tenant budget.

A request is rejected (HTTP 429) when **either** counter exceeds the limit for the request's subscription tier.

### Fail-closed mode (production default)

When Redis is unavailable the middleware behaviour is controlled by `RATE_LIMIT_FAIL_OPEN`:

- **`false` (default in `NODE_ENV=production`)** — the middleware returns `503 Service Unavailable`. This is the secure default: a Redis outage cannot be exploited to bypass rate limits.
- **`true` (default in `development` / `test`)** — the middleware passes the request through. Useful for local development where Redis may not always be running.

The catch-block fallback in `src/app.ts` also derives `failOpen` from `NODE_ENV`, so a `validateConfig` failure at startup cannot silently disable limits in production.

### Prometheus metric

`rate_limit_rejected_total` (counter) is incremented on every rejected request with labels:

| Label | Values |
|-------|--------|
| `tier` | `free`, `pro`, `enterprise` |
| `key_id` | API key id, or `none` |
| `reason` | `tenant_limit`, `key_limit`, `redis_unavailable` |

### Environment variables

| Variable | Default | Description |
|----------|---------|-------------|
| `RATE_LIMIT_ENABLED` | `true` | Enable / disable rate limiting |
| `RATE_LIMIT_WINDOW_SEC` | `60` | Fixed-window size in seconds |
| `RATE_LIMIT_MAX_FREE` | `100` | Max requests per window for free tier |
| `RATE_LIMIT_MAX_PRO` | `1000` | Max requests per window for pro tier |
| `RATE_LIMIT_MAX_ENTERPRISE` | `10000` | Max requests per window for enterprise tier |
| `RATE_LIMIT_FAIL_OPEN` | `false` in prod, `true` in dev/test | Fail-open (`true`) or fail-closed (`false`) on Redis error |

### Security considerations

- **Misconfiguration cannot disable limits in production.** The `RATE_LIMIT_FAIL_OPEN` default is `false` when `NODE_ENV=production`, and the startup fallback in `src/app.ts` mirrors this.
- **Key identifiers are never stored in plain text.** When no authenticated record is present, the tenant id is derived from a truncated SHA-256 hash of the API key or Bearer token.
- **Per-key isolation** ensures that a compromised or misbehaving key cannot exhaust the rate budget of other keys belonging to the same tenant.
5 changes: 4 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,16 @@ let rateLimitConfig: { enabled: boolean; windowSec: number; maxFree: number; max
try {
rateLimitConfig = validateConfig(process.env).rateLimit
} catch {
// Fail-closed by default in production so a misconfigured startup cannot
// silently disable rate limiting and expose the API to abuse.
const isProd = process.env.NODE_ENV === 'production'
rateLimitConfig = {
enabled: true,
windowSec: 60,
maxFree: 100,
maxPro: 1000,
maxEnterprise: 10000,
failOpen: true,
failOpen: !isProd,
}
}

Expand Down
8 changes: 6 additions & 2 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,12 @@
.pipe(z.number().int().min(1)),
RATE_LIMIT_FAIL_OPEN: z
.string()
.default('true')
.transform((val: string) => val === 'true'),
.optional()
.transform((val) => {
// Explicit env var always wins; default is fail-closed in production
if (val !== undefined) return val === 'true'
return process.env.NODE_ENV !== 'production'
}),

// Reputation scoring model
REPUTATION_MODEL_VERSION: z.string().default('1.0.0'),
Expand Down Expand Up @@ -444,7 +448,7 @@
if (err instanceof ConfigValidationError) {
console.error(`\n❌ ${err.message}`)
console.error('\nPlease check your .env file or environment variables.\n')
process.exit(1)

Check failure on line 451 in src/config/index.ts

View workflow job for this annotation

GitHub Actions / test

tests/integration/stateSyncToTrust.test.ts

Error: process.exit unexpectedly called with "1" ❯ loadConfig src/config/index.ts:451:15 ❯ src/services/reputationService.ts:37:16 ❯ src/routes/trust.ts:2:1

Check failure on line 451 in src/config/index.ts

View workflow job for this annotation

GitHub Actions / test

src/services/reputationService.test.ts

Error: process.exit unexpectedly called with "1" ❯ loadConfig src/config/index.ts:451:15 ❯ src/services/reputationService.ts:37:16 ❯ src/services/reputationService.test.ts:6:1

Check failure on line 451 in src/config/index.ts

View workflow job for this annotation

GitHub Actions / test

src/jobs/scoreSnapshot.test.ts

Error: process.exit unexpectedly called with "1" ❯ loadConfig src/config/index.ts:451:15 ❯ src/jobs/scoreSnapshot.ts:12:16 ❯ src/jobs/scoreSnapshot.test.ts:2:1

Check failure on line 451 in src/config/index.ts

View workflow job for this annotation

GitHub Actions / test

src/__tests__/identityService.test.ts

Error: process.exit unexpectedly called with "1" ❯ loadConfig src/config/index.ts:451:15 ❯ src/services/reputationService.ts:37:16 ❯ src/services/identityService.ts:113:1
}
throw err
}
Expand Down
Loading
Loading