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
63 changes: 50 additions & 13 deletions src/http-request/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,57 @@ import { ArrayIsArray } from '../primordials/array'

import { DateCtor, DateNow } from '../primordials/date'

import { SetCtor } from '../primordials/map-set'
import { btoa } from '../primordials/globals'

import { NumberIsNaN } from '../primordials/number'

import { ObjectKeys } from '../primordials/object'
const RETRY_AFTER_INT_RE = /^\d+$/

/**
* Build an HTTP Basic `Authorization` header value from a Socket API token.
*
* The Socket API uses the token as the username with an empty password, so the
* credential pair is `<token>:`. Centralized here so every fleet caller emits
* the identical shape instead of hand-rolling `btoa(\`${token}:`)`.
*
* @example
* ;```ts
* const headers = { Authorization: basicAuthHeader(apiToken) }
* // { Authorization: 'Basic c2t0X3h4eHg6' }
* ```
*
* @param token - The Socket API token (used as the Basic-auth username).
*
* @returns The `Authorization` header value, e.g. `Basic <base64>`.
*/
/*@__NO_SIDE_EFFECTS__*/
export function basicAuthHeader(token: string): string {
return `Basic ${btoa(`${token}:`)}`
}

// Match credential-bearing header names by shape rather than an enumerated
// list. A fixed list reads as complete while silently missing real headers
// (x-amz-security-token, api-key, x-functions-key, …); a name pattern catches
// the family. Same reasoning as the fleet's "a denylist is itself a leak" —
// don't try to name every secret, recognize the shape. The standard auth /
// cookie / proxy headers all contain one of these tokens, so they stay covered.
const SENSITIVE_HEADER_NAME_RE =
/auth|cookie|credential|key|password|secret|token/i

/**
* Whether a header name looks credential-bearing and should be redacted from
* logs and telemetry. Case-insensitive substring match on the name only — the
* value is never inspected.
*
* @param name - The header name (e.g. `Authorization`, `x-api-key`).
*
* @returns `true` when the value should be replaced with `[REDACTED]`.
*/
export function isSensitiveHeaderName(name: string): boolean {
return SENSITIVE_HEADER_NAME_RE.test(name)
}

/**
* Parse a `Retry-After` HTTP header value into milliseconds.
*
Expand Down Expand Up @@ -76,9 +120,10 @@ export function parseRetryAfterHeader(
/**
* Redact sensitive HTTP headers for safe logging and telemetry.
*
* Replaces values of sensitive headers (Authorization, Cookie, etc.) with
* `[REDACTED]`. Non-sensitive headers are passed through unchanged. Array
* values are joined with `', '`.
* Replaces values of credential-bearing headers with `[REDACTED]`, matching the
* header name by shape (see `isSensitiveHeaderName`) so custom token headers
* are covered without an enumerated list. Non-sensitive headers pass through
* unchanged. Array values are joined with `', '`.
*
* @example
* ;```ts
Expand All @@ -99,20 +144,12 @@ export function sanitizeHeaders(
if (!headers) {
return {}
}
const sensitiveHeaders = new SetCtor([
'authorization',
'cookie',
'proxy-authorization',
'proxy-authenticate',
'set-cookie',
'www-authenticate',
])
const result: Record<string, string> = {
__proto__: null,
} as unknown as Record<string, string>
for (const key of ObjectKeys(headers)) {
const value = headers[key]
if (sensitiveHeaders.has(key.toLowerCase())) {
if (isSensitiveHeaderName(key)) {
result[key] = '[REDACTED]'
} else if (ArrayIsArray(value)) {
result[key] = value.join(', ')
Expand Down
7 changes: 7 additions & 0 deletions src/http-request/request-attempt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ export async function httpRequestAttempt(
: undefined

const mergedHeaders = {
// Advertise only the encodings response-reader can decompress, and ONLY for
// buffered responses. Streamed responses (stream: true, e.g. httpDownload)
// are piped to disk raw — response-reader never runs — so a compressed body
// would land on disk still deflated and fail checksum. Node's http client
// does not negotiate or decompress on its own. Caller headers win, so a
// caller can override (e.g. 'identity') if it wants raw bytes.
...(stream ? undefined : { 'Accept-Encoding': 'gzip, br' }),
'User-Agent': getSocketCallerUserAgent(),
...streamHeaders,
...headers,
Expand Down
43 changes: 41 additions & 2 deletions src/http-request/response-reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,51 @@
* out of `http-request/request.ts` for size hygiene. Useful when a caller
* already has an `IncomingMessage` from code that bypasses `httpRequest()`
* (e.g., multipart uploads via `http.request()` directly, or third-party HTTP
* libraries) and wants the same fetch-like body accessors.
* libraries) and wants the same fetch-like body accessors. The body is
* transparently decompressed when the response carries a `Content-Encoding`
* of `gzip` or `br` — the two encodings `httpRequest` advertises via
* `Accept-Encoding`. Node's http client does not decompress on its own, so
* without this step a compressed Socket API response would reach callers as
* raw deflated bytes and fail JSON parsing.
*/

import { decompressBrotli } from '../compression/brotli'
import { decompressGzip } from '../compression/gzip'
import { BufferConcat } from '../primordials/buffer'
import { JSONParse } from '../primordials/json'

import type { IncomingResponse } from './request-types'
import type { HttpResponse } from './response-types'

/**
* Decompress a response body per its `Content-Encoding`. Returns the input
* unchanged for `identity` or any unrecognized/absent encoding — we only
* decompress what `httpRequest` advertised support for (`gzip`, `br`).
*/
export async function decodeBody(
body: Buffer,
contentEncoding: string | string[] | undefined,
): Promise<Buffer> {
if (!contentEncoding || body.length === 0) {
return body
}
// A comma-separated list applies encodings in order; the last applied is the
// first to undo. In practice servers send a single token — handle the common
// case and bail (return as-is) on anything layered or unrecognized.
const encoding = (
Array.isArray(contentEncoding) ? contentEncoding[0]! : contentEncoding
)
.trim()
.toLowerCase()
if (encoding === 'gzip') {
return await decompressGzip(body)
}
if (encoding === 'br') {
return await decompressBrotli(body)
}
return body
}

/**
* Read and buffer a client-side IncomingResponse into an HttpResponse.
*
Expand All @@ -34,7 +70,10 @@ export async function readIncomingResponse(
for await (const chunk of msg) {
chunks.push(chunk as Buffer)
}
const body = BufferConcat!(chunks)
const body = await decodeBody(
BufferConcat!(chunks),
msg.headers['content-encoding'],
)
const status = msg.statusCode ?? 0
const statusText = msg.statusMessage ?? ''
return {
Expand Down
15 changes: 15 additions & 0 deletions src/paths/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import {

import { msysDriveRegExp, pathLikeToString, slashRegExp } from './_internal'

// A normalized path that is exactly a bare Windows drive letter (`C:`).
const DRIVE_LETTER_REGEXP = /^[A-Za-z]:$/

// On Windows, convert MSYS drive notation to native: /c/path → C:/path
export function msysDriveToNative(normalized: string): string {
/* c8 ignore start - Windows-only branch. */
Expand Down Expand Up @@ -276,6 +279,18 @@ export function normalizePath(pathLike: string | Buffer | URL): string {
if (collapsed.length === 0) {
return prefix || '.'
}
// A bare drive letter that came from a drive ROOT keeps its slash: `D:\` and
// `D:/` normalize to `D:/`, not `D:`. The trailing separator is significant
// on a drive root — `D:` alone means "current directory on D:", a different
// location. Detected by a separator immediately after the colon in the
// original input (index 2), so drive-relative `D:foo` is unaffected.
if (
DRIVE_LETTER_REGEXP.test(collapsed) &&
(StringPrototypeCharCodeAt(filepath, 2) === 47 /*'/'*/ ||
StringPrototypeCharCodeAt(filepath, 2) === 92) /*'\\'*/
) {
return msysDriveToNative(`${prefix}${collapsed}/`)
}
return msysDriveToNative(prefix + collapsed)
}

Expand Down
27 changes: 27 additions & 0 deletions test/isolated/http-request-advanced-1.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,33 @@ describe('http-request', () => {
getSocketCallerUserAgent(),
)
expect(requestInfos[0]!.headers['X-Custom']).toBe('test-value')
// Buffered requests advertise the encodings response-reader can decode.
expect(requestInfos[0]!.headers['Accept-Encoding']).toBe('gzip, br')
})

it('omits Accept-Encoding on streamed requests (piped raw to disk)', async () => {
const requestInfos: HttpHookRequestInfo[] = []
const response = await httpRequest(`${fixture.baseUrl}/json`, {
stream: true,
hooks: {
onRequest: info => requestInfos.push(info),
},
})
// Drain the stream so the socket closes cleanly.
response.rawResponse?.resume()
expect(requestInfos).toHaveLength(1)
expect(requestInfos[0]!.headers['Accept-Encoding']).toBeUndefined()
})

it('lets a caller override Accept-Encoding', async () => {
const requestInfos: HttpHookRequestInfo[] = []
await httpRequest(`${fixture.baseUrl}/json`, {
headers: { 'Accept-Encoding': 'identity' },
hooks: {
onRequest: info => requestInfos.push(info),
},
})
expect(requestInfos[0]!.headers['Accept-Encoding']).toBe('identity')
})

it('should call onResponse with status, headers, and duration', async () => {
Expand Down
23 changes: 16 additions & 7 deletions test/unit/external-tools/bazel/resolve.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import {
resolveBazel,
} from '../../../../src/external-tools/bazel/resolve'

// resolveBazel() does real PATH/binary resolution; Windows CI agents can take
// >10s to return on a cold cache, blowing vitest's 10s default. Bump the
// per-test timeout, matching the jre/which win32-timeout fixes.
const RESOLVE_TIMEOUT = { timeout: 30_000 }

describe('external-tools/bazel/resolve', () => {
beforeEach(() => {
resetBazelResolution()
Expand All @@ -22,14 +27,18 @@ describe('external-tools/bazel/resolve', () => {
expect(resolveBazel()).toBe(resolveBazel())
})

it('returns either a resolved shape or undefined on stock Node', async () => {
const result = await resolveBazel()
if (result !== undefined) {
expect(['vfs', 'path']).toContain(result.source)
}
})
it(
'returns either a resolved shape or undefined on stock Node',
RESOLVE_TIMEOUT,
async () => {
const result = await resolveBazel()
if (result !== undefined) {
expect(['vfs', 'path']).toContain(result.source)
}
},
)

it('resetBazelResolution clears the memo slot', async () => {
it('resetBazelResolution clears the memo slot', RESOLVE_TIMEOUT, async () => {
const first = await resolveBazel()
resetBazelResolution()
const second = await resolveBazel()
Expand Down
23 changes: 16 additions & 7 deletions test/unit/external-tools/sbt/resolve.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import {
resolveSbt,
} from '../../../../src/external-tools/sbt/resolve'

// resolveSbt() does real PATH/binary resolution; Windows CI agents can take
// >10s to return on a cold cache, blowing vitest's 10s default. Bump the
// per-test timeout, matching the jre/which win32-timeout fixes.
const RESOLVE_TIMEOUT = { timeout: 30_000 }

describe('external-tools/sbt/resolve', () => {
beforeEach(() => {
resetSbtResolution()
Expand All @@ -21,14 +26,18 @@ describe('external-tools/sbt/resolve', () => {
expect(resolveSbt()).toBe(resolveSbt())
})

it('returns either a resolved shape or undefined on stock Node', async () => {
const result = await resolveSbt()
if (result !== undefined) {
expect(['vfs', 'path']).toContain(result.source)
}
})
it(
'returns either a resolved shape or undefined on stock Node',
RESOLVE_TIMEOUT,
async () => {
const result = await resolveSbt()
if (result !== undefined) {
expect(['vfs', 'path']).toContain(result.source)
}
},
)

it('resetSbtResolution clears the memo slot', async () => {
it('resetSbtResolution clears the memo slot', RESOLVE_TIMEOUT, async () => {
const first = await resolveSbt()
resetSbtResolution()
const second = await resolveSbt()
Expand Down
66 changes: 66 additions & 0 deletions test/unit/http-request/headers.test.mts
Original file line number Diff line number Diff line change
@@ -1,10 +1,56 @@
import { describe, expect, test } from 'vitest'

import {
basicAuthHeader,
isSensitiveHeaderName,
parseRetryAfterHeader,
sanitizeHeaders,
} from '../../../src/http-request/headers'

describe.sequential('http-request/headers — isSensitiveHeaderName', () => {
test('matches credential header families (case-insensitive)', () => {
for (const name of [
'Authorization',
'cookie',
'Set-Cookie',
'proxy-authorization',
'www-authenticate',
'x-api-key',
'API-KEY',
'x-auth-token',
'x-amz-security-token',
'x-secret',
'db-password',
]) {
expect(isSensitiveHeaderName(name)).toBe(true)
}
})

test('does not match benign headers', () => {
for (const name of [
'content-type',
'accept',
'user-agent',
'x-request-id',
'retry-after',
]) {
expect(isSensitiveHeaderName(name)).toBe(false)
}
})
})

describe.sequential('http-request/headers — basicAuthHeader', () => {
test('builds Basic header with token as username and empty password', () => {
// 'tok:' base64 is 'dG9rOg=='
expect(basicAuthHeader('tok')).toBe('Basic dG9rOg==')
})

test('handles an empty token', () => {
// ':' base64 is 'Og=='
expect(basicAuthHeader('')).toBe('Basic Og==')
})
})

describe.sequential('http-request/headers — parseRetryAfterHeader', () => {
test('returns undefined for undefined input', () => {
expect(parseRetryAfterHeader(undefined)).toBeUndefined()
Expand Down Expand Up @@ -78,6 +124,26 @@ describe.sequential('http-request/headers — sanitizeHeaders', () => {
})
})

test('redacts custom credential headers by name shape, not a fixed list', () => {
expect(
sanitizeHeaders({
'x-api-key': 'sk_live_xxx',
'x-auth-token': 'tok_yyy',
'x-amz-security-token': 'amz_zzz',
'api-key': 'plain_key',
'content-type': 'application/json',
'x-request-id': 'req-123',
}),
).toEqual({
'x-api-key': '[REDACTED]',
'x-auth-token': '[REDACTED]',
'x-amz-security-token': '[REDACTED]',
'api-key': '[REDACTED]',
'content-type': 'application/json',
'x-request-id': 'req-123',
})
})

test('redacts cookie / set-cookie / proxy variants', () => {
expect(
sanitizeHeaders({
Expand Down
Loading
Loading