Skip to content

Commit 35e2582

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

4 files changed

Lines changed: 153 additions & 6 deletions

File tree

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: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
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+
agent = supertest(app);
43+
});
44+
45+
it('should capture raw body buffer on POST requests', async () => {
46+
const testBody = { address: 'tb1qtest', amount: 100000 };
47+
48+
const res: Response = await agent.post('/test/rawbody').set('Content-Type', 'application/json').send(testBody);
49+
50+
res.status.should.equal(200);
51+
res.body.hasRawBodyBuffer.should.equal(true);
52+
res.body.rawBodyBufferLength.should.be.greaterThan(0);
53+
res.body.parsedBody.should.deepEqual(testBody);
54+
});
55+
56+
it('should preserve exact bytes including whitespace', async () => {
57+
// JSON with extra whitespace that would be lost during parse/re-serialize
58+
const bodyWithWhitespace = '{"address": "tb1qtest", "amount":100000}';
59+
60+
const res: Response = await agent
61+
.post('/test/rawbody')
62+
.set('Content-Type', 'application/json')
63+
.send(bodyWithWhitespace);
64+
65+
res.status.should.equal(200);
66+
// Raw buffer should preserve the exact whitespace
67+
res.body.rawBodyBufferContent.should.equal(bodyWithWhitespace);
68+
// Parsed body should have the correct values
69+
res.body.parsedBody.address.should.equal('tb1qtest');
70+
res.body.parsedBody.amount.should.equal(100000);
71+
});
72+
73+
it('should preserve exact key ordering', async () => {
74+
// JSON with specific key ordering
75+
const bodyWithOrdering = '{"z_last":"last","a_first":"first","m_middle":"middle"}';
76+
77+
const res: Response = await agent
78+
.post('/test/rawbody')
79+
.set('Content-Type', 'application/json')
80+
.send(bodyWithOrdering);
81+
82+
res.status.should.equal(200);
83+
// Raw buffer should preserve exact key ordering
84+
res.body.rawBodyBufferContent.should.equal(bodyWithOrdering);
85+
});
86+
87+
it('should handle empty body', async () => {
88+
const res: Response = await agent.post('/test/rawbody').set('Content-Type', 'application/json').send({});
89+
90+
res.status.should.equal(200);
91+
res.body.hasRawBodyBuffer.should.equal(true);
92+
res.body.rawBodyBufferLength.should.equal(2); // "{}"
93+
});
94+
95+
it('should handle nested JSON objects', async () => {
96+
const nestedBody = {
97+
level1: {
98+
level2: {
99+
level3: {
100+
value: 'deep',
101+
},
102+
},
103+
},
104+
};
105+
106+
const res: Response = await agent.post('/test/rawbody').set('Content-Type', 'application/json').send(nestedBody);
107+
108+
res.status.should.equal(200);
109+
res.body.hasRawBodyBuffer.should.equal(true);
110+
res.body.parsedBody.level1.level2.level3.value.should.equal('deep');
111+
});
112+
});
113+
});

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)