Skip to content
Open
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,21 @@ CORS_ORIGIN=*
# Rate limiting (per client IP, applied to /api)
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_MAX=100
RATE_LIMIT_MAX_KEYS=10000

# Honour X-Forwarded-For only behind a trusted reverse proxy
TRUST_PROXY=false

# Per-actor mutation budgets (see docs/ABUSE_CONTROLS.md)
MUTATION_RATE_LIMIT_MAX_KEYS=10000
MUTATION_RATE_LIMIT_TRANSFERS_WINDOW_MS=60000
MUTATION_RATE_LIMIT_TRANSFERS_MAX=30
MUTATION_RATE_LIMIT_USERS_WINDOW_MS=60000
MUTATION_RATE_LIMIT_USERS_MAX=20
MUTATION_RATE_LIMIT_QUOTE_WINDOW_MS=60000
MUTATION_RATE_LIMIT_QUOTE_MAX=60
MUTATION_RATE_LIMIT_ADMIN_WINDOW_MS=60000
MUTATION_RATE_LIMIT_ADMIN_MAX=30

# Error tracking
ERROR_TRACKING_ENABLED=true
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ When preparing a new release:

### Added

- Route-family mutation rate limits and sanitized correlation IDs for abuse
control on transfer writes, user writes, quote, and admin diagnostics.
Actor keys are truncated token fingerprints (never raw secrets); limiter
tables are bounded by `RATE_LIMIT_MAX_KEYS` /
`MUTATION_RATE_LIMIT_MAX_KEYS`. `TRUST_PROXY` gates `X-Forwarded-For`.
See `docs/ABUSE_CONTROLS.md`.

- Cursor pagination for `GET /api/transfers` and `GET /api/audit`. Pass
`?cursor=` (with optional `?order=asc|desc`) to page by an indexed position
instead of a row offset; responses carry a `pageInfo` block with
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ The application is configured using environment variables (typically defined in
| `CORS_ORIGIN` | Allowed CORS origin | `*` |
| `RATE_LIMIT_WINDOW_MS` | Time window for rate limiting (ms) | `60000` |
| `RATE_LIMIT_MAX` | Max requests per window | `100` |
| `RATE_LIMIT_MAX_KEYS` | Max distinct client identities retained by the global limiter | `10000` |
| `TRUST_PROXY` | Honour `X-Forwarded-For` when resolving client IP (`true`/`1`) | `false` |
| `MUTATION_RATE_LIMIT_*` | Per-actor budgets for transfer/user/quote/admin mutations (see `docs/ABUSE_CONTROLS.md`) | see docs |
| `BODY_LIMIT` | Max JSON request body size | `100kb` |
| `REQUEST_TIMEOUT_MS` | Request timeout before returning 503 (ms) | `15000` |
| `DB_POOL_MIN` | Minimum database connections in pool | `2` |
Expand Down
70 changes: 70 additions & 0 deletions docs/ABUSE_CONTROLS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Abuse controls and correlation IDs

RemitFlow Backend bounds high-volume retries and automated abuse on mutation
and provider-adjacent routes, and propagates a safe correlation id on every
request so incidents can be traced without leaking account secrets.

## Global limit

Every `/api/*` request is subject to an IP-keyed fixed-window limit:

| Setting | Env | Default |
|---|---|---|
| Window | `RATE_LIMIT_WINDOW_MS` | `60000` |
| Max requests | `RATE_LIMIT_MAX` | `100` |
| Max tracked keys | `RATE_LIMIT_MAX_KEYS` | `10000` |

Responses carry `X-RateLimit-Limit`, `X-RateLimit-Remaining`,
`X-RateLimit-Reset`, and `X-RateLimit-Policy`. Exhausted budgets answer
`429` with `Retry-After` and a JSON error that includes `requestId`.

## Mutation / route-family limits

Authenticated write paths use a **stricter actor-keyed** budget on top of the
global limit. The actor key is a truncated SHA-256 fingerprint of the API
token (or admin key) — never the raw secret. Public quote uses the client IP.

| Family | Routes | Env (window / max) | Default max / min |
|---|---|---|---|
| `transfers` | `POST /api/transfers`, claim, cancel, archive, unarchive | `MUTATION_RATE_LIMIT_TRANSFERS_*` | 30 / 60s |
| `users` | `POST /api/users` | `MUTATION_RATE_LIMIT_USERS_*` | 20 / 60s |
| `quote` | `GET /api/quote` | `MUTATION_RATE_LIMIT_QUOTE_*` | 60 / 60s |
| `admin` | `GET /api/admin/diagnostics` | `MUTATION_RATE_LIMIT_ADMIN_*` | 30 / 60s |

Shared cap: `MUTATION_RATE_LIMIT_MAX_KEYS` (default `10000`).

Actors are isolated: one token burning its transfer budget does not exhaust
another token's budget. Limiter tables are bounded. Expired windows are pruned
first; when every key is still live, a new identity receives 429 with
`Retry-After` until a slot expires. This preserves active budgets under an
identity flood, at the cost of delaying new identities when the table is full.

## Proxy trust

`TRUST_PROXY=true` uses Express `trust proxy` for one proxy hop. Rate
limiting reads Express's resolved `req.ip`, so an attacker-controlled left-most
`X-Forwarded-For` value cannot rotate the identity when the trusted proxy
appends the connecting address. Enable this only when the deployment has
exactly one trusted reverse proxy; otherwise leave it off until the app's
trust proxy setting matches the deployment topology.

## Correlation IDs

Every request gets a correlation id:

- Honour inbound `X-Request-Id` or `X-Correlation-Id` when the value is at
most 128 characters and matches `[A-Za-z0-9._:-]+`.
- Otherwise generate a fresh UUID.
- Echo the same value on both `X-Request-Id` and `X-Correlation-Id`.
- Expose it as `req.id` / `req.correlationId` and on every error envelope
as `error.requestId`.

Unsafe inbound values (spaces, quotes, oversized strings) are discarded so
callers cannot smuggle tokens or free-form PII into logs via the header.

## Test behaviour

When `NODE_ENV=test`, rate limiters are no-ops unless
`ENABLE_RATE_LIMIT_IN_TEST=1` (or a limiter is constructed with
`forceInTest: true`). This keeps the functional suite independent of the
abuse budget while still allowing focused regression coverage.
23 changes: 21 additions & 2 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const maintenanceMode = require('./middleware/maintenanceMode');
const jsonError = require('./middleware/jsonError');
const notFound = require('./middleware/notFound');
const errorHandler = require('./middleware/errorHandler');
const { resolveClientIp } = require('./utils/clientIdentity');

/**
* Build and configure the Express application.
Expand All @@ -26,6 +27,12 @@ const errorHandler = require('./middleware/errorHandler');
function createApp() {
const app = express();

// Only honour X-Forwarded-* when explicitly configured. Blind trust lets
// clients rotate forged IPs and evade the global abuse budget.
if (config.trustProxy) {
app.set('trust proxy', 1);
}

// Core middleware.
app.use(securityHeaders);
app.use(cacheControl({ policy: config.cache.defaultPolicy }));
Expand All @@ -46,8 +53,20 @@ function createApp() {
}
app.use(requestLogger);

// Basic abuse protection on the API surface.
app.use('/api', rateLimit(config.rateLimit));
// Basic abuse protection on the API surface (IP-keyed, bounded table).
app.use(
'/api',
rateLimit({
name: 'global',
windowMs: config.rateLimit.windowMs,
max: config.rateLimit.max,
maxKeys: config.rateLimit.maxKeys,
trustProxy: config.trustProxy,
keyGenerator(req) {
return resolveClientIp(req, { trustProxy: config.trustProxy });
},
})
);

// Block all non-health API traffic while maintenance mode is active.
app.use(maintenanceMode);
Expand Down
55 changes: 52 additions & 3 deletions src/config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,27 @@

require('dotenv').config();

/**
* Parse a positive integer env var with a fallback.
* @param {string|undefined} value
* @param {number} fallback
* @returns {number}
*/
function intEnv(value, fallback) {
const parsed = parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}

/**
* Centralized application configuration.
* Values are read from environment variables with sensible defaults
* so the app can boot even without a .env file present.
*/
const env = process.env.NODE_ENV || 'development';
const isTest = env === 'test';

const config = {
env: process.env.NODE_ENV || 'development',
env,
port: parseInt(process.env.PORT, 10) || 3000,

baseCurrency: process.env.DEFAULT_BASE_CURRENCY || 'USD',
Expand All @@ -34,9 +48,44 @@ const config = {
// Per-request time budget before a 503 is returned.
requestTimeoutMs: parseInt(process.env.REQUEST_TIMEOUT_MS, 10) || 15 * 1000,

/**
* Whether to trust `X-Forwarded-For` when resolving the client IP.
* Off by default so untrusted clients cannot rotate IPs to bypass limits.
* Enable only behind a reverse proxy that strips/forges the header safely.
*/
trustProxy: process.env.TRUST_PROXY === 'true' || process.env.TRUST_PROXY === '1',

rateLimit: {
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 60 * 1000,
max: parseInt(process.env.RATE_LIMIT_MAX, 10) || 100,
windowMs: intEnv(process.env.RATE_LIMIT_WINDOW_MS, 60 * 1000),
max: intEnv(process.env.RATE_LIMIT_MAX, isTest ? 10_000 : 100),
maxKeys: intEnv(process.env.RATE_LIMIT_MAX_KEYS, 10_000),
},

/**
* Stricter per-actor budgets for mutation / expensive routes.
* Defaults are deliberately higher than a single integration test suite
* needs, while still bounding automated abuse of provider-backed paths.
*/
mutationRateLimit: {
maxKeys: intEnv(process.env.MUTATION_RATE_LIMIT_MAX_KEYS, 10_000),
transfers: {
windowMs: intEnv(process.env.MUTATION_RATE_LIMIT_TRANSFERS_WINDOW_MS, 60 * 1000),
// Generous under test so the suite does not trip the abuse budget; production
// defaults stay tight enough to bound provider-quota exhaustion.
max: intEnv(process.env.MUTATION_RATE_LIMIT_TRANSFERS_MAX, isTest ? 10_000 : 30),
},
users: {
windowMs: intEnv(process.env.MUTATION_RATE_LIMIT_USERS_WINDOW_MS, 60 * 1000),
max: intEnv(process.env.MUTATION_RATE_LIMIT_USERS_MAX, isTest ? 10_000 : 20),
},
quote: {
windowMs: intEnv(process.env.MUTATION_RATE_LIMIT_QUOTE_WINDOW_MS, 60 * 1000),
max: intEnv(process.env.MUTATION_RATE_LIMIT_QUOTE_MAX, isTest ? 10_000 : 60),
},
admin: {
windowMs: intEnv(process.env.MUTATION_RATE_LIMIT_ADMIN_WINDOW_MS, 60 * 1000),
max: intEnv(process.env.MUTATION_RATE_LIMIT_ADMIN_MAX, isTest ? 10_000 : 30),
},
},

errorTracking: {
Expand Down
2 changes: 2 additions & 0 deletions src/controllers/adminController.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ function getDiagnostics(req, res) {
fee: config.fee,
maxTransferAmount: config.maxTransferAmount,
stellar: config.stellar,
trustProxy: config.trustProxy,
rateLimit: config.rateLimit,
mutationRateLimit: config.mutationRateLimit,
errorTrackingEnabled: config.errorTracking.enabled,
},
stats: {
Expand Down
2 changes: 2 additions & 0 deletions src/middleware/adminAuth.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ function adminAuth(req, res, next) {
return next(new ApiError(401, 'Unauthorized'));
}

// Expose for downstream actor-keyed rate limiting (never log the raw value).
req.adminToken = token;
next();
}

Expand Down
55 changes: 55 additions & 0 deletions src/middleware/mutationRateLimit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
'use strict';

const config = require('../config');
const rateLimit = require('./rateLimit');
const { resolveActorKey } = require('../utils/clientIdentity');

/**
* Route-family mutation rate limiters.
*
* Applied after authentication so the actor key is a token fingerprint
* (never the raw secret). Quote stays IP-keyed because it is public.
* Each limiter is independently bounded so one hot route cannot starve
* another family's budget, and the shared maxKeys cap keeps memory finite.
*/

function buildLimiter(family, overrides = {}) {
const settings = config.mutationRateLimit[family] || {};
const trustProxy = config.trustProxy;

return rateLimit({
name: `mutation:${family}`,
windowMs: overrides.windowMs || settings.windowMs,
max: overrides.max || settings.max,
maxKeys: overrides.maxKeys || config.mutationRateLimit.maxKeys,
trustProxy,
forceInTest: Boolean(overrides.forceInTest),
keyGenerator(req) {
return `${family}:${resolveActorKey(req, { trustProxy })}`;
},
});
}

const transfers = buildLimiter('transfers');
const users = buildLimiter('users');
const quote = buildLimiter('quote');
const admin = buildLimiter('admin');

/**
* Reset every mutation limiter. Intended for tests only.
*/
function resetAll() {
transfers.reset();
users.reset();
quote.reset();
admin.reset();
}

module.exports = {
transfers,
users,
quote,
admin,
resetAll,
buildLimiter,
};
Loading