diff --git a/src/app/bridge/send-deposit-notification.ts b/src/app/bridge/send-deposit-notification.ts index bda4f2e95..347244a75 100644 --- a/src/app/bridge/send-deposit-notification.ts +++ b/src/app/bridge/send-deposit-notification.ts @@ -1,4 +1,4 @@ -import { getI18nInstance } from "@config" +import { BridgeConfig, getI18nInstance } from "@config" import { checkedToAccountId } from "@domain/accounts" import { getLanguageOrDefault } from "@domain/locale" import { @@ -77,6 +77,8 @@ export const sendBridgeDepositNotification = async ({ export const sendBridgeDepositNotificationBestEffort = async ( args: Parameters[0], ): Promise => { + // ENG-466: never push a Bridge deposit notification when the feature is off. + if (!BridgeConfig.enabled) return const result = await sendBridgeDepositNotification(args) if (result instanceof DeviceTokensNotRegisteredNotificationsServiceError) { diff --git a/src/services/bridge/webhook-server/index.ts b/src/services/bridge/webhook-server/index.ts index 58ef57f8d..2e8e34ec4 100644 --- a/src/services/bridge/webhook-server/index.ts +++ b/src/services/bridge/webhook-server/index.ts @@ -12,6 +12,7 @@ import { BridgeConfig } from "@config" import { baseLogger } from "@services/logger" import { verifyBridgeSignature } from "./middleware/verify-signature" +import { bridgeEnabledGuard } from "./middleware/enabled-guard" import { kycHandler } from "./routes/kyc" import { depositHandler } from "./routes/deposit" import { transferHandler } from "./routes/transfer" @@ -74,6 +75,12 @@ export const startBridgeWebhookServer = () => { res.status(200).json({ status: "ok", service: "bridge-webhook" }) }) + // Defense in depth (ENG-466): the chart already gates this workload on + // galoy.bridge.webhook.enabled, but if the process ever starts with the + // feature OFF (chart/config drift, a local run, a misconfig) it must not + // mutate the DB. /health stays up for k8s probes; every other route rejects. + app.use(bridgeEnabledGuard) + // Webhook routes with signature verification app.post("/kyc", webhookRateLimit, verifyBridgeSignature("kyc"), kycHandler) app.post("/deposit", webhookRateLimit, verifyBridgeSignature("deposit"), depositHandler) diff --git a/src/services/bridge/webhook-server/middleware/enabled-guard.ts b/src/services/bridge/webhook-server/middleware/enabled-guard.ts new file mode 100644 index 000000000..db6028c69 --- /dev/null +++ b/src/services/bridge/webhook-server/middleware/enabled-guard.ts @@ -0,0 +1,26 @@ +import express from "express" + +import { BridgeConfig } from "@config" +import { baseLogger } from "@services/logger" + +/** + * Defense in depth (ENG-466): the chart gates the bridge-webhook workload on + * galoy.bridge.webhook.enabled, but if the process ever starts with the + * feature OFF (chart/config drift, a local run, a misconfig) it must not + * mutate the DB. /health stays up for k8s probes; every other route rejects. + */ +export const bridgeEnabledGuard = ( + req: express.Request, + res: express.Response, + next: express.NextFunction, +) => { + if (req.path === "/health") return next() + if (!BridgeConfig.enabled) { + baseLogger.warn( + { path: req.path }, + "Bridge webhook received while bridge is disabled — rejecting", + ) + return res.status(503).json({ error: "Bridge is disabled" }) + } + return next() +} diff --git a/test/flash/unit/app/bridge/send-deposit-notification.spec.ts b/test/flash/unit/app/bridge/send-deposit-notification.spec.ts new file mode 100644 index 000000000..3f4e70927 --- /dev/null +++ b/test/flash/unit/app/bridge/send-deposit-notification.spec.ts @@ -0,0 +1,58 @@ +const config = { enabled: true } + +jest.mock("@config", () => ({ + get BridgeConfig() { + return config + }, + getI18nInstance: jest.fn(), +})) + +const findById = jest.fn() +jest.mock("@services/mongoose/accounts", () => ({ + AccountsRepository: () => ({ findById }), +})) +jest.mock("@services/mongoose/users", () => ({ + UsersRepository: () => ({ findById: jest.fn() }), +})) +jest.mock("@app/users/remove-device-tokens", () => ({ + removeDeviceTokens: jest.fn(), +})) +jest.mock("@services/logger", () => { + const logger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + child: jest.fn(() => logger), + } + return { baseLogger: logger } +}) + +import { sendBridgeDepositNotificationBestEffort } from "@app/bridge/send-deposit-notification" + +describe("sendBridgeDepositNotificationBestEffort — bridge.enabled gate (ENG-466)", () => { + afterEach(() => { + config.enabled = true + jest.clearAllMocks() + }) + + it("no-ops without touching the account repo when bridge is disabled", async () => { + config.enabled = false + await sendBridgeDepositNotificationBestEffort({ + accountId: "507f1f77bcf86cd799439011", + amount: "10", + currency: "USD", + }) + expect(findById).not.toHaveBeenCalled() + }) + + it("proceeds (reaches the account lookup) when bridge is enabled", async () => { + findById.mockResolvedValue(new Error("stop-after-lookup")) + await sendBridgeDepositNotificationBestEffort({ + accountId: "507f1f77bcf86cd799439011", + amount: "10", + currency: "USD", + }) + expect(findById).toHaveBeenCalled() + }) +}) diff --git a/test/flash/unit/services/bridge/webhook-server/enabled-guard.spec.ts b/test/flash/unit/services/bridge/webhook-server/enabled-guard.spec.ts new file mode 100644 index 000000000..0da22e572 --- /dev/null +++ b/test/flash/unit/services/bridge/webhook-server/enabled-guard.spec.ts @@ -0,0 +1,56 @@ +const config = { enabled: true } + +jest.mock("@config", () => ({ + get BridgeConfig() { + return config + }, +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +import { Request, Response } from "express" +import { bridgeEnabledGuard } from "@services/bridge/webhook-server/middleware/enabled-guard" + +const makeRes = () => { + const res = { status: jest.fn(), json: jest.fn() } as unknown as Response + ;(res.status as jest.Mock).mockReturnValue(res) + ;(res.json as jest.Mock).mockReturnValue(res) + return res +} +const makeReq = (path: string) => ({ path }) as unknown as Request + +describe("bridgeEnabledGuard (ENG-466)", () => { + afterEach(() => { + config.enabled = true + jest.clearAllMocks() + }) + + it("passes webhook routes through when bridge is enabled", () => { + const next = jest.fn() + const res = makeRes() + bridgeEnabledGuard(makeReq("/kyc"), res, next) + expect(next).toHaveBeenCalled() + expect(res.status).not.toHaveBeenCalled() + }) + + it("rejects mutating routes with 503 when bridge is disabled", () => { + config.enabled = false + const next = jest.fn() + const res = makeRes() + bridgeEnabledGuard(makeReq("/deposit"), res, next) + expect(next).not.toHaveBeenCalled() + expect(res.status).toHaveBeenCalledWith(503) + expect(res.json).toHaveBeenCalledWith({ error: "Bridge is disabled" }) + }) + + it("always lets /health through, even when disabled (k8s probes)", () => { + config.enabled = false + const next = jest.fn() + const res = makeRes() + bridgeEnabledGuard(makeReq("/health"), res, next) + expect(next).toHaveBeenCalled() + expect(res.status).not.toHaveBeenCalled() + }) +})