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
2 changes: 2 additions & 0 deletions backend/docs/limits.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ remaining points, and reset timers is not exposed.
| Limit Type | Value | HTTP Status When Exceeded |
|------------|-------|----------------------------|
| JSON Body | 1MB | 413 Payload Too Large |
| JSON Body on `POST /api/v1/events` | 256KB | 413 Payload Too Large |
| Query per param | 2KB | 400 Bad Request |
| Query total | 8KB | 400 Bad Request |
| Header per key | 16KB | 431 Request Header Fields Too Large |
Expand All @@ -73,6 +74,7 @@ remaining points, and reset timers is not exposed.
### Rationale

- **Body (1MB)**: Invoice metadata can be detailed JSON, but unbounded bodies cause memory pressure. 1MB accommodates complex invoices while preventing abuse.
- **Body on the event ingest route (256KB)**: A batch is capped at 100 Soroban events, so 256KB is generous while keeping the indexer ingress far below the general budget. See [security.md](./security.md#event-ingest-endpoint-hardening) for the full framing policy, which also covers `Content-Length` and chunked-encoding handling.
- **Query per param (2KB)**: 64-char hex invoice IDs are ~128 bytes. 2KB provides ample headroom for legitimate values.
- **Query total (8KB)**: Allows multiple filter params (invoice_id, status, business, pagination) without hitting limits.
- **Header per key (16KB)**: Large enough for JWT tokens (~1-4KB) with room for metadata.
Expand Down
37 changes: 32 additions & 5 deletions backend/docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,40 @@

## Event Ingest Endpoint Hardening

The `POST /api/v1/events` endpoint (used by indexers to ingest Soroban events has additional security hardening:
`POST /api/v1/events` is the ingress the indexer uses to submit Soroban events. A request that reaches it is buffered and parsed before any business validation runs, so its framing is validated first, by `src/middleware/event-ingest-limits.ts`. The policy applies exclusively to this route so the rest of the API keeps the default 1 MB budget.

- **Content-Type Enforcement**: Requests must use `application/json` content type; unsupported content types are rejected with `415 Invalid Content Type`.
- **Content-Length Requirement**: A valid `Content-Length` header must be present, with maximum of 256β€―KB; exceeding this limit returns `413 Payload Too Large`.
- **Chunked Encoding Rejection**: Chunked transfer encoding is rejected unless the allowlisted header `X-Allow-Chunked-Encoding` is present (to prevent smuggling via intermediate proxies).
### Framing policy

These protections are applied exclusively to the `/api/v1/events` route to preserve flexibility for other endpoints.
| Condition | Status | Error code |
| --- | --- | --- |
| Content type is not exactly `application/json` (parameters such as `charset` are allowed) | `415` | `INVALID_CONTENT_TYPE` |
| `Content-Length` absent on a non-chunked request | `411` | `CONTENT_LENGTH_REQUIRED` |
| `Content-Length` is not a single non-negative integer (including duplicated headers) | `400` | `INVALID_CONTENT_LENGTH` |
| Declared or actual body above 256 KB | `413` | `BODY_LIMIT_EXCEEDED` |
| `Transfer-Encoding: chunked` without an allowlisted upstream proxy | `400` | `CHUNKED_ENCODING_NOT_ALLOWED` |
| `Transfer-Encoding: chunked` combined with `Content-Length` | `400` | `AMBIGUOUS_REQUEST_FRAMING` |
| Body bytes do not match the declared `Content-Length` | `400` | `CONTENT_LENGTH_MISMATCH` |
| Body is not parseable JSON | `400` | `INVALID_JSON_BODY` |

### Why the checks are ordered this way

The header guard runs before any body parser, and the application-wide 1 MB `express.json` parser explicitly skips this route (see `isEventIngestRequest` in `src/app.ts`). An oversized or ambiguously framed request is therefore refused without buffering its payload, and the 256 KB budget is bound to the route instead of being inherited from the global parser.

Chunked encoding is evaluated before `Content-Length` because HTTP strips `Content-Length` from chunked requests: checking length first would mask a smuggled request behind a `411`. Seeing both headers at once is the canonical request-smuggling signature, since intermediaries disagree on which one delimits the message, so that combination is rejected outright even for allowlisted proxies.

### Chunked-encoding allowlist

Chunked bodies are refused by default. An upstream proxy that must forward them declares itself with the `X-Allow-Chunked-Encoding` header, and the value has to match an entry in `EVENT_INGEST_CHUNKED_PROXY_ALLOWLIST`, a comma-separated list of proxy identifiers. Presence of the header alone is not sufficient; with the variable unset (the default) every chunked request is rejected.

```bash
EVENT_INGEST_CHUNKED_PROXY_ALLOWLIST=edge-proxy-1,edge-proxy-2
```

Only terminate chunked ingest at a proxy you control, and make sure that proxy strips any client-supplied `X-Allow-Chunked-Encoding` header before forwarding.

### Rejection messages

Every rejection message is a constant. Body-parser failures are re-mapped rather than surfaced, because its native messages quote the offending payload bytes (for example `Unexpected token c ... is not valid JSON`). No response from this endpoint echoes request payload content back to the caller.

## CORS Policy

Expand Down
7 changes: 7 additions & 0 deletions backend/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ module.exports = {
lines: 95,
statements: 95,
},
"src/middleware/event-ingest-limits.ts": {
branches: 95,
functions: 95,
lines: 95,
statements: 95,
},
},
collectCoverageFrom: [
"scripts/lib/secret-scan-utils.js",
Expand All @@ -42,6 +48,7 @@ module.exports = {
"src/middleware/access-log.ts",
"src/services/eventProcessor.ts",
"src/middleware/cache-headers.ts",
"src/middleware/event-ingest-limits.ts",
"src/controllers/v1/bids.ts",
"src/lib/entityId.ts",
],
Expand Down
25 changes: 17 additions & 8 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import v1Routes from "./routes/v1";
import webhookRoutes from "./routes/webhooks";
import healthRoutes from "./routes/health";
import { requestLogger } from "./middleware/request-logger";
import { isEventIngestRequest } from "./middleware/event-ingest-limits";
import { lagMonitor } from "./services/lagMonitor";
import { alertRouter, Severity } from "./services/alertRouter";

Expand Down Expand Up @@ -49,14 +50,22 @@ declare global {
// Security Middleware
app.use(helmet());
app.use(cors(corsOptionsDelegate));
app.use(
express.json({
limit: "1mb",
verify: (req: express.Request, res: express.Response, buf: Buffer) => {
req.rawBody = buf;
},
})
);
const globalJsonParser = express.json({
limit: "1mb",
verify: (req: express.Request, res: express.Response, buf: Buffer) => {
req.rawBody = buf;
},
});

// The event ingest route enforces a stricter 256 KB budget with its own parser,
// so the 1 MB parser must not buffer that payload first.
app.use((req, res, next) => {
if (isEventIngestRequest(req)) {
next();
return;
}
globalJsonParser(req, res, next);
});
app.set("trust proxy", true);

// Test middleware to simulate no IP for coverage
Expand Down
Loading
Loading