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
12 changes: 9 additions & 3 deletions docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,15 @@ credential routes `POST /api/auth/login`, `POST /api/auth/password/change`, and
create and modify staff credentials. `POST /api/auth/login` on `3002` and `3003` uses it too. Its default ceiling is 10 attempts per 15 minutes, overridable
with `FLO_AUTH_RATE_LIMIT_MAX`.

Per-resource limiters named in the tables below (for example the order read and write limiters) are
`express-rate-limit` instances declared in their own route file. They apply to every caller
including LAN addresses.
Per-resource limiters named in the tables below are `express-rate-limit` instances declared in
their own route file. Unless noted otherwise, they apply to every caller including LAN addresses.
In [`main/routes/orders.ts`](../../main/routes/orders.ts), `orderWriteRateLimit` and
`orderItemCancelRateLimit` remain separate 60-requests-per-minute buckets (configurable via
`FLO_ORDER_WRITE_RATE_LIMIT_MAX` when set to a positive integer) and exempt authenticated callers
connecting from private, loopback, or Tailscale socket addresses (`isAllowedPrivateIp` on
`req.socket.remoteAddress`, ignoring caller-supplied forwarding headers), while still rate-limiting
public or unauthenticated traffic and returning `{ error: "Too many requests, please try again later." }`
on `429`.

## Main API (`:3001`)

Expand Down
26 changes: 24 additions & 2 deletions main/routes/orders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,27 @@ import { getCurrencyFractionDigits, getCurrencyMinorUnitFactor } from '../countr
import { getTenantCurrency, syncUnpaidBillsForOrder } from './bills';
import expressRateLimit from 'express-rate-limit';
import { randomUUID } from 'crypto';
import { isAllowedPrivateIp } from '../middleware/security';
import { isOperationReference, serializeOperationFailure } from '../lib/operation-failure';

const router = Router();
const ORDER_WRITE_RATE_LIMIT_ERROR = {
error: 'Too many requests, please try again later.',
};

export function resolveOrderWriteRateLimitMax(
rawLimit = process.env.FLO_ORDER_WRITE_RATE_LIMIT_MAX,
): number {
const configured = Number.parseInt(rawLimit || '', 10);
return Number.isFinite(configured) && configured > 0 ? configured : 60;
}

export function shouldSkipOrderWriteRateLimit(req: Request): boolean {
const userId = String((req as Request & { user?: { userId?: string } }).user?.userId || '');
if (!userId) return false;
return isAllowedPrivateIp(req.socket?.remoteAddress || '');
}

const orderReadRateLimit = expressRateLimit({
windowMs: 60 * 1000,
limit: 120,
Expand All @@ -72,18 +90,22 @@ const orderReadRateLimit = expressRateLimit({
});
const orderWriteRateLimit = expressRateLimit({
windowMs: 60 * 1000,
limit: 60,
limit: () => resolveOrderWriteRateLimitMax(),
standardHeaders: true,
legacyHeaders: false,
message: ORDER_WRITE_RATE_LIMIT_ERROR,
skip: shouldSkipOrderWriteRateLimit,
});
// Kept as its own bucket rather than folded into orderWriteRateLimit: this limit
// had its own counter when the route lived in the registrar, and sharing a
// counter would let unrelated order writes exhaust the item-void budget.
const orderItemCancelRateLimit = expressRateLimit({
windowMs: 60 * 1000,
limit: 60,
limit: () => resolveOrderWriteRateLimitMax(),
standardHeaders: true,
legacyHeaders: false,
message: ORDER_WRITE_RATE_LIMIT_ERROR,
skip: shouldSkipOrderWriteRateLimit,
});
const MAX_ORDER_IDEMPOTENCY_KEY_LENGTH = 128;
const MAX_ORDER_ITEMS = 200;
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@
"test:security": "node tests/run-electron-node-test.cjs tests/security-hardening.test.ts && npm run test:api-path-casing-auth",
"test:api-path-casing-auth": "ts-node --transpile-only -P tests/tsconfig.json tests/api-path-casing-auth.test.ts",
"test:staff-authz": "node tests/run-electron-node-test.cjs tests/staff-authz.test.ts",
"test:orders-authz": "node tests/run-electron-node-test.cjs tests/orders-authz.test.ts",
"test:orders-authz": "node tests/run-electron-node-test.cjs tests/orders-authz.test.ts && node tests/run-electron-node-test.cjs tests/order-write-rate-limits.test.ts",
"test:order-item-cancel-restore": "node tests/run-electron-node-test.cjs tests/order-item-cancel-restore.test.ts",
"test:order-totals-recompute": "node tests/run-electron-node-test.cjs tests/order-totals-recompute.test.ts",
"test:issue-718-whatsapp-order-status": "node tests/run-electron-node-test.cjs tests/issue-718-whatsapp-order-status.test.ts",
Expand Down
Loading
Loading