Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/red-cases-know.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/server": patch
---

🗃️ add bridge id index to credentials
5 changes: 5 additions & 0 deletions .changeset/thin-pumas-brush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/server": patch
---

✨ handle bridge webhook events
3 changes: 3 additions & 0 deletions .do/app.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ services:
- key: BRIDGE_API_URL
scope: RUN_TIME
value: ${{ env.BRIDGE_API_URL }}
- key: BRIDGE_WEBHOOK_PUBLIC_KEY
scope: RUN_TIME
value: ${{ env.BRIDGE_WEBHOOK_PUBLIC_KEY }}
- key: DEBUG
scope: RUN_TIME
value: ${{ env.DEBUG }}
Expand Down
2 changes: 1 addition & 1 deletion server/database/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const credentials = pgTable(
bridgeId: text("bridge_id"),
source: text("source"),
},
({ account }) => [uniqueIndex("account_index").on(account)],
({ account, bridgeId }) => [uniqueIndex("account_index").on(account), index("bridge_id_index").on(bridgeId)],
);

export const cards = pgTable(
Expand Down
191 changes: 191 additions & 0 deletions server/hooks/bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { vValidator } from "@hono/valibot-validator";
import { captureException, setUser } from "@sentry/core";
import createDebug from "debug";
import { eq } from "drizzle-orm";
import { Hono } from "hono";
import { validator } from "hono/validator";
import { createHash, createVerify } from "node:crypto";
import { literal, object, parse, picklist, string, unknown, variant } from "valibot";

import { Address } from "@exactly/common/validation";

import database, { credentials } from "../database";
import { sendPushNotification } from "../utils/onesignal";
import { BridgeCurrency, publicKey } from "../utils/ramps/bridge";
import { track } from "../utils/segment";
import validatorHook from "../utils/validatorHook";

const debug = createDebug("exa:bridge");
Object.assign(debug, { inspectOpts: { depth: undefined } });

export default new Hono().post(
"/",
headerValidator(publicKey),
vValidator(
"json",
variant("event_type", [
object({ event_type: literal("customer.created"), event_object: unknown() }),
object({
event_type: literal("customer.updated.status_transitioned"),
event_object: object({
id: string(),
status: picklist([
"active",
"awaiting_questionnaire",
"awaiting_ubo",
"incomplete",
"not_started",
"offboarded",
"paused",
"rejected",
"under_review",
]),
}),
}),
object({ event_type: literal("customer.updated"), event_object: unknown() }),
object({ event_type: literal("liquidation_address.drain.created"), event_object: unknown() }),
object({
event_type: literal("liquidation_address.drain.updated"),
event_object: unknown(),
}),
object({
event_type: literal("liquidation_address.drain.updated.status_transitioned"),
event_object: object({
currency: picklist(BridgeCurrency),
customer_id: string(),
id: string(),
state: picklist(["funds_received", "funds_scheduled", "payment_submitted", "payment_processed"]),
receipt: object({ initial_amount: string(), outgoing_amount: string() }),
}),
}),
object({
event_type: literal("virtual_account.activity.created"),
event_object: variant("type", [
object({ type: literal("account_update"), id: string(), customer_id: string() }),
object({ type: literal("activation"), id: string(), customer_id: string() }),
object({ type: literal("deactivation"), id: string(), customer_id: string() }),
object({ type: literal("funds_received"), id: string(), customer_id: string() }),
object({ type: literal("funds_scheduled"), id: string(), customer_id: string() }),
object({ type: literal("in_review"), id: string(), customer_id: string() }),
object({ type: literal("microdeposit"), id: string(), customer_id: string() }), // cspell:ignore microdeposit
object({ type: literal("payment_processed"), id: string(), customer_id: string() }),
object({
customer_id: string(),
currency: picklist(BridgeCurrency),
id: string(),
type: literal("payment_submitted"),
receipt: object({ initial_amount: string(), final_amount: string() }),
}),
object({ type: literal("refund"), id: string(), customer_id: string() }),
]),
}),
object({ event_type: literal("virtual_account.activity.updated"), event_object: unknown() }),
]),
validatorHook({ code: "bad bridge", status: 200, debug }),
),
async (c) => {
const payload = c.req.valid("json");
switch (payload.event_type) {
case "customer.created":
case "customer.updated":
case "liquidation_address.drain.created":
case "liquidation_address.drain.updated":
case "virtual_account.activity.updated":
return c.json({ code: "ok" }, 200);
}

const bridgeId =
payload.event_type === "customer.updated.status_transitioned"
? payload.event_object.id
: payload.event_object.customer_id;
const credential = await database.query.credentials.findFirst({
columns: { account: true, source: true },
where: eq(credentials.bridgeId, bridgeId),
});
Comment on lines +97 to +104

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip credential lookup for ignored bridge events

The handler fetches credentials before checking subtype/state-specific no-op paths, so events you intentionally ignore (for example virtual_account.activity.created with type !== "payment_submitted" or drain transitions not in payment_submitted) still hit the database and can emit credential not found errors for unknown customer_ids. In production webhook traffic where Bridge sends many non-actionable transitions, this creates avoidable DB load and noisy Sentry errors even though the final behavior is just 200 { code: "ok" }.

Useful? React with 👍 / 👎.

if (!credential) {
captureException(new Error("credential not found"), {
level: "error",
contexts: { details: { bridgeId } },
});
return c.json({ code: "credential not found" }, 200);
}
const account = parse(Address, credential.account);
setUser({ id: account });

switch (payload.event_type) {
case "customer.updated.status_transitioned":
if (payload.event_object.status !== "active") return c.json({ code: "ok" }, 200);
track({
userId: account,
event: "RampAccount",
properties: { provider: "bridge", source: credential.source },
});
sendPushNotification({
userId: account,
headings: { en: "Fiat onramp activated" },
contents: { en: "Your fiat onramp account has been activated" },
}).catch((error: unknown) => captureException(error, { level: "error" }));
return c.json({ code: "ok" }, 200);
case "virtual_account.activity.created":
if (payload.event_object.type !== "payment_submitted") return c.json({ code: "ok" }, 200);
sendPushNotification({
userId: account,
headings: { en: "Deposited funds" },
contents: {
en: `${payload.event_object.receipt.initial_amount} ${payload.event_object.currency.toUpperCase()} deposited`,
},
}).catch((error: unknown) => captureException(error, { level: "error" }));
track({
userId: account,
event: "Onramp",
properties: {
currency: payload.event_object.currency,
amount: Number(payload.event_object.receipt.initial_amount),
provider: "bridge",
source: credential.source,
usdcAmount: Number(payload.event_object.receipt.final_amount),
},
});
return c.json({ code: "ok" }, 200);
case "liquidation_address.drain.updated.status_transitioned":
if (payload.event_object.state !== "payment_submitted") return c.json({ code: "ok" }, 200);
sendPushNotification({
userId: account,
headings: { en: "Deposited funds" },
contents: {
en: `${payload.event_object.receipt.initial_amount} ${payload.event_object.currency.toUpperCase()} deposited`,
},
}).catch((error: unknown) => captureException(error, { level: "error" }));
track({
userId: account,
event: "Onramp",
properties: {
currency: payload.event_object.currency,
amount: Number(payload.event_object.receipt.initial_amount),
provider: "bridge",
source: credential.source,
usdcAmount: Number(payload.event_object.receipt.outgoing_amount),
},
});
return c.json({ code: "ok" }, 200);
}
},
);

function headerValidator(key: string) {
return validator("header", async ({ "x-webhook-signature": signature }, c) => {
if (typeof signature !== "string") return c.json({ code: "unauthorized" }, 401);
const match = /^t=(\d+),v0=(.+)$/.exec(signature);
if (!match) return c.json({ code: "unauthorized" }, 401);
const [, timestamp, base64Signature] = match;
if (!timestamp || !base64Signature) return c.json({ code: "unauthorized" }, 401);
if (Math.abs(Date.now() - Number(timestamp)) > 600_000) return c.json({ code: "unauthorized" }, 401);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Timestamp validation assumes millisecond precision

The timestamp comparison at line 191 uses Math.abs(Date.now() - Number(timestamp)) > 600_000, treating the bridge timestamp as milliseconds (matching Date.now()). The test at server/test/hooks/bridge.test.ts:83 confirms this assumption by using Date.now() - 600_001 as an expired timestamp. If Bridge's webhook actually sends timestamps in Unix seconds (not milliseconds), the 10-minute window (600_000 ms) would be compared against a value ~1000x larger, causing every request to be rejected. This depends on Bridge's actual API behavior which should be verified against their documentation.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

const body = Buffer.from(await c.req.arrayBuffer()).toString("utf8");
const digest = createHash("sha256").update(`${timestamp}.${body}`).digest();
const verifier = createVerify("RSA-SHA256");
verifier.update(digest);
Comment on lines +184 to +186
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The Bridge webhook handler uses RSA-SHA256 for signature verification, but the Bridge API requires HMAC-SHA256. This will cause all legitimate webhooks to fail verification.
Severity: CRITICAL

Suggested Fix

Replace the RSA-SHA256 verification logic with HMAC-SHA256. Use crypto.createHmac("sha256", webhookSecret) to generate a signature from the request body and timestamp. Compare this generated signature against the one provided in the bridge-signature header. Update the environment variable from BRIDGE_WEBHOOK_PUBLIC_KEY to a shared secret provided by Bridge.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: server/hooks/bridge.ts#L193-L195

Potential issue: The Bridge webhook signature verification is incorrectly implemented
using `RSA-SHA256` with a public key, as seen with `createVerify("RSA-SHA256")`.
According to Bridge's API documentation, webhooks must be verified using `HMAC-SHA256`
with a shared secret. This mismatch will cause the verification to fail for all incoming
webhooks from Bridge, resulting in a `401 Unauthorized` response. Consequently, critical
functionalities like customer activation, onramp transaction tracking, and deposit
notifications will be completely broken. The implementation appears to be an incorrect
adaptation from a Stripe webhook handler.

Did we get this right? 👍 / 👎 to inform future reviews.

if (!verifier.verify(key, Buffer.from(base64Signature, "base64"))) {
return c.json({ code: "unauthorized" }, 401);
}
});
}
2 changes: 1 addition & 1 deletion server/hooks/manteca.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ export default new Hono().post(
event: "Onramp",
properties: {
currency: payload.data.against,
fiatAmount: Number(payload.data.assetAmount) * Number(payload.data.effectivePrice),
amount: Number(payload.data.assetAmount) * Number(payload.data.effectivePrice),
provider: "manteca",
source: credential.source,
usdcAmount: Number(payload.data.assetAmount),
Expand Down
2 changes: 2 additions & 0 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import api from "./api";
import database from "./database";
import activityHook from "./hooks/activity";
import block from "./hooks/block";
import bridge from "./hooks/bridge";
import manteca from "./hooks/manteca";
import panda from "./hooks/panda";
import persona from "./hooks/persona";
Expand All @@ -30,6 +31,7 @@ app.route("/api", api);

app.route("/hooks/activity", activityHook);
app.route("/hooks/block", block);
app.route("/hooks/bridge", bridge);
app.route("/hooks/manteca", manteca);
app.route("/hooks/panda", panda);
app.route("/hooks/persona", persona);
Expand Down
Loading