Skip to content

Commit 004c293

Browse files
authored
fix: support SSL_CERT_FILE for TLS certificate configuration (#1124)
* fix: support SSL_CERT_FILE for TLS certificate configuration Node.js only reads NODE_EXTRA_CA_CERTS at process startup, so setting SSL_CERT_FILE (which the CLI maps to NODE_EXTRA_CA_CERTS internally) had no effect on the parent process's TLS connections. This caused "unable to get local issuer certificate" errors for users behind corporate proxies with SSL inspection (e.g. Cloudflare). The fix manually reads the certificate file and passes the combined CA certificates (root + extra) to HTTPS agents: - SDK calls: HttpsAgent or HttpsProxyAgent with ca option - Direct fetch calls: falls back to node:https.request with custom agent - Child processes (Coana CLI): already worked via constants.processEnv * fix: harden SSL_CERT_FILE support with Content-Length, redirects, and broader coverage - Set Content-Length header for POST bodies in httpsRequest path to avoid chunked transfer encoding divergence from fetch() - Follow 3xx redirects in httpsRequest path to match fetch() behavior - Route all fetch calls through apiFetch (GitHub API, npm registry) - Add debug logging when certificate file read fails * fix: strip sensitive headers on cross-origin redirects in apiFetch The _httpsRequestFetch redirect handler forwarded all headers (including Authorization, Cookie, Proxy-Authorization) to redirect targets regardless of origin. Per the Fetch spec, these sensitive headers must be stripped on cross-origin redirects to prevent credential leaks. This is especially relevant for GitHub API calls that may redirect to CDN hosts for file downloads.
1 parent 51d1eb7 commit 004c293

File tree

6 files changed

+994
-12
lines changed

6 files changed

+994
-12
lines changed

src/commands/scan/create-scan-from-github.mts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { confirm, select } from '@socketsecurity/registry/lib/prompts'
1616
import { fetchSupportedScanFileNames } from './fetch-supported-scan-file-names.mts'
1717
import { handleCreateNewScan } from './handle-create-new-scan.mts'
1818
import constants from '../../constants.mts'
19+
import { apiFetch } from '../../utils/api.mts'
1920
import { debugApiRequest, debugApiResponse } from '../../utils/debug.mts'
2021
import { formatErrorWithDetail } from '../../utils/errors.mts'
2122
import { isReportSupportedFile } from '../../utils/glob.mts'
@@ -402,7 +403,7 @@ async function downloadManifestFile({
402403
debugApiRequest('GET', fileUrl)
403404
let downloadUrlResponse: Response
404405
try {
405-
downloadUrlResponse = await fetch(fileUrl, {
406+
downloadUrlResponse = await apiFetch(fileUrl, {
406407
method: 'GET',
407408
headers: {
408409
Authorization: `Bearer ${githubToken}`,
@@ -466,7 +467,7 @@ async function streamDownloadWithFetch(
466467

467468
try {
468469
debugApiRequest('GET', downloadUrl)
469-
response = await fetch(downloadUrl)
470+
response = await apiFetch(downloadUrl)
470471
debugApiResponse('GET', downloadUrl, response.status)
471472

472473
if (!response.ok) {
@@ -567,7 +568,7 @@ async function getLastCommitDetails({
567568
debugApiRequest('GET', commitApiUrl)
568569
let commitResponse: Response
569570
try {
570-
commitResponse = await fetch(commitApiUrl, {
571+
commitResponse = await apiFetch(commitApiUrl, {
571572
headers: {
572573
Authorization: `Bearer ${githubToken}`,
573574
},
@@ -679,7 +680,7 @@ async function getRepoDetails({
679680
let repoDetailsResponse: Response
680681
try {
681682
debugApiRequest('GET', repoApiUrl)
682-
repoDetailsResponse = await fetch(repoApiUrl, {
683+
repoDetailsResponse = await apiFetch(repoApiUrl, {
683684
method: 'GET',
684685
headers: {
685686
Authorization: `Bearer ${githubToken}`,
@@ -743,7 +744,7 @@ async function getRepoBranchTree({
743744
let treeResponse: Response
744745
try {
745746
debugApiRequest('GET', treeApiUrl)
746-
treeResponse = await fetch(treeApiUrl, {
747+
treeResponse = await apiFetch(treeApiUrl, {
747748
method: 'GET',
748749
headers: {
749750
Authorization: `Bearer ${githubToken}`,

src/utils/api.mts

Lines changed: 146 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
* - Falls back to configured apiBaseUrl or default API_V0_URL
2020
*/
2121

22+
import { Agent as HttpsAgent, request as httpsRequest } from 'node:https'
23+
2224
import { messageWithCauses } from 'pony-cause'
2325

2426
import { debugDir, debugFn } from '@socketsecurity/registry/lib/debug'
@@ -37,7 +39,7 @@ import constants, {
3739
HTTP_STATUS_UNAUTHORIZED,
3840
} from '../constants.mts'
3941
import { getRequirements, getRequirementsKey } from './requirements.mts'
40-
import { getDefaultApiToken } from './sdk.mts'
42+
import { getDefaultApiToken, getExtraCaCerts } from './sdk.mts'
4143

4244
import type { CResult } from '../types.mts'
4345
import type { Spinner } from '@socketsecurity/registry/lib/spinner'
@@ -48,8 +50,149 @@ import type {
4850
SocketSdkSuccessResult,
4951
} from '@socketsecurity/sdk'
5052

53+
const MAX_REDIRECTS = 20
5154
const NO_ERROR_MESSAGE = 'No error message returned'
5255

56+
// Cached HTTPS agent for extra CA certificate support in direct API calls.
57+
let _httpsAgent: HttpsAgent | undefined
58+
let _httpsAgentResolved = false
59+
60+
// Returns an HTTPS agent configured with extra CA certificates when
61+
// SSL_CERT_FILE is set but NODE_EXTRA_CA_CERTS is not.
62+
function getHttpsAgent(): HttpsAgent | undefined {
63+
if (_httpsAgentResolved) {
64+
return _httpsAgent
65+
}
66+
_httpsAgentResolved = true
67+
const ca = getExtraCaCerts()
68+
if (!ca) {
69+
return undefined
70+
}
71+
_httpsAgent = new HttpsAgent({ ca })
72+
return _httpsAgent
73+
}
74+
75+
// Wrapper around fetch that supports extra CA certificates via SSL_CERT_FILE.
76+
// Uses node:https.request with a custom agent when extra CA certs are needed,
77+
// falling back to regular fetch() otherwise. Follows redirects like fetch().
78+
export type ApiFetchInit = {
79+
body?: string | undefined
80+
headers?: Record<string, string> | undefined
81+
method?: string | undefined
82+
}
83+
84+
// Internal httpsRequest-based fetch with redirect support.
85+
function _httpsRequestFetch(
86+
url: string,
87+
init: ApiFetchInit,
88+
agent: HttpsAgent,
89+
redirectCount: number,
90+
): Promise<Response> {
91+
return new Promise((resolve, reject) => {
92+
const headers: Record<string, string> = { ...init.headers }
93+
// Set Content-Length for request bodies to avoid chunked transfer encoding.
94+
if (init.body) {
95+
headers['content-length'] = String(Buffer.byteLength(init.body))
96+
}
97+
const req = httpsRequest(
98+
url,
99+
{
100+
method: init.method || 'GET',
101+
headers,
102+
agent,
103+
},
104+
res => {
105+
const { statusCode } = res
106+
// Follow redirects to match fetch() behavior.
107+
if (
108+
statusCode &&
109+
statusCode >= 300 &&
110+
statusCode < 400 &&
111+
res.headers['location']
112+
) {
113+
// Consume the response body to free up memory.
114+
res.resume()
115+
if (redirectCount >= MAX_REDIRECTS) {
116+
reject(new Error('Maximum redirect limit reached'))
117+
return
118+
}
119+
const redirectUrl = new URL(res.headers['location'], url).href
120+
// Strip sensitive headers on cross-origin redirects to match
121+
// fetch() behavior per the Fetch spec.
122+
const originalOrigin = new URL(url).origin
123+
const redirectOrigin = new URL(redirectUrl).origin
124+
let redirectHeaders = init.headers
125+
if (originalOrigin !== redirectOrigin && redirectHeaders) {
126+
redirectHeaders = { ...redirectHeaders }
127+
for (const key of Object.keys(redirectHeaders)) {
128+
const lower = key.toLowerCase()
129+
if (
130+
lower === 'authorization' ||
131+
lower === 'cookie' ||
132+
lower === 'proxy-authorization'
133+
) {
134+
delete redirectHeaders[key]
135+
}
136+
}
137+
}
138+
// 307 and 308 preserve the original method and body.
139+
const preserveMethod = statusCode === 307 || statusCode === 308
140+
resolve(
141+
_httpsRequestFetch(
142+
redirectUrl,
143+
preserveMethod
144+
? { ...init, headers: redirectHeaders }
145+
: { headers: redirectHeaders, method: 'GET' },
146+
agent,
147+
redirectCount + 1,
148+
),
149+
)
150+
return
151+
}
152+
const chunks: Buffer[] = []
153+
res.on('data', (chunk: Buffer) => chunks.push(chunk))
154+
res.on('end', () => {
155+
const body = Buffer.concat(chunks)
156+
const responseHeaders = new Headers()
157+
for (const [key, value] of Object.entries(res.headers)) {
158+
if (typeof value === 'string') {
159+
responseHeaders.set(key, value)
160+
} else if (Array.isArray(value)) {
161+
for (const v of value) {
162+
responseHeaders.append(key, v)
163+
}
164+
}
165+
}
166+
resolve(
167+
new Response(body, {
168+
status: statusCode ?? 0,
169+
statusText: res.statusMessage ?? '',
170+
headers: responseHeaders,
171+
}),
172+
)
173+
})
174+
res.on('error', reject)
175+
},
176+
)
177+
if (init.body) {
178+
req.write(init.body)
179+
}
180+
req.on('error', reject)
181+
req.end()
182+
})
183+
}
184+
185+
export async function apiFetch(
186+
url: string,
187+
init: ApiFetchInit = {},
188+
): Promise<Response> {
189+
const agent = getHttpsAgent()
190+
if (!agent) {
191+
return await fetch(url, init as globalThis.RequestInit)
192+
}
193+
return await _httpsRequestFetch(url, init, agent, 0)
194+
}
195+
53196
export type CommandRequirements = {
54197
permissions?: string[] | undefined
55198
quota?: number | undefined
@@ -287,7 +430,7 @@ async function queryApi(path: string, apiToken: string) {
287430
}
288431

289432
const url = `${baseUrl}${baseUrl.endsWith('/') ? '' : '/'}${path}`
290-
const result = await fetch(url, {
433+
const result = await apiFetch(url, {
291434
method: 'GET',
292435
headers: {
293436
Authorization: `Basic ${btoa(`${apiToken}:`)}`,
@@ -480,7 +623,7 @@ export async function sendApiRequest<T>(
480623
...(body ? { body: JSON.stringify(body) } : {}),
481624
}
482625

483-
result = await fetch(
626+
result = await apiFetch(
484627
`${baseUrl}${baseUrl.endsWith('/') ? '' : '/'}${path}`,
485628
fetchOptions,
486629
)

0 commit comments

Comments
 (0)