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
4 changes: 3 additions & 1 deletion src/app/bridge/send-deposit-notification.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getI18nInstance } from "@config"
import { BridgeConfig, getI18nInstance } from "@config"
import { checkedToAccountId } from "@domain/accounts"
import { getLanguageOrDefault } from "@domain/locale"
import {
Expand Down Expand Up @@ -77,6 +77,8 @@ export const sendBridgeDepositNotification = async ({
export const sendBridgeDepositNotificationBestEffort = async (
args: Parameters<typeof sendBridgeDepositNotification>[0],
): Promise<void> => {
// 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) {
Expand Down
7 changes: 7 additions & 0 deletions src/services/bridge/webhook-server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions src/services/bridge/webhook-server/middleware/enabled-guard.ts
Original file line number Diff line number Diff line change
@@ -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()
}
58 changes: 58 additions & 0 deletions test/flash/unit/app/bridge/send-deposit-notification.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading