diff --git a/src/http-request/headers.ts b/src/http-request/headers.ts index 2ebcdda35..b81ca7fcf 100644 --- a/src/http-request/headers.ts +++ b/src/http-request/headers.ts @@ -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 `:`. 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 `. + */ +/*@__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. * @@ -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 @@ -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 = { __proto__: null, } as unknown as Record 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(', ') diff --git a/src/http-request/request-attempt.ts b/src/http-request/request-attempt.ts index 8a234f98c..8a8b665f6 100644 --- a/src/http-request/request-attempt.ts +++ b/src/http-request/request-attempt.ts @@ -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, diff --git a/src/http-request/response-reader.ts b/src/http-request/response-reader.ts index 7bb248f2f..bbc41a877 100644 --- a/src/http-request/response-reader.ts +++ b/src/http-request/response-reader.ts @@ -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 { + 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. * @@ -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 { diff --git a/src/paths/normalize.ts b/src/paths/normalize.ts index 9231fb4f5..3269a4029 100644 --- a/src/paths/normalize.ts +++ b/src/paths/normalize.ts @@ -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. */ @@ -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) } diff --git a/test/isolated/http-request-advanced-1.test.mts b/test/isolated/http-request-advanced-1.test.mts index 4cf1b1398..1754b40b2 100644 --- a/test/isolated/http-request-advanced-1.test.mts +++ b/test/isolated/http-request-advanced-1.test.mts @@ -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 () => { diff --git a/test/unit/external-tools/bazel/resolve.test.mts b/test/unit/external-tools/bazel/resolve.test.mts index 5a2c5655b..6e9b05fd3 100644 --- a/test/unit/external-tools/bazel/resolve.test.mts +++ b/test/unit/external-tools/bazel/resolve.test.mts @@ -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() @@ -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() diff --git a/test/unit/external-tools/sbt/resolve.test.mts b/test/unit/external-tools/sbt/resolve.test.mts index 15f669f9d..d1d6b7dfa 100644 --- a/test/unit/external-tools/sbt/resolve.test.mts +++ b/test/unit/external-tools/sbt/resolve.test.mts @@ -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() @@ -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() diff --git a/test/unit/http-request/headers.test.mts b/test/unit/http-request/headers.test.mts index 6fe9068f7..39a97b84e 100644 --- a/test/unit/http-request/headers.test.mts +++ b/test/unit/http-request/headers.test.mts @@ -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() @@ -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({ diff --git a/test/unit/http-request/response-reader.test.mts b/test/unit/http-request/response-reader.test.mts index 0881ff60b..66a5f1ac5 100644 --- a/test/unit/http-request/response-reader.test.mts +++ b/test/unit/http-request/response-reader.test.mts @@ -1,5 +1,7 @@ import { describe, expect, test } from 'vitest' +import { compressBrotli } from '../../../src/compression/brotli' +import { compressGzip } from '../../../src/compression/gzip' import { readIncomingResponse } from '../../../src/http-request/response-reader' import type { IncomingResponse } from '../../../src/http-request/request-types' @@ -114,4 +116,61 @@ describe.sequential('http-request/response-reader — readIncomingResponse', () expect(response.text()).toBe('') expect(response.body.byteLength).toBe(0) }) + + test('decompresses a gzip Content-Encoding body', async () => { + const payload = JSON.stringify({ hello: 'gzip' }) + const compressed = await compressGzip(payload) + const msg = makeMsg({ + chunks: [compressed], + statusCode: 200, + headers: { 'content-encoding': 'gzip' }, + }) + const response = await readIncomingResponse(msg) + expect(response.text()).toBe(payload) + expect(response.json<{ hello: string }>().hello).toBe('gzip') + }) + + test('decompresses a br Content-Encoding body', async () => { + const payload = JSON.stringify({ hello: 'brotli' }) + const compressed = await compressBrotli(payload) + const msg = makeMsg({ + chunks: [compressed], + statusCode: 200, + headers: { 'content-encoding': 'br' }, + }) + const response = await readIncomingResponse(msg) + expect(response.text()).toBe(payload) + }) + + test('is case-insensitive on the encoding token', async () => { + const payload = 'plain text body' + const compressed = await compressGzip(payload) + const msg = makeMsg({ + chunks: [compressed], + statusCode: 200, + headers: { 'content-encoding': 'GZIP' }, + }) + const response = await readIncomingResponse(msg) + expect(response.text()).toBe(payload) + }) + + test('leaves the body untouched for identity / absent encoding', async () => { + const msg = makeMsg({ + chunks: [Buffer.from('raw')], + statusCode: 200, + headers: { 'content-encoding': 'identity' }, + }) + const response = await readIncomingResponse(msg) + expect(response.text()).toBe('raw') + }) + + test('does not attempt to decompress an empty body even when encoded', async () => { + const msg = makeMsg({ + chunks: [], + statusCode: 204, + headers: { 'content-encoding': 'gzip' }, + }) + const response = await readIncomingResponse(msg) + expect(response.body.byteLength).toBe(0) + }) }) diff --git a/test/unit/paths/normalize.test.mts b/test/unit/paths/normalize.test.mts index 902535b1f..1f5041ea5 100644 --- a/test/unit/paths/normalize.test.mts +++ b/test/unit/paths/normalize.test.mts @@ -50,6 +50,18 @@ describe('paths/normalize', () => { expect(normalizePath('D:\\projects\\app')).toBe('D:/projects/app') }) + it('should keep the root slash on a bare drive root', () => { + // A drive ROOT keeps its trailing slash — `D:` alone means "current + // directory on D:", a different location from the root `D:/`. + expect(normalizePath('D:\\')).toBe('D:/') + expect(normalizePath('C:/')).toBe('C:/') + }) + + it('should not add a slash to a drive-relative path', () => { + // `D:foo` (no separator after the colon) is drive-relative, not a root. + expect(normalizePath('D:foo')).toBe('D:foo') + }) + it('should normalize mixed slashes', () => { expect(normalizePath('C:\\Users/user\\file.txt')).toBe( 'C:/Users/user/file.txt', diff --git a/test/unit/paths/walk.test.mts b/test/unit/paths/walk.test.mts index 457d994a2..effc5c843 100644 --- a/test/unit/paths/walk.test.mts +++ b/test/unit/paths/walk.test.mts @@ -54,7 +54,7 @@ describe('walkUp', () => { }) it('a start AT the stopAt yields just that one dir', () => { - expect([...walkUp('/a', { stopAt: '/a' })]).toStrictEqual(['/a']) + expect([...walkUp('/a', { stopAt: '/a' })]).toStrictEqual([withDrive('/a')]) }) it('is lazy — can break early without computing the whole chain', () => {