Skip to content

Commit 8056da8

Browse files
authored
Add "source" field to user-agent header (#2740)
* Add source field to user-agent header * be more defensive in case something goes wrong
1 parent fac893c commit 8056da8

5 files changed

Lines changed: 125 additions & 2 deletions

File tree

src/platform/NodePlatformFunctions.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as crypto from 'crypto';
22
import * as http from 'http';
3+
import * as os from 'os';
34
import {CryptoProvider} from '../crypto/CryptoProvider.js';
45
import {EventEmitter} from 'events';
56
import {HttpClient, NodeHttpClientInterface} from '../net/HttpClient.js';
@@ -8,7 +9,6 @@ import {NodeHttpClient} from '../net/NodeHttpClient.js';
89
import {PlatformFunctions} from './PlatformFunctions.js';
910
import {StripeError} from '../Error.js';
1011
import {concat} from '../utils.js';
11-
import {arch, release} from 'os';
1212
import {MultipartRequestData, RequestData, BufferedFile} from '../Types.js';
1313

1414
class StreamProcessingError extends StripeError {}
@@ -28,7 +28,7 @@ export class NodePlatformFunctions extends PlatformFunctions {
2828

2929
/** @override */
3030
getPlatformInfo(): string {
31-
return `${process.platform} ${release()} ${arch()}`;
31+
return `${process.platform} ${os.release()} ${os.arch()}`;
3232
}
3333

3434
/** @override */
@@ -50,6 +50,38 @@ export class NodePlatformFunctions extends PlatformFunctions {
5050
return process.version;
5151
}
5252

53+
private getUname(): string | null {
54+
try {
55+
const parts = [os.type(), os.release(), os.arch()];
56+
// os.version() returns detailed kernel version, available since Node 10.7.0
57+
// It may not exist in older typings, so access carefully
58+
const version = (os as any).version?.();
59+
if (version) parts.push(version);
60+
try {
61+
parts.push(os.hostname());
62+
// eslint-disable-next-line no-empty
63+
} catch (_e) {}
64+
return parts.join(' ');
65+
} catch {
66+
return null;
67+
}
68+
}
69+
70+
/** @override */
71+
getSourceHash(): string | null {
72+
try {
73+
const uname = this.getUname();
74+
return uname
75+
? crypto
76+
.createHash('md5')
77+
.update(uname)
78+
.digest('hex')
79+
: null;
80+
} catch {
81+
return null;
82+
}
83+
}
84+
5385
/**
5486
* @override
5587
* Secure compare, from https://github.com/freewil/scmp

src/platform/PlatformFunctions.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ export class PlatformFunctions {
3535
return null;
3636
}
3737

38+
getSourceHash(): string | null {
39+
return null;
40+
}
41+
3842
/**
3943
* Emits a warning. Node.js uses process.emitWarning; other runtimes
4044
* fall back to console.warn.

src/stripe.core.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -956,6 +956,7 @@ export class Stripe {
956956
lang: 'node',
957957
typescript: false,
958958
};
959+
static SOURCE_HASH: string | null = null;
959960
static StripeResource = StripeResource;
960961
static resources = resources;
961962
static HttpClient = HttpClient;
@@ -1099,6 +1100,8 @@ export class Stripe {
10991100
...(runtimeVersion ? {lang_version: runtimeVersion} : {}),
11001101
...(Stripe.aiAgent ? {ai_agent: Stripe.aiAgent} : {}),
11011102
};
1103+
1104+
Stripe.SOURCE_HASH = platformFunctions.getSourceHash();
11021105
}
11031106

11041107
constructor(key: string, config: StripeConfig = {}) {
@@ -1445,6 +1448,10 @@ export class Stripe {
14451448
userAgent.application = this._appInfo;
14461449
}
14471450

1451+
if (Stripe.SOURCE_HASH) {
1452+
userAgent.source = Stripe.SOURCE_HASH;
1453+
}
1454+
14481455
cb(JSON.stringify(userAgent));
14491456
}
14501457

test/PlatformFunctions.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,27 @@ function testPlatform(platformFunctions: PlatformFunctions): void {
160160
}
161161
});
162162

163+
describe('getSourceHash', () => {
164+
if (isNodeEnvironment) {
165+
it('returns a 32-character hex MD5 string', () => {
166+
const hash = platformFunctions.getSourceHash();
167+
expect(hash).to.be.a('string');
168+
expect(hash).to.match(/^[0-9a-f]{32}$/);
169+
});
170+
171+
it('returns null when getUname returns null', () => {
172+
const orig = platformFunctions.getUname.bind(platformFunctions);
173+
(platformFunctions as any).getUname = () => null;
174+
expect(platformFunctions.getSourceHash()).to.be.null;
175+
(platformFunctions as any).getUname = orig;
176+
});
177+
} else {
178+
it('returns null on non-Node environments', () => {
179+
expect(platformFunctions.getSourceHash()).to.be.null;
180+
});
181+
}
182+
});
183+
163184
describe('createEmitter', () => {
164185
let emitter;
165186
beforeEach(() => {

test/stripe.spec.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,37 @@ describe('Stripe Module', function() {
253253
})
254254
).to.eventually.have.property('httplib', 'node');
255255
});
256+
257+
it('Should include source field when SOURCE_HASH is set', async () => {
258+
const origSourceHash = StripeCore.SOURCE_HASH;
259+
StripeCore.SOURCE_HASH = 'abc123def456abc123def456abc123de';
260+
261+
const userAgent: Record<string, string> = await new Promise((resolve) => {
262+
stripe.getClientUserAgentSeeded({lang: 'node'}, (c) => {
263+
resolve(JSON.parse(c));
264+
});
265+
});
266+
267+
StripeCore.SOURCE_HASH = origSourceHash;
268+
expect(userAgent).to.have.property(
269+
'source',
270+
'abc123def456abc123def456abc123de'
271+
);
272+
});
273+
274+
it('Should omit source field when SOURCE_HASH is null', async () => {
275+
const origSourceHash = StripeCore.SOURCE_HASH;
276+
StripeCore.SOURCE_HASH = null;
277+
278+
const userAgent: Record<string, string> = await new Promise((resolve) => {
279+
stripe.getClientUserAgentSeeded({lang: 'node'}, (c) => {
280+
resolve(JSON.parse(c));
281+
});
282+
});
283+
284+
StripeCore.SOURCE_HASH = origSourceHash;
285+
expect(userAgent).to.not.have.property('source');
286+
});
256287
});
257288

258289
describe('AI agent detection', () => {
@@ -324,11 +355,13 @@ describe('Stripe Module', function() {
324355
const origAIAgent = StripeCore.aiAgent;
325356
const origAiAgentStatic = StripeCore.AI_AGENT;
326357
const origUserAgent = StripeCore.USER_AGENT;
358+
const origSourceHash = StripeCore.SOURCE_HASH;
327359

328360
afterEach(() => {
329361
StripeCore.aiAgent = origAIAgent;
330362
StripeCore.AI_AGENT = origAiAgentStatic;
331363
StripeCore.USER_AGENT = origUserAgent;
364+
StripeCore.SOURCE_HASH = origSourceHash;
332365
StripeCore.initialize(new NodePlatformFunctions());
333366
});
334367

@@ -358,6 +391,32 @@ describe('Stripe Module', function() {
358391
expect(StripeCore.USER_AGENT).to.not.have.property('ai_agent');
359392
expect(StripeCore.USER_AGENT).to.not.have.property('lang_version');
360393
});
394+
395+
it('sets SOURCE_HASH to an MD5 hex string when getSourceHash is available', () => {
396+
const mockPlatform = new NodePlatformFunctions();
397+
const expectedHash = crypto
398+
.createHash('md5')
399+
.update('Linux hostname 5.4.0 #1 SMP x86_64 GNU/Linux')
400+
.digest('hex');
401+
mockPlatform.getSourceHash = () => expectedHash;
402+
403+
StripeCore.initialize(mockPlatform);
404+
405+
expect(StripeCore.SOURCE_HASH).to.be.a('string');
406+
expect(StripeCore.SOURCE_HASH).to.match(/^[0-9a-f]{32}$/);
407+
expect(StripeCore.SOURCE_HASH).to.equal(expectedHash);
408+
});
409+
410+
it('sets SOURCE_HASH to null when getSourceHash returns null', () => {
411+
const {
412+
PlatformFunctions,
413+
} = require('../src/platform/PlatformFunctions.js');
414+
const basePlatform = new PlatformFunctions();
415+
416+
StripeCore.initialize(basePlatform);
417+
418+
expect(StripeCore.SOURCE_HASH).to.be.null;
419+
});
361420
});
362421

363422
describe('timeout config', () => {

0 commit comments

Comments
 (0)