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
52 changes: 40 additions & 12 deletions src/services/bridge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,36 @@ const checkAccountLevel = async (
return account
}

const requireApprovedBridgeCustomer = async ({
account,
operation,
}: {
account: Account
operation: string
}): Promise<BridgeCustomerId | Error> => {
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 ============

/**
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
22 changes: 20 additions & 2 deletions src/services/bridge/webhook-server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,34 @@ 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({
windowMs: 60_000,
limit: 20,
standardHeaders: true,
legacyHeaders: false,
validate: { xForwardedForHeader: false },
})

export const startBridgeWebhookServer = () => {
Expand Down Expand Up @@ -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(
Expand Down
104 changes: 100 additions & 4 deletions src/services/bridge/webhook-server/routes/replay.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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<RouteKey, (req: Request, res: Response) => Promise<Response>> = {
kyc: kycHandler,
Expand All @@ -27,6 +30,7 @@ const HANDLERS: Record<RouteKey, (req: Request, res: Response) => Promise<Respon
}

const WEAK_REPLAY_SECRETS = new Set(["also-not-so-secret", "change-me", "<replace>"])
const REPLAY_ALLOWED_IPS_ENV = "BRIDGE_WEBHOOK_REPLAY_ALLOWED_IPS"

const DEPOSIT_EVENT_TYPES = new Set([
"funds_scheduled",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown>
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,
Expand All @@ -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,
}

Expand Down
18 changes: 17 additions & 1 deletion src/services/ibex/webhook-server/middleware/authenticate.ts
Original file line number Diff line number Diff line change
@@ -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()
}
26 changes: 24 additions & 2 deletions src/services/ibex/webhook-server/routes/crypto-receive.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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 }
Loading
Loading