From fafecdab451a7910c4aab2cbb4a6dd4330b1116d Mon Sep 17 00:00:00 2001 From: Michael McShinsky Date: Tue, 4 Aug 2026 15:24:42 -0700 Subject: [PATCH 1/2] fix(express): bind wallet signing to route wallet ID Reject mismatched prebuild wallet IDs before wallet lookup or signing on wallet and TSS wallet transaction routes. #9419 --- modules/express/src/clientRoutes.ts | 11 +++++++ .../test/unit/typedRoutes/walletSignTx.ts | 29 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index 598c0f6ee7..eeff8d6751 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -549,6 +549,7 @@ export async function handleV2GenerateShareTSS( } export async function handleV2SignTSSWalletTx(req: ExpressApiRouteRequest<'express.wallet.signtxtss', 'post'>) { + assertWalletIdMatchesRoute(req.decoded.id, req.decoded.txPrebuild?.walletId); const bitgo = req.bitgo; const coin = bitgo.coin(req.decoded.coin); const wallet = await coin.wallets().get({ id: req.decoded.id }); @@ -867,10 +868,20 @@ async function handleV2AcceptWalletShare(req: express.Request) { return coin.wallets().acceptShare(params); } +/** + * Ensure a prebuild cannot direct a wallet route to sign for a different wallet. + */ +function assertWalletIdMatchesRoute(routeWalletId: string, prebuildWalletId: string | undefined): void { + if (prebuildWalletId !== undefined && prebuildWalletId !== routeWalletId) { + throw new ApiResponseError('Wallet ID in txPrebuild does not match the route wallet ID', 400); + } +} + /** * handle wallet sign transaction */ async function handleV2SignTxWallet(req: ExpressApiRouteRequest<'express.wallet.signtx', 'post'>) { + assertWalletIdMatchesRoute(req.decoded.id, req.decoded.txPrebuild?.walletId); const bitgo = req.bitgo; const coin = bitgo.coin(req.decoded.coin); const wallet = await coin.wallets().get({ id: req.decoded.id }); diff --git a/modules/express/test/unit/typedRoutes/walletSignTx.ts b/modules/express/test/unit/typedRoutes/walletSignTx.ts index e06e88ba2b..058355ffd9 100644 --- a/modules/express/test/unit/typedRoutes/walletSignTx.ts +++ b/modules/express/test/unit/typedRoutes/walletSignTx.ts @@ -34,6 +34,35 @@ describe('WalletSignTx codec tests', function () { sinon.restore(); }); + it('should reject a prebuild belonging to a different wallet', async function () { + const requestBody = { + txPrebuild: { + txHex: 'transaction', + walletId: 'different-wallet-id', + }, + prv: 'xprv-test', + }; + + const mockWallet = { + signTransaction: sinon.stub().resolves(mockFullySignedResponse), + }; + const walletsGetStub = sinon.stub().resolves(mockWallet); + sinon.stub(BitGo.prototype, 'coin').returns({ + wallets: sinon.stub().returns({ get: walletsGetStub }), + } as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/signtx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 400); + result.body.error.should.match(/does not match the route wallet ID/); + walletsGetStub.called.should.be.false(); + mockWallet.signTransaction.called.should.be.false(); + }); + it('should successfully sign a wallet transaction', async function () { const requestBody = { txPrebuild: { From bad59d8cd07836d473e79f34656ed7373bf0178a Mon Sep 17 00:00:00 2001 From: Michael McShinsky Date: Wed, 5 Aug 2026 13:30:45 -0700 Subject: [PATCH 2/2] test(express): cover TSS wallet ID mismatch rejection #9419 --- .../test/unit/typedRoutes/walletTxSignTSS.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/modules/express/test/unit/typedRoutes/walletTxSignTSS.ts b/modules/express/test/unit/typedRoutes/walletTxSignTSS.ts index 36d96a4f87..4c39919985 100644 --- a/modules/express/test/unit/typedRoutes/walletTxSignTSS.ts +++ b/modules/express/test/unit/typedRoutes/walletTxSignTSS.ts @@ -34,6 +34,36 @@ describe('WalletTxSignTSS codec tests', function () { sinon.restore(); }); + it('should reject a prebuild belonging to a different wallet', async function () { + const requestBody = { + txPrebuild: { + txHex: 'transaction', + walletId: 'different-wallet-id', + }, + walletPassphrase: 'test_passphrase_12345', + apiVersion: 'lite' as const, + }; + + const mockWallet = { + ensureCleanSigSharesAndSignTransaction: sinon.stub().resolves(mockFullySignedResponse), + }; + const walletsGetStub = sinon.stub().resolves(mockWallet); + sinon.stub(BitGo.prototype, 'coin').returns({ + wallets: sinon.stub().returns({ get: walletsGetStub }), + } as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/signtxtss`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 400); + result.body.error.should.match(/does not match the route wallet ID/); + walletsGetStub.called.should.be.false(); + mockWallet.ensureCleanSigSharesAndSignTransaction.called.should.be.false(); + }); + it('should successfully sign a TSS wallet transaction', async function () { const requestBody = { txPrebuild: {