diff --git a/src/services/bridge/index.ts b/src/services/bridge/index.ts index 8b55d9713..429f999e6 100644 --- a/src/services/bridge/index.ts +++ b/src/services/bridge/index.ts @@ -288,6 +288,36 @@ const checkAccountLevel = async ( return account } +const requireApprovedBridgeCustomer = async ({ + account, + operation, +}: { + account: Account + operation: string +}): Promise => { + if (!account.bridgeCustomerId) { + return new BridgeCustomerNotFoundError( + "Account has no Bridge customer ID. Complete KYC first.", + ) + } + + const customerId = toBridgeCustomerId(account.bridgeCustomerId) + const customer = await BridgeApiClient.getCustomer(customerId) + const kycStatus = customer.status + + if (kycStatus === "offboarded") return new BridgeKycOffboardedError() + if (kycStatus === "rejected") return new BridgeKycRejectedError() + if (kycStatus !== "active" && (kycStatus as string) !== "approved") { + baseLogger.warn( + { accountId: account.id, customerId, kycStatus, operation }, + "Bridge customer is not approved for withdrawal", + ) + return new BridgeKycPendingError("KYC must be approved before withdrawal") + } + + return customerId +} + // ============ Service Methods ============ /** @@ -702,12 +732,11 @@ const requestWithdrawal = async ( if (account instanceof Error) return account try { - const customerId = account.bridgeCustomerId - if (!customerId) { - return new BridgeCustomerNotFoundError( - "Account has no Bridge customer ID. Complete KYC first.", - ) - } + const customerId = await requireApprovedBridgeCustomer({ + account, + operation: "requestWithdrawal", + }) + if (customerId instanceof Error) return customerId const ethereumAddress = account.bridgeEthereumAddress if (!ethereumAddress) { @@ -901,12 +930,11 @@ const initiateWithdrawal = async ( if (account instanceof Error) return account try { - const customerId = account.bridgeCustomerId - if (!customerId) { - return new BridgeCustomerNotFoundError( - "Account has no Bridge customer ID. Complete KYC first.", - ) - } + const customerId = await requireApprovedBridgeCustomer({ + account, + operation: "initiateWithdrawal", + }) + if (customerId instanceof Error) return customerId const pendingWithdrawal = await BridgeAccountsRepo.findWithdrawalById(withdrawalId) if (pendingWithdrawal instanceof Error) { diff --git a/src/services/bridge/webhook-server/index.ts b/src/services/bridge/webhook-server/index.ts index 126409dec..9c17cf861 100644 --- a/src/services/bridge/webhook-server/index.ts +++ b/src/services/bridge/webhook-server/index.ts @@ -16,15 +16,26 @@ import { kycHandler } from "./routes/kyc" import { depositHandler } from "./routes/deposit" import { transferHandler } from "./routes/transfer" import { externalAccountHandler } from "./routes/external-account" -import { replayAuthMiddleware, replayHandler } from "./routes/replay" +import { + replayAuthMiddleware, + replayHandler, + replayIngressMiddleware, +} from "./routes/replay" type RawBodyRequest = express.Request & { rawBody?: string } +// `validate: { xForwardedForHeader: false }` on both limiters: without Express +// `trust proxy`, express-rate-limit v7 throws ERR_ERL_UNEXPECTED_X_FORWARDED_FOR +// on any request carrying X-Forwarded-For (i.e. anything behind an LB), turning +// every webhook into a 500. Skipping that validation degrades an unset trust +// proxy to keying on the LB's socket address (one shared bucket) instead of an +// outage. Set `trust proxy` in the server for per-sender buckets. const webhookRateLimit = rateLimitMiddleware({ windowMs: 60_000, limit: 120, standardHeaders: true, legacyHeaders: false, + validate: { xForwardedForHeader: false }, }) const replayRateLimit = rateLimitMiddleware({ @@ -32,6 +43,7 @@ const replayRateLimit = rateLimitMiddleware({ limit: 20, standardHeaders: true, legacyHeaders: false, + validate: { xForwardedForHeader: false }, }) export const startBridgeWebhookServer = () => { @@ -70,7 +82,13 @@ export const startBridgeWebhookServer = () => { verifyBridgeSignature("external_account"), externalAccountHandler, ) - app.post("/internal/replay", replayRateLimit, replayAuthMiddleware, replayHandler) + app.post( + "/internal/replay", + replayRateLimit, + replayIngressMiddleware, + replayAuthMiddleware, + replayHandler, + ) if (!(process.env.BRIDGE_WEBHOOK_REPLAY_SECRET || BridgeConfig.webhook.replaySecret)) { baseLogger.warn( diff --git a/src/services/bridge/webhook-server/routes/replay.ts b/src/services/bridge/webhook-server/routes/replay.ts index 67fd40178..8053f54a7 100644 --- a/src/services/bridge/webhook-server/routes/replay.ts +++ b/src/services/bridge/webhook-server/routes/replay.ts @@ -1,6 +1,8 @@ import crypto from "crypto" import { Request, Response } from "express" +import ipaddr from "ipaddr.js" +import requestIp from "request-ip" import { BridgeConfig } from "@config" @@ -18,6 +20,7 @@ import { externalAccountHandler } from "./external-account" import { kycHandler } from "./kyc" import { transferHandler } from "./transfer" type RouteKey = "kyc" | "deposit" | "transfer" | "external_account" +type ParsedRange = [ipaddr.IPv4 | ipaddr.IPv6, number] const HANDLERS: Record Promise> = { kyc: kycHandler, @@ -27,6 +30,7 @@ const HANDLERS: Record Promise"]) +const REPLAY_ALLOWED_IPS_ENV = "BRIDGE_WEBHOOK_REPLAY_ALLOWED_IPS" const DEPOSIT_EVENT_TYPES = new Set([ "funds_scheduled", @@ -90,6 +94,73 @@ const resolveReplayEventType = ({ return eventType } +const parseReplayAllowlistEntry = (entry: string): ParsedRange | null => { + const trimmed = entry.trim() + if (!trimmed) return null + + try { + if (trimmed.includes("/")) return ipaddr.parseCIDR(trimmed) + const addr = ipaddr.parse(trimmed) + return [addr, addr.kind() === "ipv6" ? 128 : 32] + } catch { + baseLogger.error({ entry }, "Ignoring invalid Bridge replay IP allowlist entry") + return null + } +} + +const parseReplayAllowlist = (value: string | undefined): ParsedRange[] => + (value ?? "") + .split(",") + .map(parseReplayAllowlistEntry) + .filter((range): range is ParsedRange => range !== null) + +const isLoopbackIp = (clientIp: string | null | undefined): boolean => { + if (!clientIp || !ipaddr.isValid(clientIp)) return false + return ipaddr.process(clientIp).range() === "loopback" +} + +export const isReplayIpAllowed = ( + clientIp: string | null | undefined, + allowlistEnv: string | undefined = process.env[REPLAY_ALLOWED_IPS_ENV], +): boolean => { + if (!clientIp || !ipaddr.isValid(clientIp)) return false + + const addr = ipaddr.process(clientIp) + const ranges = parseReplayAllowlist(allowlistEnv) + + return ranges.some(([rangeAddr, prefix]) => { + if (addr.kind() === "ipv4" && rangeAddr.kind() === "ipv4") { + return (addr as ipaddr.IPv4).match(rangeAddr as ipaddr.IPv4, prefix) + } + if (addr.kind() === "ipv6" && rangeAddr.kind() === "ipv6") { + return (addr as ipaddr.IPv6).match(rangeAddr as ipaddr.IPv6, prefix) + } + return false + }) +} + +export const replayIngressMiddleware = ( + req: Request, + res: Response, + next: () => void, +) => { + // Loopback trust must come from the socket address, never from request-ip: + // request-ip prefers X-Forwarded-For, which any external caller can set to + // "127.0.0.1" and walk through this gate. The allowlist path below still uses + // request-ip so operators behind a trusted LB match on their real IP — it is + // only meaningful when the ingress strips or overwrites client-supplied XFF. + if (isLoopbackIp(req.socket?.remoteAddress)) return next() + + const clientIp = requestIp.getClientIp(req) + + if (!isReplayIpAllowed(clientIp)) { + baseLogger.warn({ clientIp, path: req.path }, "Rejected Bridge replay request") + return res.status(403).json({ error: "Forbidden" }) + } + + next() +} + const toHandlerBody = ({ routeKey, eventId, @@ -163,18 +234,43 @@ export const replayHandler = async (req: Request, res: Response) => { dry_run = false, } = req.body - if (!event_type || !event_object || !event_created_at) { + if ( + !event_type || + !event_object || + !event_created_at || + !operator || + !time_window_start || + !time_window_end + ) { return res.status(400).json({ error: "Missing required fields: event_type, event_object, event_created_at, operator, time_window_start, time_window_end", }) } + if (typeof operator !== "string" || operator.trim() === "") { + return res.status(400).json({ error: "operator must be a non-empty string" }) + } + if (typeof event_object !== "object" || event_object === null) { return res.status(400).json({ error: "event_object must be an object" }) } const eventObjectTyped = event_object as Record + const bridgeEventCreatedAt = new Date(event_created_at) + const timeWindowStart = new Date(time_window_start) + const timeWindowEnd = new Date(time_window_end) + + if ( + Number.isNaN(bridgeEventCreatedAt.getTime()) || + Number.isNaN(timeWindowStart.getTime()) || + Number.isNaN(timeWindowEnd.getTime()) + ) { + return res.status(400).json({ + error: + "event_created_at, time_window_start, and time_window_end must be valid dates", + }) + } const normalizedEventType = resolveReplayEventType({ eventType: event_type, @@ -201,11 +297,11 @@ export const replayHandler = async (req: Request, res: Response) => { eventId, eventType: routeKey, eventPayload: event_object, - bridgeEventCreatedAt: new Date(event_created_at), + bridgeEventCreatedAt, replayedAt: new Date(), operator, - timeWindowStart: new Date(time_window_start), - timeWindowEnd: new Date(time_window_end), + timeWindowStart, + timeWindowEnd, dryRun: dry_run, } diff --git a/src/services/ibex/webhook-server/middleware/authenticate.ts b/src/services/ibex/webhook-server/middleware/authenticate.ts index fbe6026a9..629260420 100644 --- a/src/services/ibex/webhook-server/middleware/authenticate.ts +++ b/src/services/ibex/webhook-server/middleware/authenticate.ts @@ -1,8 +1,24 @@ +import crypto from "crypto" + import { Request, Response, NextFunction } from "express" import { IbexConfig } from "@config" +const timingSafeStringEqual = (actual: unknown, expected: unknown): boolean => { + if (typeof actual !== "string" || typeof expected !== "string" || expected === "") { + return false + } + + const actualBuffer = Buffer.from(actual) + const expectedBuffer = Buffer.from(expected) + + return ( + actualBuffer.length === expectedBuffer.length && + crypto.timingSafeEqual(actualBuffer, expectedBuffer) + ) +} + export const authenticate = (req: Request, resp: Response, next: NextFunction) => { - if (req.body.webhookSecret !== IbexConfig.webhook.secret) + if (!timingSafeStringEqual(req.body.webhookSecret, IbexConfig.webhook.secret)) return resp.status(401).end("Invalid secret") next() } diff --git a/src/services/ibex/webhook-server/routes/crypto-receive.ts b/src/services/ibex/webhook-server/routes/crypto-receive.ts index 6416bc9d8..2efdd7b24 100644 --- a/src/services/ibex/webhook-server/routes/crypto-receive.ts +++ b/src/services/ibex/webhook-server/routes/crypto-receive.ts @@ -1,4 +1,5 @@ import express, { Request, Response } from "express" +import rateLimitMiddleware from "express-rate-limit" import { AccountsRepository } from "@services/mongoose/accounts" import { createIbexCryptoReceive } from "@services/mongoose/ibex-crypto-receive-log" import { listWalletsByAccountId } from "@app/wallets" @@ -14,12 +15,26 @@ import { import { writeIbexCryptoReceiveRequest } from "@services/frappe/BridgeTransferRequestWriter" import { ibexWebhookPaths } from "@services/ibex/webhook-config" -import { authenticate, logRequest } from "../middleware" +import { authenticate, logRequest, validateIbexIp } from "../middleware" const paths = ibexWebhookPaths.cryptoReceive const router = express.Router() +const webhookRateLimit = rateLimitMiddleware({ + windowMs: 60_000, + limit: 120, + standardHeaders: true, + legacyHeaders: false, + // Without Express `trust proxy`, express-rate-limit v7 throws + // ERR_ERL_UNEXPECTED_X_FORWARDED_FOR on any request carrying X-Forwarded-For + // (i.e. anything behind an LB), turning every webhook into a 500. Skip that + // validation so an unset trust proxy degrades to keying on the LB's socket + // address (one shared bucket) instead of an outage. Set `trust proxy` in the + // server for per-sender buckets. + validate: { xForwardedForHeader: false }, +}) + interface CryptoReceiveResult { status: "success" | "error" code?: string @@ -222,6 +237,13 @@ const cryptoReceiveHandler = async (req: Request, res: Response) => { return res.status(statusCode).json({ error: lockResult.code }) } -router.post(paths.cryptoReceive, authenticate, logRequest, cryptoReceiveHandler) +router.post( + paths.cryptoReceive, + webhookRateLimit, + validateIbexIp, + authenticate, + logRequest, + cryptoReceiveHandler, +) export { cryptoReceiveHandler, paths, router } diff --git a/test/flash/unit/services/bridge/index.spec.ts b/test/flash/unit/services/bridge/index.spec.ts index 14dc40c8e..353088e28 100644 --- a/test/flash/unit/services/bridge/index.spec.ts +++ b/test/flash/unit/services/bridge/index.spec.ts @@ -828,6 +828,40 @@ describe("requestWithdrawal", () => { expect(result).toBeInstanceOf(BridgeCustomerNotFoundError) }) + it("rejects withdrawal requests when live Bridge KYC status is offboarded", async () => { + ;(BridgeClient.getCustomer as jest.Mock).mockResolvedValueOnce({ + status: "offboarded", + }) + + const result = await BridgeService.requestWithdrawal( + ACCOUNT_ID, + AMOUNT, + EXTERNAL_ACCOUNT_ID, + ) + + const { BridgeKycOffboardedError } = jest.requireActual("@services/bridge/errors") + expect(result).toBeInstanceOf(BridgeKycOffboardedError) + expect(BridgeAccountsRepo.createWithdrawal).not.toHaveBeenCalled() + expect(BridgeClient.listExternalAccounts).not.toHaveBeenCalled() + }) + + it("rejects withdrawal requests when live Bridge KYC status is not approved", async () => { + ;(BridgeClient.getCustomer as jest.Mock).mockResolvedValueOnce({ + status: "paused", + }) + + const result = await BridgeService.requestWithdrawal( + ACCOUNT_ID, + AMOUNT, + EXTERNAL_ACCOUNT_ID, + ) + + const { BridgeKycPendingError } = jest.requireActual("@services/bridge/errors") + expect(result).toBeInstanceOf(BridgeKycPendingError) + expect(BridgeAccountsRepo.createWithdrawal).not.toHaveBeenCalled() + expect(BridgeClient.listExternalAccounts).not.toHaveBeenCalled() + }) + it("returns an error when account has no Ethereum address", async () => { ;(AccountsRepository as jest.Mock).mockReturnValue({ findById: jest.fn().mockResolvedValue({ @@ -968,6 +1002,20 @@ describe("initiateWithdrawal — takes withdrawalId (step 2A)", () => { ) }) + it("rejects initiation when live Bridge KYC status is rejected", async () => { + ;(BridgeClient.getCustomer as jest.Mock).mockResolvedValueOnce({ + status: "rejected", + }) + + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + const { BridgeKycRejectedError } = jest.requireActual("@services/bridge/errors") + expect(result).toBeInstanceOf(BridgeKycRejectedError) + expect(BridgeAccountsRepo.findWithdrawalById).not.toHaveBeenCalled() + expect(BridgeClient.createTransfer).not.toHaveBeenCalled() + expect(IbexClient.sendCrypto).not.toHaveBeenCalled() + }) + it("sends a fixed developer_fee instead of developer_fee_percent for offramps", async () => { await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) diff --git a/test/flash/unit/services/bridge/return-shapes.spec.ts b/test/flash/unit/services/bridge/return-shapes.spec.ts index 372f6840a..d3e6ebf62 100644 --- a/test/flash/unit/services/bridge/return-shapes.spec.ts +++ b/test/flash/unit/services/bridge/return-shapes.spec.ts @@ -66,7 +66,11 @@ jest.mock("@services/mongoose/bridge-accounts", () => ({ jest.mock("@services/bridge/client", () => ({ __esModule: true, - default: { createTransfer: jest.fn(), listExternalAccounts: jest.fn() }, + default: { + createTransfer: jest.fn(), + listExternalAccounts: jest.fn(), + getCustomer: jest.fn(), + }, })) jest.mock("@services/mongoose/accounts", () => ({ @@ -255,6 +259,7 @@ const setupGuards = () => { transactionHub: { id: IBEX_PAYOUT_ID }, }) ;(BridgeClient.createTransfer as jest.Mock).mockResolvedValue(mockTransfer) + ;(BridgeClient.getCustomer as jest.Mock).mockResolvedValue({ status: "active" }) } describe("initiateWithdrawal — BridgeWithdrawal GraphQL contract shape", () => { diff --git a/test/flash/unit/services/bridge/webhook-server/replay.spec.ts b/test/flash/unit/services/bridge/webhook-server/replay.spec.ts index 72c976e98..0800e3d7f 100644 --- a/test/flash/unit/services/bridge/webhook-server/replay.spec.ts +++ b/test/flash/unit/services/bridge/webhook-server/replay.spec.ts @@ -12,6 +12,11 @@ jest.mock("@services/logger", () => ({ baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, })) +jest.mock("request-ip", () => ({ + __esModule: true, + default: { getClientIp: jest.fn() }, +})) + jest.mock("@services/mongoose/bridge-replay-log", () => ({ createBridgeReplay: jest.fn(), })) @@ -25,16 +30,22 @@ jest.mock("@services/bridge/webhook-server/routes/kyc", () => ({ jest.mock("@services/bridge/webhook-server/routes/transfer", () => ({ transferHandler: jest.fn(), })) +jest.mock("@services/bridge/webhook-server/routes/external-account", () => ({ + externalAccountHandler: jest.fn(), +})) import { Request, Response } from "express" import { + isReplayIpAllowed, replayAuthMiddleware, replayHandler, + replayIngressMiddleware, } from "@services/bridge/webhook-server/routes/replay" import * as ReplayLog from "@services/mongoose/bridge-replay-log" import { depositHandler } from "@services/bridge/webhook-server/routes/deposit" import { kycHandler } from "@services/bridge/webhook-server/routes/kyc" import { transferHandler } from "@services/bridge/webhook-server/routes/transfer" +import requestIp from "request-ip" // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -48,7 +59,13 @@ const makeRes = () => { const makeReq = ( body: Record = {}, headers: Record = {}, -) => ({ body, headers }) as unknown as Request + socketRemoteAddress?: string, +) => + ({ + body, + headers, + socket: { remoteAddress: socketRemoteAddress }, + }) as unknown as Request const BASE_BODY = { event_type: "funds_received", @@ -61,6 +78,71 @@ const BASE_BODY = { // ── replayAuthMiddleware ────────────────────────────────────────────────────── +describe("replayIngressMiddleware", () => { + const originalReplayAllowedIps = process.env.BRIDGE_WEBHOOK_REPLAY_ALLOWED_IPS + + beforeEach(() => { + jest.clearAllMocks() + delete process.env.BRIDGE_WEBHOOK_REPLAY_ALLOWED_IPS + }) + + afterAll(() => { + if (originalReplayAllowedIps === undefined) { + delete process.env.BRIDGE_WEBHOOK_REPLAY_ALLOWED_IPS + } else { + process.env.BRIDGE_WEBHOOK_REPLAY_ALLOWED_IPS = originalReplayAllowedIps + } + }) + + it("allows loopback replay calls (by socket address) without an explicit allowlist", () => { + ;(requestIp.getClientIp as jest.Mock).mockReturnValue(null) + const res = makeRes() + const next = jest.fn() + + replayIngressMiddleware(makeReq({}, {}, "127.0.0.1"), res, next) + + expect(next).toHaveBeenCalledTimes(1) + expect(res.status as jest.Mock).not.toHaveBeenCalled() + }) + + it("rejects a spoofed loopback X-Forwarded-For from a public socket", () => { + // request-ip resolves headers like X-Forwarded-For, which the caller + // controls — only the socket address may grant the loopback exemption. + ;(requestIp.getClientIp as jest.Mock).mockReturnValue("127.0.0.1") + const res = makeRes() + const next = jest.fn() + + replayIngressMiddleware(makeReq({}, {}, "198.51.100.9"), res, next) + + expect(next).not.toHaveBeenCalled() + expect(res.status as jest.Mock).toHaveBeenCalledWith(403) + }) + + it("rejects public replay calls when no allowlist matches", () => { + ;(requestIp.getClientIp as jest.Mock).mockReturnValue("198.51.100.9") + const res = makeRes() + const next = jest.fn() + + replayIngressMiddleware(makeReq({}, {}, "198.51.100.9"), res, next) + + expect(next).not.toHaveBeenCalled() + expect(res.status as jest.Mock).toHaveBeenCalledWith(403) + }) + + it("allows public replay calls from the configured allowlist", () => { + expect(isReplayIpAllowed("198.51.100.9", "198.51.100.0/24")).toBe(true) + ;(requestIp.getClientIp as jest.Mock).mockReturnValue("198.51.100.9") + process.env.BRIDGE_WEBHOOK_REPLAY_ALLOWED_IPS = "198.51.100.0/24" + const res = makeRes() + const next = jest.fn() + + replayIngressMiddleware(makeReq({}, {}, "10.0.0.7"), res, next) + + expect(next).toHaveBeenCalledTimes(1) + expect(res.status as jest.Mock).not.toHaveBeenCalled() + }) +}) + describe("replayAuthMiddleware", () => { beforeEach(() => jest.clearAllMocks()) @@ -174,6 +256,26 @@ describe("replayHandler", () => { expect(res.status as jest.Mock).toHaveBeenCalledWith(400) }) + it("returns 400 when replay audit fields are missing", async () => { + const res = makeRes() + const body: Record = { ...BASE_BODY } + delete body.operator + await replayHandler(makeReq(body), res) + expect(res.status as jest.Mock).toHaveBeenCalledWith(400) + }) + + it("returns 400 when replay audit dates are invalid", async () => { + const res = makeRes() + await replayHandler( + makeReq({ + ...BASE_BODY, + time_window_start: "not-a-date", + }), + res, + ) + expect(res.status as jest.Mock).toHaveBeenCalledWith(400) + }) + it("returns 400 for an unrecognised event_type", async () => { const res = makeRes() await replayHandler(makeReq({ ...BASE_BODY, event_type: "unknown_event" }), res) diff --git a/test/flash/unit/services/ibex/webhook-server/authenticate.spec.ts b/test/flash/unit/services/ibex/webhook-server/authenticate.spec.ts new file mode 100644 index 000000000..3f8413cb9 --- /dev/null +++ b/test/flash/unit/services/ibex/webhook-server/authenticate.spec.ts @@ -0,0 +1,85 @@ +jest.mock("@config", () => ({ + IbexConfig: { webhook: { secret: "Kramerica" } }, +})) + +import { Request, Response } from "express" + +import { authenticate } from "@services/ibex/webhook-server/middleware/authenticate" + +const makeReq = (webhookSecret?: string) => + ({ + body: webhookSecret === undefined ? {} : { webhookSecret }, + }) as Request + +const makeRes = () => { + const res = { + status: jest.fn().mockReturnThis(), + end: jest.fn().mockReturnThis(), + } + return res as unknown as Response +} + +describe("IBEX webhook authenticate middleware", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("accepts the configured webhook secret", () => { + const res = makeRes() + const next = jest.fn() + + authenticate(makeReq("Kramerica"), res, next) + + expect(next).toHaveBeenCalledTimes(1) + expect(res.status).not.toHaveBeenCalled() + }) + + it("rejects a same-length invalid secret", () => { + const res = makeRes() + const next = jest.fn() + + authenticate(makeReq("Kramerics"), res, next) + + expect(next).not.toHaveBeenCalled() + expect(res.status).toHaveBeenCalledWith(401) + expect(res.end).toHaveBeenCalledWith("Invalid secret") + }) + + it("rejects missing and different-length secrets without throwing", () => { + for (const secret of [undefined, "short"]) { + const res = makeRes() + const next = jest.fn() + + authenticate(makeReq(secret), res, next) + + expect(next).not.toHaveBeenCalled() + expect(res.status).toHaveBeenCalledWith(401) + expect(res.end).toHaveBeenCalledWith("Invalid secret") + } + }) + + it("fails closed when the webhook secret is unconfigured", () => { + // The old `!==` compare passed when both sides were undefined (or both + // empty), silently disabling auth on unconfigured deployments. + const { IbexConfig } = jest.requireMock("@config") + const configuredSecret = IbexConfig.webhook.secret + + try { + for (const unconfigured of [undefined, ""]) { + IbexConfig.webhook.secret = unconfigured + + for (const provided of [undefined, ""]) { + const res = makeRes() + const next = jest.fn() + + authenticate(makeReq(provided), res, next) + + expect(next).not.toHaveBeenCalled() + expect(res.status).toHaveBeenCalledWith(401) + } + } + } finally { + IbexConfig.webhook.secret = configuredSecret + } + }) +}) diff --git a/test/flash/unit/services/ibex/webhook-server/routes/crypto-receive.spec.ts b/test/flash/unit/services/ibex/webhook-server/routes/crypto-receive.spec.ts index af7830a4c..bc781bcd3 100644 --- a/test/flash/unit/services/ibex/webhook-server/routes/crypto-receive.spec.ts +++ b/test/flash/unit/services/ibex/webhook-server/routes/crypto-receive.spec.ts @@ -1,6 +1,7 @@ jest.mock("@services/ibex/webhook-server/middleware", () => ({ authenticate: jest.fn((_req, _res, next) => next()), logRequest: jest.fn((_req, _res, next) => next()), + validateIbexIp: jest.fn((_req, _res, next) => next()), })) jest.mock("@services/mongoose/accounts", () => ({ @@ -40,7 +41,16 @@ jest.mock("@services/alerts/ibex-bridge-movement", () => ({ alertIbexReconciliationFailed: jest.fn(), })) -import { cryptoReceiveHandler } from "@services/ibex/webhook-server/routes/crypto-receive" +import { + authenticate, + logRequest, + validateIbexIp, +} from "@services/ibex/webhook-server/middleware" +import { + cryptoReceiveHandler, + paths, + router, +} from "@services/ibex/webhook-server/routes/crypto-receive" import { AccountsRepository } from "@services/mongoose/accounts" import { createIbexCryptoReceive } from "@services/mongoose/ibex-crypto-receive-log" import { listWalletsByAccountId } from "@app/wallets" @@ -78,6 +88,27 @@ describe("cryptoReceiveHandler", () => { ;(writeIbexCryptoReceiveRequest as jest.Mock).mockResolvedValue(true) }) + it("protects the route with rate limit, IP allowlist, auth, and request logging", () => { + const routeLayer = ( + router.stack as Array<{ + route?: { path: string; stack: Array<{ handle: unknown }> } + }> + ).find((layer) => layer.route?.path === paths.cryptoReceive) + + expect(routeLayer).toBeDefined() + const handles = routeLayer!.route!.stack.map((layer) => layer.handle) + + const validateIndex = handles.indexOf(validateIbexIp) + const authIndex = handles.indexOf(authenticate) + const logIndex = handles.indexOf(logRequest) + const handlerIndex = handles.indexOf(cryptoReceiveHandler) + + expect(validateIndex).toBeGreaterThan(0) + expect(authIndex).toBeGreaterThan(validateIndex) + expect(logIndex).toBeGreaterThan(authIndex) + expect(handlerIndex).toBeGreaterThan(logIndex) + }) + it("accepts Ethereum USDT receive webhooks and normalizes persisted currency/network", async () => { const res = makeResponse()