Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions src/utils/api.mts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import { Agent as HttpsAgent, request as httpsRequest } from 'node:https'
import { ReadableStream } from 'node:stream/web'

import { HttpsProxyAgent } from 'hpagent'
import { messageWithCauses } from 'pony-cause'

import { debugDir, debugFn } from '@socketsecurity/registry/lib/debug'
Expand All @@ -40,7 +41,12 @@ import constants, {
HTTP_STATUS_UNAUTHORIZED,
} from '../constants.mts'
import { getRequirements, getRequirementsKey } from './requirements.mts'
import { getCliUserAgent, getDefaultApiToken, getExtraCaCerts } from './sdk.mts'
import {
getCliUserAgent,
getDefaultApiToken,
getDefaultProxyUrl,
getExtraCaCerts,
} from './sdk.mts'

import type { CResult } from '../types.mts'
import type { Spinner } from '@socketsecurity/registry/lib/spinner'
Expand All @@ -52,9 +58,10 @@ const NO_ERROR_MESSAGE = 'No error message returned'
// getHttpsAgent() call lazily creates it.
let _httpsAgent: HttpsAgent | undefined

// Returns an explicit HTTPS agent for direct API calls, carrying extra CA
// certificates when SSL_CERT_FILE is set but NODE_EXTRA_CA_CERTS is not. An
// explicit agent is always returned. Node >=19's global agent enables keepAlive
// Returns an explicit HTTPS agent for direct API calls, routing through the
// configured proxy when one is set and carrying extra CA certificates when
// SSL_CERT_FILE is set but NODE_EXTRA_CA_CERTS is not. An explicit agent is
// always returned. Node >=19's global agent enables keepAlive
// with a 5s socket timeout that Node applies as a per-socket inactivity
// timeout. A request made without an explicit agent inherits it and is torn
// down after 5s of socket inactivity, prematurely dropping slow or idle-gapped
Expand All @@ -65,7 +72,17 @@ function getHttpsAgent(): HttpsAgent {
return _httpsAgent
}
const ca = getExtraCaCerts()
const agent = ca ? new HttpsAgent({ ca }) : new HttpsAgent()
const proxyUrl = getDefaultProxyUrl()
// `ca` covers the destination TLS handshake; `proxyRequestOptions.ca` covers
// the hop to an https:// proxy.
const agent = proxyUrl
? new HttpsProxyAgent({
proxy: proxyUrl,
...(ca ? { ca, proxyRequestOptions: { ca } } : {}),
})
: ca
? new HttpsAgent({ ca })
: new HttpsAgent()
_httpsAgent = agent
return agent
}
Expand Down
103 changes: 101 additions & 2 deletions src/utils/api.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
* - apiFetch always uses node:https.request (no undici body timeout).
* - apiFetch passes a custom HttpsAgent when CA certs are set via SSL_CERT_FILE.
* - apiFetch passes an explicit HttpsAgent (no timeout) when no CA certs are configured.
* - apiFetch routes through an hpagent proxy agent when a proxy is configured.
* - apiFetch trusts extra CA certs on the destination and the proxy hop.
* - Response object construction from https.request output.
* - POST requests with JSON body through https.request path.
* - Error propagation from https.request failures.
Expand All @@ -19,7 +21,7 @@
*
* Related Files:
* - utils/api.mts (implementation)
* - utils/sdk.mts (getExtraCaCerts)
* - utils/sdk.mts (getExtraCaCerts, getDefaultProxyUrl)
*/

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
Expand All @@ -32,11 +34,14 @@ const mockGetDefaultApiToken = vi.hoisted(() => vi.fn(() => 'test-api-token'))
const mockGetCliUserAgent = vi.hoisted(() =>
vi.fn(() => 'socket-cli/1.0.0 node/v20.0.0 test/x64'),
)
const mockGetDefaultProxyUrl = vi.hoisted(() =>
vi.fn((): string | undefined => undefined),
)
vi.mock('./sdk.mts', () => ({
getCliUserAgent: mockGetCliUserAgent,
getDefaultApiToken: mockGetDefaultApiToken,
getDefaultApiBaseUrl: vi.fn(() => undefined),
getDefaultProxyUrl: vi.fn(() => undefined),
getDefaultProxyUrl: mockGetDefaultProxyUrl,
getExtraCaCerts: mockGetExtraCaCerts,
}))

Expand All @@ -57,6 +62,14 @@ vi.mock('node:https', () => ({
request: mockHttpsRequest,
}))

// Mock hpagent proxy agent.
const MockHttpsProxyAgent = vi.hoisted(() =>
vi.fn().mockImplementation(opts => ({ ...opts, _isHttpsProxyAgent: true })),
)
vi.mock('hpagent', () => ({
HttpsProxyAgent: MockHttpsProxyAgent,
}))

// Mock constants.
vi.mock('../constants.mts', () => ({
default: {
Expand Down Expand Up @@ -107,11 +120,41 @@ vi.mock('./telemetry/integration.mts', () => ({
// Store original fetch for restoration.
const originalFetch = globalThis.fetch

function stubHttpsResponse() {
const mockReq = {
end: vi.fn(),
on: vi.fn(),
write: vi.fn(),
}
mockHttpsRequest.mockImplementation(
(_url: string, _opts: unknown, callback: RequestCallback) => {
setTimeout(() => {
const mockRes = {
headers: { 'content-type': 'text/plain' },
on: vi.fn(),
statusCode: 200,
statusMessage: 'OK',
}
const handlers: Record<string, Function> = {}
mockRes.on.mockImplementation((event: string, handler: Function) => {
handlers[event] = handler
return mockRes
})
callback(mockRes)
handlers['data']?.(Buffer.from('response body'))
handlers['end']?.()
}, 0)
return mockReq
},
)
}

describe('apiFetch with extra CA certificates', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
mockGetExtraCaCerts.mockReturnValue(undefined)
mockGetDefaultProxyUrl.mockReturnValue(undefined)
})

afterEach(() => {
Expand Down Expand Up @@ -622,3 +665,59 @@ describe('apiFetch with extra CA certificates', () => {
expect(secondCallHeaders['Authorization']).toBe('Bearer ghp_secret')
})
})

describe('apiFetch proxy support', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
mockGetExtraCaCerts.mockReturnValue(undefined)
mockGetDefaultProxyUrl.mockReturnValue(undefined)
})

afterEach(() => {
globalThis.fetch = originalFetch
})

it('should route direct API calls through the configured proxy', async () => {
mockGetDefaultProxyUrl.mockReturnValue('http://proxy.example.com:8080')
stubHttpsResponse()

const { queryApiSafeText } = await import('./api.mts')
const result = await queryApiSafeText('test/path')

expect(MockHttpsProxyAgent).toHaveBeenCalledTimes(1)
expect(MockHttpsProxyAgent.mock.calls[0]?.[0]).toMatchObject({
proxy: 'http://proxy.example.com:8080',
})
expect(MockHttpsAgent).not.toHaveBeenCalled()
const callArgs = mockHttpsRequest.mock.calls[0]
expect(callArgs[1].agent).toMatchObject({ _isHttpsProxyAgent: true })
expect(result.ok).toBe(true)
})

it('should trust extra CA certs on the destination and the proxy hop', async () => {
const caCerts = ['ROOT_CERT', 'EXTRA_CERT']
mockGetDefaultProxyUrl.mockReturnValue('https://proxy.example.com:8443')
mockGetExtraCaCerts.mockReturnValue(caCerts)
stubHttpsResponse()

const { queryApiSafeText } = await import('./api.mts')
await queryApiSafeText('test/path')

expect(MockHttpsProxyAgent.mock.calls[0]?.[0]).toMatchObject({
ca: caCerts,
proxy: 'https://proxy.example.com:8443',
proxyRequestOptions: { ca: caCerts },
})
})

it('should use a plain agent when no proxy is configured', async () => {
stubHttpsResponse()

const { queryApiSafeText } = await import('./api.mts')
await queryApiSafeText('test/path')

expect(MockHttpsProxyAgent).not.toHaveBeenCalled()
expect(MockHttpsAgent).toHaveBeenCalledTimes(1)
})
})