Skip to content

Commit a7dbd99

Browse files
feat: capture raw body bytes for v4 hmac calculation
TICKET: CAAS-659
1 parent 8b4a279 commit a7dbd99

File tree

4 files changed

+193
-6
lines changed

4 files changed

+193
-6
lines changed

modules/express/src/clientRoutes.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1192,6 +1192,23 @@ async function handleNetworkV1EnterpriseClientConnections(
11921192
return handleProxyReq(req, res, next);
11931193
}
11941194

1195+
/**
1196+
* Helper to send request body, using raw bytes when available.
1197+
* For v4 HMAC authentication, we need to send the exact bytes that were
1198+
* received from the client to ensure the HMAC signature matches.
1199+
*
1200+
* @param request - The superagent request object
1201+
* @param req - The Express request containing body and rawBodyBuffer
1202+
* @returns The request with body attached
1203+
*/
1204+
function sendRequestBody(request: ReturnType<BitGo['post']>, req: express.Request) {
1205+
if (req.rawBodyBuffer && req.rawBodyBuffer.length > 0) {
1206+
return request.set('Content-Type', 'application/json').send(req.rawBodyBuffer);
1207+
}
1208+
// Fall back to parsed body for backward compatibility
1209+
return request.send(req.body);
1210+
}
1211+
11951212
/**
11961213
* Redirect a request using the bitgo request functions.
11971214
* @param bitgo
@@ -1214,19 +1231,19 @@ export function redirectRequest(
12141231
request = bitgo.get(url);
12151232
break;
12161233
case 'POST':
1217-
request = bitgo.post(url).send(req.body);
1234+
request = sendRequestBody(bitgo.post(url), req);
12181235
break;
12191236
case 'PUT':
1220-
request = bitgo.put(url).send(req.body);
1237+
request = sendRequestBody(bitgo.put(url), req);
12211238
break;
12221239
case 'PATCH':
1223-
request = bitgo.patch(url).send(req.body);
1240+
request = sendRequestBody(bitgo.patch(url), req);
12241241
break;
12251242
case 'OPTIONS':
1226-
request = bitgo.options(url).send(req.body);
1243+
request = sendRequestBody(bitgo.options(url), req);
12271244
break;
12281245
case 'DELETE':
1229-
request = bitgo.del(url).send(req.body);
1246+
request = sendRequestBody(bitgo.del(url), req);
12301247
break;
12311248
}
12321249

modules/express/src/expressApp.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,18 @@ export function app(cfg: Config): express.Application {
302302
checkPreconditions(cfg);
303303
debug('preconditions satisfied');
304304

305-
app.use(bodyParser.json({ limit: '20mb' }));
305+
app.use(
306+
bodyParser.json({
307+
limit: '20mb',
308+
verify: (req, res, buf) => {
309+
// Store the raw body buffer on the request object.
310+
// This preserves the exact bytes before JSON parsing,
311+
// which may alter whitespace, key ordering, etc.
312+
// Required for v4 HMAC authentication.
313+
(req as express.Request).rawBodyBuffer = buf;
314+
},
315+
})
316+
);
306317

307318
// Be more robust about accepting URLs with double slashes
308319
app.use(function replaceUrlSlashes(req, res, next) {
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/**
2+
* Tests for raw body buffer capture functionality
3+
* This ensures exact bytes are preserved for v4 HMAC authentication
4+
* @prettier
5+
*/
6+
import 'should';
7+
import 'should-http';
8+
import 'should-sinon';
9+
import '../../lib/asserts';
10+
import * as sinon from 'sinon';
11+
import * as express from 'express';
12+
import { agent as supertest, Response } from 'supertest';
13+
import { app as expressApp } from '../../../src/expressApp';
14+
15+
describe('Raw Body Buffer Capture', () => {
16+
const sandbox = sinon.createSandbox();
17+
18+
afterEach(() => {
19+
sandbox.verifyAndRestore();
20+
});
21+
22+
describe('body-parser verify callback', () => {
23+
let agent: ReturnType<typeof supertest>;
24+
25+
beforeEach(() => {
26+
const app = expressApp({
27+
env: 'test',
28+
disableProxy: true,
29+
} as any);
30+
31+
// Add a test route that returns the rawBodyBuffer info
32+
app.post('/test/rawbody', (req: express.Request, res: express.Response) => {
33+
res.json({
34+
hasRawBodyBuffer: !!req.rawBodyBuffer,
35+
rawBodyBufferLength: req.rawBodyBuffer?.length || 0,
36+
rawBodyBufferContent: req.rawBodyBuffer?.toString('utf-8') || null,
37+
parsedBody: req.body,
38+
bodyKeysCount: Object.keys(req.body || {}).length,
39+
});
40+
});
41+
42+
// Add a test route for HMAC verification
43+
app.post('/test/hmac-check', (req: express.Request, res: express.Response) => {
44+
const rawBytes = req.rawBodyBuffer;
45+
const reSerializedBytes = Buffer.from(JSON.stringify(req.body));
46+
47+
res.json({
48+
rawBytesHex: rawBytes?.toString('hex') || null,
49+
reSerializedBytesHex: reSerializedBytes.toString('hex'),
50+
bytesMatch: rawBytes?.toString('hex') === reSerializedBytes.toString('hex'),
51+
rawLength: rawBytes?.length || 0,
52+
reSerializedLength: reSerializedBytes.length,
53+
});
54+
});
55+
56+
// Add a test route for HMAC chain simulation
57+
app.post('/test/hmac-chain', (req: express.Request, res: express.Response) => {
58+
const clientBytes = req.rawBodyBuffer;
59+
const parsedAndSerialized = Buffer.from(JSON.stringify(req.body));
60+
61+
res.json({
62+
clientBytesPreserved: !!clientBytes,
63+
wouldHmacMatch: clientBytes?.toString('hex') === parsedAndSerialized.toString('hex'),
64+
recommendation: clientBytes ? 'Use rawBodyBuffer for HMAC' : 'Missing rawBodyBuffer',
65+
});
66+
});
67+
68+
agent = supertest(app);
69+
});
70+
71+
it('should capture raw body buffer on POST requests', async () => {
72+
const testBody = { address: 'tb1qtest', amount: 100000 };
73+
74+
const res: Response = await agent.post('/test/rawbody').set('Content-Type', 'application/json').send(testBody);
75+
76+
res.status.should.equal(200);
77+
res.body.hasRawBodyBuffer.should.equal(true);
78+
res.body.rawBodyBufferLength.should.be.greaterThan(0);
79+
res.body.parsedBody.should.deepEqual(testBody);
80+
});
81+
82+
it('should preserve exact bytes including whitespace', async () => {
83+
// JSON with extra whitespace that would be lost during parse/re-serialize
84+
const bodyWithWhitespace = '{"address": "tb1qtest", "amount":100000}';
85+
86+
const res: Response = await agent
87+
.post('/test/rawbody')
88+
.set('Content-Type', 'application/json')
89+
.send(bodyWithWhitespace);
90+
91+
res.status.should.equal(200);
92+
// Raw buffer should preserve the exact whitespace
93+
res.body.rawBodyBufferContent.should.equal(bodyWithWhitespace);
94+
// Parsed body should have the correct values
95+
res.body.parsedBody.address.should.equal('tb1qtest');
96+
res.body.parsedBody.amount.should.equal(100000);
97+
});
98+
99+
it('should preserve exact key ordering', async () => {
100+
// JSON with specific key ordering
101+
const bodyWithOrdering = '{"z_last":"last","a_first":"first","m_middle":"middle"}';
102+
103+
const res: Response = await agent
104+
.post('/test/rawbody')
105+
.set('Content-Type', 'application/json')
106+
.send(bodyWithOrdering);
107+
108+
res.status.should.equal(200);
109+
// Raw buffer should preserve exact key ordering
110+
res.body.rawBodyBufferContent.should.equal(bodyWithOrdering);
111+
});
112+
113+
it('should handle empty body', async () => {
114+
const res: Response = await agent.post('/test/rawbody').set('Content-Type', 'application/json').send({});
115+
116+
res.status.should.equal(200);
117+
res.body.hasRawBodyBuffer.should.equal(true);
118+
res.body.rawBodyBufferLength.should.equal(2); // "{}"
119+
});
120+
121+
it('should handle nested JSON objects', async () => {
122+
const nestedBody = {
123+
level1: {
124+
level2: {
125+
level3: {
126+
value: 'deep',
127+
},
128+
},
129+
},
130+
};
131+
132+
const res: Response = await agent.post('/test/rawbody').set('Content-Type', 'application/json').send(nestedBody);
133+
134+
res.status.should.equal(200);
135+
res.body.hasRawBodyBuffer.should.equal(true);
136+
res.body.parsedBody.level1.level2.level3.value.should.equal('deep');
137+
});
138+
139+
it('should preserve raw bytes for HMAC calculation across the request chain', async () => {
140+
const walletSendBody =
141+
'{"address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh","amount":1000000,"walletPassphrase":"test"}';
142+
143+
const res: Response = await agent
144+
.post('/test/hmac-chain')
145+
.set('Content-Type', 'application/json')
146+
.send(walletSendBody);
147+
148+
res.status.should.equal(200);
149+
res.body.clientBytesPreserved.should.equal(true);
150+
res.body.recommendation.should.equal('Use rawBodyBuffer for HMAC');
151+
});
152+
});
153+
});

modules/express/types/express/index.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,11 @@ declare module 'express-serve-static-core' {
66
isProxy: boolean;
77
bitgo: BitGo;
88
config: Config;
9+
/**
10+
* Raw body buffer captured before JSON parsing.
11+
* Used for v4 HMAC authentication to ensure the exact bytes
12+
* sent by the client are used for signature calculation.
13+
*/
14+
rawBodyBuffer?: Buffer;
915
}
1016
}

0 commit comments

Comments
 (0)