Skip to content

Commit 95355ea

Browse files
committed
feat(http): add ca option for custom TLS certificates
Add `ca` option to HttpRequestOptions, HttpDownloadOptions, and FetchChecksumsOptions for custom CA certificate support. This enables SSL_CERT_FILE support when NODE_EXTRA_CA_CERTS was not set at process startup. Thread ca through httpRequest, httpDownload, and fetchChecksums including their redirect and retry paths. The ca option is passed directly to Node.js https.request for TLS connections.
1 parent 9528e31 commit 95355ea

2 files changed

Lines changed: 113 additions & 4 deletions

File tree

src/http-request.ts

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,24 @@ export interface HttpRequestOptions {
100100
* ```
101101
*/
102102
body?: Buffer | string | undefined
103+
/**
104+
* Custom CA certificates for TLS connections.
105+
* When provided, these certificates are combined with the default trust
106+
* store via an HTTPS agent. Useful when SSL_CERT_FILE is set but
107+
* NODE_EXTRA_CA_CERTS was not available at process startup.
108+
*
109+
* @example
110+
* ```ts
111+
* import { rootCertificates } from 'node:tls'
112+
* import { readFileSync } from 'node:fs'
113+
*
114+
* const extraCerts = readFileSync('/path/to/cert.pem', 'utf-8')
115+
* await httpRequest('https://api.example.com', {
116+
* ca: [...rootCertificates, extraCerts]
117+
* })
118+
* ```
119+
*/
120+
ca?: string[] | undefined
103121
/**
104122
* Whether to automatically follow HTTP redirects (3xx status codes).
105123
*
@@ -334,6 +352,12 @@ export interface HttpResponse {
334352
* Configuration options for file downloads.
335353
*/
336354
export interface HttpDownloadOptions {
355+
/**
356+
* Custom CA certificates for TLS connections.
357+
* When provided, these certificates are used for the download request.
358+
* See `HttpRequestOptions.ca` for details.
359+
*/
360+
ca?: string[] | undefined
337361
/**
338362
* Whether to automatically follow HTTP redirects (3xx status codes).
339363
* This is essential for downloading from services that use CDN redirects,
@@ -608,6 +632,11 @@ export function parseChecksums(text: string): Checksums {
608632
* Options for fetching checksums from a URL.
609633
*/
610634
export interface FetchChecksumsOptions {
635+
/**
636+
* Custom CA certificates for TLS connections.
637+
* See `HttpRequestOptions.ca` for details.
638+
*/
639+
ca?: string[] | undefined
611640
/**
612641
* HTTP headers to send with the request.
613642
*/
@@ -649,12 +678,16 @@ export async function fetchChecksums(
649678
url: string,
650679
options?: FetchChecksumsOptions | undefined,
651680
): Promise<Checksums> {
652-
const { headers = {}, timeout = 30_000 } = {
681+
const {
682+
ca,
683+
headers = {},
684+
timeout = 30_000,
685+
} = {
653686
__proto__: null,
654687
...options,
655688
} as FetchChecksumsOptions
656689

657-
const response = await httpRequest(url, { headers, timeout })
690+
const response = await httpRequest(url, { ca, headers, timeout })
658691

659692
if (!response.ok) {
660693
throw new Error(
@@ -675,6 +708,7 @@ async function httpDownloadAttempt(
675708
options: HttpDownloadOptions,
676709
): Promise<HttpDownloadResult> {
677710
const {
711+
ca,
678712
followRedirects = true,
679713
headers = {},
680714
maxRedirects = 5,
@@ -687,7 +721,7 @@ async function httpDownloadAttempt(
687721
const isHttps = parsedUrl.protocol === 'https:'
688722
const httpModule = isHttps ? getHttps() : getHttp()
689723

690-
const requestOptions = {
724+
const requestOptions: Record<string, unknown> = {
691725
headers: {
692726
'User-Agent': 'socket-registry/1.0',
693727
...headers,
@@ -699,6 +733,11 @@ async function httpDownloadAttempt(
699733
timeout,
700734
}
701735

736+
// Pass custom CA certificates for TLS connections.
737+
if (ca && isHttps) {
738+
requestOptions['ca'] = ca
739+
}
740+
702741
const { createWriteStream } = getFs()
703742

704743
let fileStream: ReturnType<typeof createWriteStream> | undefined
@@ -739,6 +778,7 @@ async function httpDownloadAttempt(
739778

740779
resolve(
741780
httpDownloadAttempt(redirectUrl, destPath, {
781+
ca,
742782
followRedirects,
743783
headers,
744784
maxRedirects: maxRedirects - 1,
@@ -850,6 +890,7 @@ async function httpRequestAttempt(
850890
): Promise<HttpResponse> {
851891
const {
852892
body,
893+
ca,
853894
followRedirects = true,
854895
headers = {},
855896
maxRedirects = 5,
@@ -862,7 +903,7 @@ async function httpRequestAttempt(
862903
const isHttps = parsedUrl.protocol === 'https:'
863904
const httpModule = isHttps ? getHttps() : getHttp()
864905

865-
const requestOptions = {
906+
const requestOptions: Record<string, unknown> = {
866907
headers: {
867908
'User-Agent': 'socket-registry/1.0',
868909
...headers,
@@ -874,6 +915,11 @@ async function httpRequestAttempt(
874915
timeout,
875916
}
876917

918+
// Pass custom CA certificates for TLS connections.
919+
if (ca && isHttps) {
920+
requestOptions['ca'] = ca
921+
}
922+
877923
/* c8 ignore start - External HTTP/HTTPS request */
878924
const request = httpModule.request(
879925
requestOptions,
@@ -903,6 +949,7 @@ async function httpRequestAttempt(
903949
resolve(
904950
httpRequestAttempt(redirectUrl, {
905951
body,
952+
ca,
906953
followRedirects,
907954
headers,
908955
maxRedirects: maxRedirects - 1,
@@ -1055,6 +1102,7 @@ export async function httpDownload(
10551102
options?: HttpDownloadOptions | undefined,
10561103
): Promise<HttpDownloadResult> {
10571104
const {
1105+
ca,
10581106
followRedirects = true,
10591107
headers = {},
10601108
logger,
@@ -1103,6 +1151,7 @@ export async function httpDownload(
11031151
try {
11041152
// eslint-disable-next-line no-await-in-loop
11051153
const result = await httpDownloadAttempt(url, tempPath, {
1154+
ca,
11061155
followRedirects,
11071156
headers,
11081157
maxRedirects,
@@ -1296,6 +1345,7 @@ export async function httpRequest(
12961345
): Promise<HttpResponse> {
12971346
const {
12981347
body,
1348+
ca,
12991349
followRedirects = true,
13001350
headers = {},
13011351
maxRedirects = 5,
@@ -1312,6 +1362,7 @@ export async function httpRequest(
13121362
// eslint-disable-next-line no-await-in-loop
13131363
return await httpRequestAttempt(url, {
13141364
body,
1365+
ca,
13151366
followRedirects,
13161367
headers,
13171368
maxRedirects,

test/unit/http-request.test.mts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1746,4 +1746,62 @@ abc123def456789012345678901234567890123456789012345678901234abcd
17461746
}
17471747
})
17481748
})
1749+
1750+
describe('ca option', () => {
1751+
it('should accept ca option on httpRequest without error', async () => {
1752+
// ca is a no-op for HTTP (only applies to HTTPS), but should not throw.
1753+
const response = await httpRequest(`${httpBaseUrl}/text`, {
1754+
ca: ['-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----'],
1755+
})
1756+
1757+
expect(response.status).toBe(200)
1758+
expect(response.text()).toBe('Plain text response')
1759+
})
1760+
1761+
it('should accept ca option on httpJson without error', async () => {
1762+
const data = await httpJson<{ message: string }>(`${httpBaseUrl}/json`, {
1763+
ca: ['-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----'],
1764+
})
1765+
1766+
expect(data.message).toBe('Hello, World!')
1767+
})
1768+
1769+
it('should accept ca option on httpText without error', async () => {
1770+
const text = await httpText(`${httpBaseUrl}/text`, {
1771+
ca: ['-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----'],
1772+
})
1773+
1774+
expect(text).toBe('Plain text response')
1775+
})
1776+
1777+
it('should accept ca option on httpDownload without error', async () => {
1778+
await runWithTempDir(async tempDir => {
1779+
const destPath = path.join(tempDir, 'ca-test.txt')
1780+
const result = await httpDownload(`${httpBaseUrl}/download`, destPath, {
1781+
ca: ['-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----'],
1782+
})
1783+
1784+
expect(result.path).toBe(destPath)
1785+
expect(result.size).toBeGreaterThan(0)
1786+
})
1787+
})
1788+
1789+
it('should accept ca option on fetchChecksums without error', async () => {
1790+
const checksums = await fetchChecksums(`${httpBaseUrl}/checksums.txt`, {
1791+
ca: ['-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----'],
1792+
})
1793+
1794+
expect(checksums['checksum-file']).toBeDefined()
1795+
})
1796+
1797+
it('should pass ca through redirects on httpRequest', async () => {
1798+
// ca should be preserved through redirect chains.
1799+
const response = await httpRequest(`${httpBaseUrl}/redirect`, {
1800+
ca: ['-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----'],
1801+
})
1802+
1803+
expect(response.status).toBe(200)
1804+
expect(response.text()).toBe('Plain text response')
1805+
})
1806+
})
17491807
})

0 commit comments

Comments
 (0)