Skip to content

Commit ab2a26c

Browse files
authored
Merge pull request #8406 from BitGo/CAAS-1186
fix(express): prevent double-stringify of string payloads in handleV2 OFCSignPayload
2 parents d9880f4 + e00bc1e commit ab2a26c

3 files changed

Lines changed: 81 additions & 1 deletion

File tree

modules/express/src/clientRoutes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -625,7 +625,7 @@ export async function handleV2OFCSignPayload(
625625

626626
const walletPassphrase = bodyWalletPassphrase || getWalletPwFromEnv(wallet.id());
627627
const tradingAccount = wallet.toTradingAccount();
628-
const stringifiedPayload = JSON.stringify(payload);
628+
const stringifiedPayload = typeof payload === 'string' ? payload : JSON.stringify(payload);
629629
const signature = await tradingAccount.signPayload({
630630
payload: stringifiedPayload,
631631
walletPassphrase,

modules/express/test/unit/clientRoutes/signPayload.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,32 @@ describe('Sign an arbitrary payload with trading account key', function () {
8787
throw new Error(`Response did not match expected codec`);
8888
});
8989
});
90+
it('should not double-stringify when decoded payload is already a string', async function () {
91+
// When the io-ts Json codec matches a string input (left-to-right union resolution),
92+
// decoded.payload is the original string. The handler must not JSON.stringify it again.
93+
const expectedResponse = {
94+
payload: stringifiedPayload,
95+
signature,
96+
};
97+
const req = {
98+
bitgo: bitGoStub,
99+
body: {
100+
payload: stringifiedPayload,
101+
walletId,
102+
},
103+
decoded: {
104+
walletId,
105+
payload: stringifiedPayload,
106+
},
107+
query: {},
108+
} as unknown as ExpressApiRouteRequest<'express.ofc.signPayload', 'post'>;
109+
const result = await handleV2OFCSignPayload(req).should.be.resolvedWith(expectedResponse);
110+
result.payload.should.equal(stringifiedPayload);
111+
result.payload.should.not.startWith('"');
112+
decodeOrElse('OfcSignPayloadResponse200', OfcSignPayloadResponse[200], result, (_) => {
113+
throw new Error(`Response did not match expected codec`);
114+
});
115+
});
90116
it('should decode handler response with OfcSignPayloadResponse codec', async function () {
91117
const expected = {
92118
payload: JSON.stringify(payload),

modules/express/test/unit/typedRoutes/ofcSignPayload.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,60 @@ describe('OfcSignPayload codec tests', function () {
136136
assert.ok(decodedResponse);
137137
});
138138

139+
it('should not double-stringify a stringified JSON payload', async function () {
140+
const originalPayload = '{"coin":"ofctbtc","recipients":[{"address":"abc123","amount":"1000"}]}';
141+
const requestBody = {
142+
walletId: 'ofc-wallet-id-123',
143+
payload: originalPayload,
144+
walletPassphrase: 'test_passphrase',
145+
};
146+
147+
const mockTradingAccount = {
148+
signPayload: sinon.stub().resolves(mockSignPayloadResponse.signature),
149+
};
150+
151+
const mockWallet = {
152+
id: () => requestBody.walletId,
153+
toTradingAccount: sinon.stub().returns(mockTradingAccount),
154+
};
155+
156+
const walletsGetStub = sinon.stub().resolves(mockWallet);
157+
const mockWallets = { get: walletsGetStub };
158+
const mockCoin = { wallets: sinon.stub().returns(mockWallets) };
159+
sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any);
160+
161+
const result = await agent
162+
.post('/api/v2/ofc/signPayload')
163+
.set('Authorization', 'Bearer test_access_token_12345')
164+
.set('Content-Type', 'application/json')
165+
.send(requestBody);
166+
167+
assert.strictEqual(result.status, 200);
168+
const decodedResponse = assertDecode(OfcSignPayloadResponse200, result.body);
169+
170+
// The returned payload must not be double-stringified.
171+
// A double-stringified payload would start with a quote character and contain escaped quotes.
172+
assert.strictEqual(
173+
decodedResponse.payload.startsWith('"'),
174+
false,
175+
'payload was double-stringified: starts with a quote character'
176+
);
177+
assert.strictEqual(
178+
decodedResponse.payload.includes('\\"'),
179+
false,
180+
'payload was double-stringified: contains escaped quotes'
181+
);
182+
183+
// The payload passed to tradingAccount.signPayload must match the original string
184+
const signCall = mockTradingAccount.signPayload.getCall(0);
185+
assert.ok(signCall, 'tradingAccount.signPayload should have been called');
186+
assert.strictEqual(
187+
signCall.args[0].payload,
188+
originalPayload,
189+
'tradingAccount.signPayload received a different payload than the original'
190+
);
191+
});
192+
139193
it('should successfully sign payload without walletPassphrase (uses env)', async function () {
140194
const requestBody = {
141195
walletId: 'ofc-wallet-id-123',

0 commit comments

Comments
 (0)