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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@ All notable changes to the AnchorNet API are documented here.

[Unreleased]
Added
Security: the metrics endpoints (GET /api/v1/metrics and
/api/v1/metrics/history) are now protected reads. When API_KEY or the new
METRICS_API_KEY is configured they require a matching x-api-key header
(401 otherwise); when neither is set they stay open, matching the existing
write-auth model. METRICS_API_KEY is a read-only credential that unlocks
metrics but not mutating routes, so a monitoring scraper needs no write
key. Metrics reads are now rate-limited per client (METRICS_RATE_LIMIT_MAX,
default 120/min) via a new opt-in limitReads flag on the rate limiter, so
the history endpoint cannot be used as an unlimited load generator.
Snapshot-history retention remains bounded to the most recent 50 entries,
now pinned by a route-level test. src/openapi.ts declares an ApiKeyAuth
security scheme and marks both metrics operations as protected. The
read-limiting is scoped to the metrics mount; global read limiting and a
shared multi-instance store remain owned by the separate rate-limiter issue.
Metrics: GET /api/v1/metrics now reports totalSettledAmount (sum of
settlement amount) and totalFeesCollected (sum of settlement fee),
computed from executed settlements only — pending settlements have
Expand Down
33 changes: 31 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,33 @@ client-side. Each read also appends a timestamped snapshot to an in-memory
rolling history (last 50 reads).
GET /api/v1/metrics/history – the recorded metrics snapshots, oldest first
({ snapshots: [...] }); each snapshot carries the same fields as
GET /api/v1/metrics plus an ISO-8601 timestamp
GET /api/v1/metrics plus an ISO-8601 timestamp. Retention is bounded to the
most recent 50 snapshots (MAX_HISTORY in src/routes/metrics.ts); older ones
are evicted, so the response can never grow without limit.

Metrics access (protected reads). Unlike the other read endpoints, the two
metrics endpoints expose aggregate operational intelligence — participant
counts, total liquidity, settlement volume and protocol fees earned, sampled
over time. That is useful to an operator and equally useful to someone
profiling the network before targeting it, so exposing it is treated as a
deliberate decision rather than a middleware side effect:

- When neither API_KEY nor METRICS_API_KEY is set, metrics reads are open
(unchanged local/dev behaviour).
- When either key is set, GET /api/v1/metrics and GET /api/v1/metrics/history
require a matching x-api-key header and return 401 otherwise.
- A monitoring scraper should be given METRICS_API_KEY — a read-only
credential accepted for metrics but not for any mutating route — so
monitoring keeps working without handing the write key to the scraper. The
primary API_KEY is also accepted for metrics, so an operator already holding
it needs nothing extra. Example scrape:
`curl -H "x-api-key: $METRICS_API_KEY" http://localhost:3001/api/v1/metrics`
- Metrics reads (both endpoints) are rate-limited per client via
METRICS_RATE_LIMIT_MAX (default 120/min), so the history endpoint cannot be
used as a cheap load generator. This read-path limiting is scoped to the
metrics mount and owned by this change; extending rate limiting to all reads
and to a shared multi-instance store is tracked by the separate
rate-limiter issue.
Errors use a uniform envelope: { "error": { "code", "message" } }, including
malformed JSON (400) and oversized request bodies (413,
PAYLOAD_TOO_LARGE). Every response carries an x-request-id header for
Expand Down Expand Up @@ -252,7 +278,10 @@ The application is configured using environment variables. Every environment var
Variable Default Valid Range / Format Description
PORT 3001 Positive integer (typically 1 - 65535) HTTP port the server binds to. Non-numeric values fall back to default.
FEE_BPS 10 Integer between 0 and 10000 (inclusive) Protocol fee in basis points applied to settlements and quotes. The process throws an error and fails to start if configured outside this range.
API_KEY (Unset) Any non-empty string If set, mutating requests (POST/PUT/PATCH/DELETE) must send an matching x-api-key header. Whitespace-only values are treated as unset.
API_KEY (Unset) Any non-empty string If set, mutating requests (POST/PUT/PATCH/DELETE) must send an matching x-api-key header. Whitespace-only values are treated as unset. Also accepted for metrics reads.
METRICS_API_KEY (Unset) Any non-empty string Read-only credential for the metrics endpoints. If either this or API_KEY is set, GET /api/v1/metrics and /history require a matching x-api-key header. This key unlocks metrics only — it cannot authorize mutating requests — so a monitoring scraper can read metrics without the write key. Whitespace-only values are treated as unset.
METRICS_RATE_LIMIT_MAX 120 Positive integer Maximum metrics reads allowed per client within the metrics window. Covers reads (unlike the mutating-only global limiter) so the history endpoint is not an unlimited load generator.
METRICS_RATE_LIMIT_WINDOW_MS 60000 (1 min) Positive integer Length of the rolling window for the metrics read rate limit.
CORS_ORIGIN (Unset) Comma-separated list of origin URLs Allowed CORS origins. Whitespace around entries is trimmed; empty entries are ignored. If unset, every origin is permitted.
BODY_LIMIT 100kb Express bytes-compatible string (e.g., "500kb", "2mb") Maximum accepted JSON request body size. Default is applied if value is blank.
MAINTENANCE_MODE false "1", "true" (case-insensitive) to enable When enabled, mutating requests are rejected with a 503 Service Unavailable error, while read requests continue to function normally.
Expand Down
17 changes: 17 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { errorHandler, notFoundHandler } from "./middleware/errorHandler";
import { requestLogger } from "./middleware/requestLogger";
import { requestId } from "./middleware/requestId";
import { apiKeyAuth } from "./middleware/apiKeyAuth";
import { metricsAuth } from "./middleware/metricsAuth";
import { rateLimiter } from "./middleware/rateLimiter";
import { securityHeaders } from "./middleware/securityHeaders";
import { idempotency } from "./middleware/idempotency";
Expand Down Expand Up @@ -116,8 +117,24 @@ export function createApp(): Express {
app.use("/api/v1/quote", quoteRouter(quotes));
app.use("/api/v1/anchors", anchorRouter(anchors, settlements));
app.use("/api/v1/settlements", settlementRouter(settlements, audit.entries));
// Metrics expose aggregate operational data (participant counts, liquidity
// totals, settlement volume and fees over time). That is deliberately
// treated as protected rather than public: reads require authentication
// whenever a key is configured, and — unlike the global writes-only limiter
// — are rate-limited via `limitReads` so the unauthenticated-or-not history
// endpoint cannot be used as a cheap load generator. When no key is set the
// guard is a no-op, preserving open access for local/dev deployments.
app.use(
"/api/v1/metrics",
metricsAuth(config.apiKey, config.metricsApiKey),
rateLimiter(
{
max: config.metricsRateLimitMax,
windowMs: config.metricsRateLimitWindowMs,
limitReads: true,
},
config.apiKey ?? config.metricsApiKey,
),
metricsRouter({
liquidity,
anchors,
Expand Down
29 changes: 29 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,35 @@ describe("loadConfig", () => {
expect(config.rateLimitWindowMs).toBe(120000);
});

it("leaves the metrics API key unset by default", () => {
expect(loadConfig({}).metricsApiKey).toBeUndefined();
});

it("reads a configured metrics API key", () => {
expect(loadConfig({ METRICS_API_KEY: "scraper" }).metricsApiKey).toBe(
"scraper",
);
});

it("treats a blank metrics API key as unset", () => {
expect(loadConfig({ METRICS_API_KEY: " " }).metricsApiKey).toBeUndefined();
});

it("defaults the metrics read rate limit", () => {
const config = loadConfig({});
expect(config.metricsRateLimitMax).toBe(120);
expect(config.metricsRateLimitWindowMs).toBe(60_000);
});

it("reads the metrics read rate limit from the environment", () => {
const config = loadConfig({
METRICS_RATE_LIMIT_MAX: "10",
METRICS_RATE_LIMIT_WINDOW_MS: "5000",
});
expect(config.metricsRateLimitMax).toBe(10);
expect(config.metricsRateLimitWindowMs).toBe(5000);
});

describe("TRUST_PROXY", () => {
it('parses "true" to boolean true', () => {
expect(loadConfig({ TRUST_PROXY: "true" }).trustProxy).toBe(true);
Expand Down
21 changes: 21 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ export interface Config {
feeBps: number;
/** Optional API key required for mutating requests (disabled if unset). */
apiKey?: string;
/**
* Optional read-only credential that grants access to the metrics endpoints
* (`GET /api/v1/metrics` and `/history`) without granting the write access
* carried by {@link apiKey}. Lets a monitoring scraper read operational
* metrics with a credential that cannot mutate the network. Whitespace-only
* values are treated as unset.
*/
metricsApiKey?: string;
/**
* Allowed CORS origins. `undefined` means no allowlist is configured and
* every origin is permitted (the historical default behavior).
Expand All @@ -30,6 +38,15 @@ export interface Config {
rateLimitMax: number;
/** Length of the rolling window, in milliseconds. */
rateLimitWindowMs: number;
/**
* Maximum metrics **reads** allowed per client within the metrics window.
* Unlike {@link rateLimitMax}, this budget covers the read-only metrics
* endpoints, which are otherwise unlimited. Defaults higher than the
* mutating limit so a polling scraper is not throttled.
*/
metricsRateLimitMax: number;
/** Length of the metrics read rate-limiting window, in milliseconds. */
metricsRateLimitWindowMs: number;
/**
* Express `trust proxy` setting. When enabled behind a load balancer,
* Express trusts the `X-Forwarded-For` header so `req.ip` reflects the
Expand Down Expand Up @@ -121,6 +138,7 @@ export function loadConfig(
env: Record<string, string | undefined> = process.env,
): Config {
const apiKey = env.API_KEY?.trim();
const metricsApiKey = env.METRICS_API_KEY?.trim();
const feeBps = intFromEnv(env.FEE_BPS, 10);

if (feeBps < MIN_FEE_BPS || feeBps > MAX_FEE_BPS) {
Expand All @@ -133,6 +151,7 @@ export function loadConfig(
port: intFromEnv(env.PORT, 3001),
feeBps,
apiKey: apiKey ? apiKey : undefined,
metricsApiKey: metricsApiKey ? metricsApiKey : undefined,
corsOrigins: parseCorsOrigins(env.CORS_ORIGIN),
bodyLimit: env.BODY_LIMIT?.trim() || DEFAULT_BODY_LIMIT,
maintenanceMode: parseBooleanFlag(env.MAINTENANCE_MODE),
Expand All @@ -143,6 +162,8 @@ export function loadConfig(
idempotencyTtlMs: intFromEnv(env.IDEMPOTENCY_TTL_MS, 86_400_000),
rateLimitMax: intFromEnv(env.RATE_LIMIT_MAX, 30),
rateLimitWindowMs: intFromEnv(env.RATE_LIMIT_WINDOW_MS, 60_000),
metricsRateLimitMax: intFromEnv(env.METRICS_RATE_LIMIT_MAX, 120),
metricsRateLimitWindowMs: intFromEnv(env.METRICS_RATE_LIMIT_WINDOW_MS, 60_000),
trustProxy: parseTrustProxy(env.TRUST_PROXY),
};
}
116 changes: 116 additions & 0 deletions src/middleware/metricsAuth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import request from "supertest";
import { createApp } from "../app";

/**
* Metrics reads are protected whenever a credential is configured. These tests
* exercise the three deployment shapes: open (no key), primary-key only, and a
* dedicated read-only metrics key alongside the write key.
*/
describe("metricsAuth", () => {
const originalApiKey = process.env.API_KEY;
const originalMetricsKey = process.env.METRICS_API_KEY;

afterEach(() => {
if (originalApiKey === undefined) delete process.env.API_KEY;
else process.env.API_KEY = originalApiKey;
if (originalMetricsKey === undefined) delete process.env.METRICS_API_KEY;
else process.env.METRICS_API_KEY = originalMetricsKey;
});

describe("open access when no key is configured", () => {
beforeEach(() => {
delete process.env.API_KEY;
delete process.env.METRICS_API_KEY;
});

it("serves current metrics without a key", async () => {
const res = await request(createApp()).get("/api/v1/metrics");
expect(res.status).toBe(200);
});

it("serves metrics history without a key", async () => {
const res = await request(createApp()).get("/api/v1/metrics/history");
expect(res.status).toBe(200);
});
});

describe("protected by the primary API key", () => {
beforeEach(() => {
process.env.API_KEY = "write-secret";
delete process.env.METRICS_API_KEY;
});

it("rejects metrics reads without a key", async () => {
const res = await request(createApp()).get("/api/v1/metrics");
expect(res.status).toBe(401);
expect(res.body.error.code).toBe("UNAUTHORIZED");
});

it("rejects history reads without a key", async () => {
const res = await request(createApp()).get("/api/v1/metrics/history");
expect(res.status).toBe(401);
});

it("rejects metrics reads with the wrong key", async () => {
const res = await request(createApp())
.get("/api/v1/metrics")
.set("x-api-key", "nope");
expect(res.status).toBe(401);
});

it("allows metrics reads with the primary key", async () => {
const res = await request(createApp())
.get("/api/v1/metrics")
.set("x-api-key", "write-secret");
expect(res.status).toBe(200);
expect(res.body.anchors).toBe(0);
});

it("does not trigger a snapshot when a read is rejected", async () => {
const app = createApp();
// Rejected read must not leak data via the history side effect.
await request(app).get("/api/v1/metrics");
const res = await request(app)
.get("/api/v1/metrics/history")
.set("x-api-key", "write-secret");
expect(res.status).toBe(200);
expect(res.body.snapshots).toEqual([]);
});
});

describe("dedicated read-only metrics key", () => {
beforeEach(() => {
process.env.API_KEY = "write-secret";
process.env.METRICS_API_KEY = "read-only-scraper";
});

it("allows metrics reads with the read-only metrics key", async () => {
const res = await request(createApp())
.get("/api/v1/metrics")
.set("x-api-key", "read-only-scraper");
expect(res.status).toBe(200);
});

it("still allows metrics reads with the primary key", async () => {
const res = await request(createApp())
.get("/api/v1/metrics")
.set("x-api-key", "write-secret");
expect(res.status).toBe(200);
});

it("does not let the read-only metrics key authorize writes", async () => {
const res = await request(createApp())
.post("/api/v1/anchors")
.set("x-api-key", "read-only-scraper")
.send({ id: "anchorA" });
expect(res.status).toBe(401);
});

it("rejects an unknown key", async () => {
const res = await request(createApp())
.get("/api/v1/metrics/history")
.set("x-api-key", "guessed");
expect(res.status).toBe(401);
});
});
});
56 changes: 56 additions & 0 deletions src/middleware/metricsAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Read authentication for the metrics endpoints.
*
* The aggregate metrics served by `GET /api/v1/metrics` and
* `GET /api/v1/metrics/history` — anchor and participant counts, total
* liquidity, settlement volume and protocol fees earned, sampled over time —
* describe the operational state of the network. That is business
* intelligence: valuable to an operator, and equally valuable to someone
* profiling the network before targeting it. Exposing it publicly should be a
* deliberate decision, not a side effect of the write-only `apiKeyAuth`. This
* middleware makes metrics reads authenticated by default.
*
* A request is authorized when it presents an `x-api-key` header matching
* **either**:
* - the primary {@link apiKey} (the same credential that authorizes writes),
* so an operator already holding it needs nothing new; or
* - a dedicated, read-only {@link metricsApiKey}, so a monitoring scraper can
* read metrics with a credential that cannot mutate the network.
*
* When neither key is configured the middleware is a no-op (open access),
* matching the "locked only once a key is set" model of `apiKeyAuth` and
* preserving the historical behaviour for local development and deliberately
* open deployments.
*/

import { NextFunction, Request, Response } from "express";
import { ApiError } from "../errors/ApiError";

/**
* Builds the metrics read-authentication middleware.
*
* @param apiKey Primary API key, if configured. Accepted for metrics
* reads so operators reuse a single credential.
* @param metricsApiKey Dedicated read-only metrics key, if configured.
*/
export function metricsAuth(apiKey?: string, metricsApiKey?: string) {
return (req: Request, _res: Response, next: NextFunction): void => {
// No credential configured anywhere: metrics remain openly readable.
if (!apiKey && !metricsApiKey) {
next();
return;
}

const presented = req.header("x-api-key");
const matchesPrimary = apiKey !== undefined && presented === apiKey;
const matchesMetrics =
metricsApiKey !== undefined && presented === metricsApiKey;

if (matchesPrimary || matchesMetrics) {
next();
return;
}

next(ApiError.unauthorized("missing or invalid API key"));
};
}
14 changes: 13 additions & 1 deletion src/middleware/rateLimiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@ export interface RateLimitOptions {
* require the exclusion list to account for the mount prefix.
*/
skipPaths?: string[];
/**
* When `true`, this limiter also counts read (non-mutating) requests toward
* the per-client budget. Defaults `false`, so the global limiter's
* writes-only behaviour is unchanged.
*
* This flag is enabled only for the metrics mount, whose read endpoints
* (notably `GET /history`) are otherwise unlimited. Extending read limiting
* to every route — and the shared, multi-instance store that would require —
* is deliberately left to the separate rate-limiter issue; this PR owns the
* flag and its use for metrics only.
*/
limitReads?: boolean;
}

export function rateLimiter(
Expand All @@ -52,7 +64,7 @@ export function rateLimiter(
const buckets = new Map<string, Bucket>();

return (req: Request, _res: Response, next: NextFunction): void => {
if (!MUTATING_METHODS.has(req.method)) {
if (!MUTATING_METHODS.has(req.method) && !options.limitReads) {
next();
return;
}
Expand Down
Loading