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
6 changes: 5 additions & 1 deletion docs/WEBHOOK_EVENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@ these event type strings when creating a subscription and can expect the
documented payload schema on every delivery.

> **Delivery mechanics.** Every POST carries the JSON payload in the body,
> signed with `X-Predictify-Signature: sha256=<hex>` (HMAC-SHA256 over the raw
> signed with `X-Predictify-Signature: sha256=<hex>` (HMAC-SHA-256 over the raw
> body bytes using the subscription secret). A unique `X-Predictify-Delivery`
> UUID is included in each request so subscribers can detect and deduplicate
> retries. See [webhook delivery & DLQ](./webhooks-dlq.md) for retry schedules
> and dead-letter queue behaviour.
>
> **Catalog retrieval.** `GET /api/webhooks` now returns a strong `ETag` and
> supports conditional revalidation with `If-None-Match`; a matching request
> returns `304 Not Modified` without a body.

---

Expand Down
5 changes: 5 additions & 0 deletions docs/rate-limiting.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ Public read endpoints (`GET /api/markets`, `GET /api/leaderboard`) are throttled
per client IP using a sliding-window counter. Authenticated requests that include
a `Bearer` token bypass the limiter.

Authentication endpoints under `/api/auth` are also rate-limited with a per-identity
window (default 5 requests per minute) to reduce abuse from repeated challenge or
verification attempts. The limiter uses the submitted Stellar address when present
and falls back to the client IP otherwise.

## Configuration

| Variable | Default | Description |
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { createDocsRouter } from "./routes/docs";
import { sessionsRouter } from "./routes/me/sessions";
import { notificationsRouter } from "./routes/notifications";
import { socialRouter } from "./routes/social";
import { webhooksRouter } from "./routes/webhooks";
import { adminAuditRouter } from "./routes/admin/audit";
import { adminMarketsRouter } from "./routes/admin/markets";
import { adminSchemaVersionsRouter } from "./routes/admin/schema-versions";
Expand Down Expand Up @@ -117,6 +118,7 @@ export function createApp(): express.Express {
app.use("/api/leaderboard", leaderboardRouter);
app.use("/api/rate-limit", rateLimitStatusRouter);
app.use("/api/notifications", notificationsRouter);
app.use("/api/webhooks", webhooksRouter);
app.use("/api/users/health", usersHealthRouter);
app.use("/api/users", socialRouter);
app.use("/api/users", userPortfolioRouter);
Expand Down
9 changes: 8 additions & 1 deletion src/middleware/rateLimit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,13 @@ export function createPerUserRateLimiter(options: Partial<Options> = {}): RateLi
windowMs: 60 * 1000,
limit: 60,
...options,
keyGenerator: (req: Request) => getAuthenticatedUserKey(req) ?? `ip:${getClientIp(req)}`,
keyGenerator: (req: Request) => {
const overrideKey = options.keyGenerator?.(req);
if (typeof overrideKey === "string" && overrideKey.trim().length > 0) {
return overrideKey;
}

return getAuthenticatedUserKey(req) ?? `ip:${getClientIp(req)}`;
},
});
}
18 changes: 18 additions & 0 deletions src/routes/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Router } from "express";
import { z } from "zod";
import { StrKey } from "@stellar/stellar-sdk";
import { createPerUserRateLimiter } from "../middleware/rateLimit";
import {
rotateRefreshToken,
revokeFamily,
Expand All @@ -11,6 +12,23 @@ import { RouteErrorFactory } from "../errors";

export const authRouter = Router();

function getAuthRateLimitKey(req: { body?: unknown; socket?: { remoteAddress?: string | null } }): string {
const body = typeof req.body === "object" && req.body !== null ? req.body as Record<string, unknown> : undefined;
const stellarAddress = typeof body?.stellarAddress === "string" ? body.stellarAddress.trim() : "";

if (stellarAddress.length > 0) {
return `auth:${stellarAddress}`;
}

return `ip:${req.socket?.remoteAddress ?? "unknown"}`;
}

authRouter.use(createPerUserRateLimiter({
windowMs: 60 * 1000,
limit: 5,
keyGenerator: (req) => getAuthRateLimitKey(req),
}));

const refreshTokenBodySchema = z.object({
refreshToken: z.string().min(1),
});
Expand Down
20 changes: 20 additions & 0 deletions src/routes/webhooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Router } from "express";
import { conditionalGet } from "../middleware/etag";
import { ALL_EVENT_TYPES } from "../services/webhookCatalog";

export const webhooksRouter = Router();

webhooksRouter.get("/", (req, res) => {
const payload = {
data: {
events: ALL_EVENT_TYPES,
eventCount: ALL_EVENT_TYPES.length,
},
};

if (conditionalGet(payload, req, res)) {
return;
}

res.json(payload);
});
24 changes: 24 additions & 0 deletions tests/apiVersion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,30 @@ import request from "supertest";
import { createApp } from "../src/index";
import { API_VERSION_HEADER, DEFAULT_API_VERSION } from "../src/middleware/apiVersion";

describe("GET /api/webhooks ETag support", () => {
it("returns an ETag and supports conditional revalidation", async () => {
const app = createApp();

const first = await request(app).get("/api/webhooks");

expect(first.status).toBe(200);
expect(first.headers.etag).toBeDefined();
expect(first.body).toEqual({
data: {
events: expect.any(Array),
eventCount: expect.any(Number),
},
});

const cached = await request(app)
.get("/api/webhooks")
.set("If-None-Match", first.headers.etag as string);

expect(cached.status).toBe(304);
expect(cached.text).toBe("");
});
});

describe("X-Api-Version middleware", () => {
it("defaults to v1 and exposes the resolved version to handlers", async () => {
const app = createApp();
Expand Down
26 changes: 26 additions & 0 deletions tests/authChallenge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ process.env.PREDICTIFY_CONTRACT_ID = "CABC...";

import request from "supertest";

jest.mock("../src/services/auditService", () => ({
createAuditLog: jest.fn().mockResolvedValue(undefined),
}));

jest.mock("../src/services/authChallengeService", () => ({
generateNonce: jest.fn(() => "aaaa"),
computeExpiresAt: jest.fn(() => new Date()),
Expand Down Expand Up @@ -70,4 +74,26 @@ describe("POST /api/auth/challenge", () => {
expect(res.status).toBe(400);
expect(res.body.error.type).toBe("BadRequest");
}, 10000);

it("rate limits repeated challenge attempts for the same authenticated identity", async () => {
const address = "GABSCDZCXMOO6CYNTHBGHAOE3RX72FRMNWK6O4FOXW6OBQATNWKBUUW6";

for (let index = 0; index < 5; index += 1) {
const res = await request(app)
.post("/api/auth/challenge")
.send({ stellarAddress: address });

if (index < 4) {
expect(res.status).toBe(201);
}
}

const res = await request(app)
.post("/api/auth/challenge")
.send({ stellarAddress: address });

expect(res.status).toBe(429);
expect(res.body.error.code).toBe("rate_limit_exceeded");
expect(res.body.error.retryAfter).toEqual(expect.any(Number));
}, 10000);
});
Loading