diff --git a/CHANGELOG.md b/CHANGELOG.md index ab86f61..4719696 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 3.0.2 + +- Hide API keys when printing client settings and request errors, including errors in typed bulk results. +- Stop redirects from sending lookup data or custom headers to another server. +- Keep the HTTP status when an error response is too large or interrupted. Check that typed bulk results match the number of IPs requested. +- Add Arabic (`ar`) for single and bulk lookups. +- Include CDN, proxy, bot and corporate gateway details in typed results. +- Report the installed package version and preserve special property names in headers and JSON output. + ## 3.0.0 - Initial JavaScript SDK release for `/v3/ipgeo` and `/v3/ipgeo-bulk` diff --git a/README.md b/README.md index a0afe48..33267f4 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,8 @@ Paid plans still need `include` for optional modules. `fields` and `excludes` on You can also pass a custom `HttpTransport` as the second constructor argument. +If you set `baseUrl`, use the final API address. In Node.js, the built-in client follows redirects only within the same protocol, host and port. Browsers hide the redirect address, so the client stops at a redirect. + ## Available Methods | Method | Returns | Notes | @@ -210,7 +212,7 @@ You can also pass a custom `HttpTransport` as the second constructor argument. |--------|------------|-------| | `ip` | Single lookup | IPv4, IPv6, or domain. Omit it for caller IP lookup. Domain lookup requires a paid plan. | | `ips` | Bulk lookup | Iterable of 1 to 50,000 IPs or domains. | -| `lang` | Single and bulk | One of `en`, `de`, `ru`, `ja`, `fr`, `cn`, `es`, `cs`, `it`, `ko`, `fa`, `pt`. | +| `lang` | Single and bulk | One of `en`, `de`, `ru`, `ja`, `fr`, `cn`, `es`, `cs`, `it`, `ko`, `fa`, `pt`, `ar` (Arabic). | | `include` | Single and bulk | Iterable of module names such as `security`, `abuse`, `user_agent`, `hostname`, `liveHostname`, `hostnameFallbackLive`, `geo_accuracy`, `dma_code`, or `*`. | | `fields` | Single and bulk | Iterable of field paths to keep, for example `["location.country_name", "security.threat_score"]`. | | `excludes` | Single and bulk | Iterable of field paths to remove from the response. | @@ -401,7 +403,7 @@ for (const result of response.data) { ### Mixed Success and Error Results -Each bulk result is either a success with `data` or an error with `error.message`. +Each bulk result is either a success with `data` or an error with `error.message`. If the API sends a different number of results than you requested, the typed lookup throws `SerializationError`. The raw lookup returns the response as received. ```js const response = await client.bulkLookupIpGeolocation({ @@ -453,7 +455,7 @@ console.log(response.metadata.firstHeaderValue("x-credits-charged")); // the sam ## JSON Helpers -Use `toJson()` or `toPrettyJson()` when you want a stable JSON view of SDK objects. +Use `toJson()` or `toPrettyJson()` to print SDK objects as JSON. When you print a client or its settings as JSON, the SDK shows `[REDACTED]` in place of your API key. `config.apiKey` still gives you the key. ```js import { toPrettyJson } from "ip-geolocation-api-javascript-sdk"; @@ -467,7 +469,7 @@ console.log(toPrettyJson(response.data)); ## Error Handling -All SDK errors extend `IpGeolocationError`. +All SDK errors extend `IpGeolocationError`. If an API error includes your key, the SDK hides it. This also applies to error messages in typed bulk results. If the SDK receives an HTTP status but can’t read the error body, you still get the status code. Runtime and validation errors: diff --git a/package-lock.json b/package-lock.json index e275959..980a2c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ip-geolocation-api-javascript-sdk", - "version": "3.0.1", + "version": "3.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ip-geolocation-api-javascript-sdk", - "version": "3.0.1", + "version": "3.0.2", "license": "MIT", "devDependencies": { "@types/node": "^24.5.2", diff --git a/package.json b/package.json index bbab827..8efb7d2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "author": "IPGeolocation.io", "name": "ip-geolocation-api-javascript-sdk", - "version": "3.0.1", + "version": "3.0.2", "description": "Official JavaScript SDK for the IPGeolocation IP Location API with single and bulk lookup, typed JSON, and raw JSON/XML support.", "license": "MIT", "type": "module", diff --git a/scripts/check-package.cjs b/scripts/check-package.cjs index e53e44c..41851e1 100644 --- a/scripts/check-package.cjs +++ b/scripts/check-package.cjs @@ -7,14 +7,19 @@ async function main() { const esm = await import(pkg.name); const cjs = require(pkg.name); assert.deepEqual(Object.keys(esm).sort(), Object.keys(cjs).sort()); - assert.equal(esm.getVersion(), cjs.getVersion()); await access(path.resolve(__dirname, '..', pkg.types)); await access(path.resolve(__dirname, '..', 'dist/index.d.cts')); for (const sdk of [esm, cjs]) { + assert.equal(sdk.getVersion(), pkg.version); + assert.equal(sdk.SDK_VERSION, pkg.version); + assert.equal(sdk.IpGeolocationClient.defaultUserAgent(), `ipgeolocation-javascript-sdk/${pkg.version}`); const config = new sdk.IpGeolocationClientConfig({ apiKey: 'package-test' }); assert.equal(config.baseUrl, 'https://api.ipgeolocation.io'); assert.equal(Object.isFrozen(config), true); + for (const formatter of [esm, cjs]) { + assert.equal(JSON.parse(formatter.toJson(config)).apiKey, '[REDACTED]'); + } const request = new sdk.LookupIpGeolocationRequest({ ip: '2001:db8::1' }); assert.equal(request.ip, '2001:db8::1'); assert.equal(request.output, sdk.ResponseFormat.JSON); diff --git a/src/client.ts b/src/client.ts index a1f540b..bf5494d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -15,6 +15,7 @@ import { buildQuery, mergeHeaders, resolveUserAgentHeader, + safeTransportError, toApiError, toMetadata, validateJsonOutput, @@ -31,7 +32,7 @@ import { getVersion } from "./version"; export class IpGeolocationClient { private readonly config: IpGeolocationClientConfig; - private readonly transport: HttpTransport; + readonly #transport: HttpTransport; private readonly ownsTransport: boolean; private closed = false; @@ -47,7 +48,7 @@ export class IpGeolocationClient { config instanceof IpGeolocationClientConfig ? config : new IpGeolocationClientConfig(config); - this.transport = transport ?? new FetchHttpTransport(); + this.#transport = transport ?? new FetchHttpTransport(); this.ownsTransport = transport == null; } @@ -66,12 +67,8 @@ export class IpGeolocationClient { const httpRequest = this.buildLookupHttpRequest(normalizedRequest); const { response, durationMs } = await this.executeWithMetrics(httpRequest); - if (Math.floor(response.statusCode / 100) !== 2) { - throw toApiError(response.statusCode, response.body); - } - return { - data: parseSingleLookup(response.body), + data: parseSingleLookup(response.body, this.config.apiKey), metadata: toMetadata(response.statusCode, durationMs, response.headers), }; } @@ -86,10 +83,6 @@ export class IpGeolocationClient { const httpRequest = this.buildLookupHttpRequest(normalizedRequest); const { response, durationMs } = await this.executeWithMetrics(httpRequest); - if (Math.floor(response.statusCode / 100) !== 2) { - throw toApiError(response.statusCode, response.body); - } - return { data: response.body, metadata: toMetadata(response.statusCode, durationMs, response.headers), @@ -109,12 +102,8 @@ export class IpGeolocationClient { const httpRequest = this.buildBulkHttpRequest(normalizedRequest); const { response, durationMs } = await this.executeWithMetrics(httpRequest); - if (Math.floor(response.statusCode / 100) !== 2) { - throw toApiError(response.statusCode, response.body); - } - return { - data: parseBulkLookup(response.body), + data: parseBulkLookup(response.body, normalizedRequest.ips.length, this.config.apiKey), metadata: toMetadata(response.statusCode, durationMs, response.headers), }; } @@ -131,10 +120,6 @@ export class IpGeolocationClient { const httpRequest = this.buildBulkHttpRequest(normalizedRequest); const { response, durationMs } = await this.executeWithMetrics(httpRequest); - if (Math.floor(response.statusCode / 100) !== 2) { - throw toApiError(response.statusCode, response.body); - } - return { data: response.body, metadata: toMetadata(response.statusCode, durationMs, response.headers), @@ -148,7 +133,7 @@ export class IpGeolocationClient { this.closed = true; if (this.ownsTransport) { - await this.transport.close(); + await this.#transport.close(); } } @@ -269,7 +254,15 @@ export class IpGeolocationClient { request: HttpRequestData, ): Promise<{ response: HttpResponseData; durationMs: number }> { const started = Date.now(); - const response = await this.transport.send(request); + let response: HttpResponseData; + try { + response = await this.#transport.send(request); + } catch (error) { + throw safeTransportError(error, this.config.apiKey); + } + if (Math.floor(response.statusCode / 100) !== 2) { + throw toApiError(response.statusCode, response.body, this.config.apiKey); + } return { response, durationMs: Math.max(0, Date.now() - started), diff --git a/src/config.ts b/src/config.ts index 40a18dc..dcfb7cf 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,5 @@ import { ValidationError } from "./errors"; +import { configForOutput, configJson } from "./internal/redaction"; import { containsCrOrLf, isPlainObjectRecord } from "./internal/shared"; const DEFAULT_BASE_URL = "https://api.ipgeolocation.io"; @@ -15,7 +16,7 @@ export interface IpGeolocationClientConfigInit { } export class IpGeolocationClientConfig { - public readonly apiKey: string | undefined; + declare public readonly apiKey: string | undefined; public readonly requestOrigin: string | undefined; public readonly baseUrl: string; public readonly connectTimeoutMs: number; @@ -41,7 +42,13 @@ export class IpGeolocationClientConfig { DEFAULT_READ_TIMEOUT_MS, ); - this.apiKey = apiKey; + const forOutput = () => configForOutput(this); + Object.defineProperties(this, { + apiKey: { enumerable: true, get: () => apiKey }, + toJSON: { value: forOutput }, + [configJson]: { value: forOutput }, + [Symbol.for("nodejs.util.inspect.custom")]: { value: forOutput }, + }); this.requestOrigin = requestOrigin; this.baseUrl = baseUrl; this.connectTimeoutMs = connectTimeoutMs; diff --git a/src/enums.ts b/src/enums.ts index 7161e00..f571728 100644 --- a/src/enums.ts +++ b/src/enums.ts @@ -11,6 +11,7 @@ export const Language = Object.freeze({ KO: "ko", FA: "fa", PT: "pt", + AR: "ar", } as const); export type Language = (typeof Language)[keyof typeof Language]; diff --git a/src/internal/redaction.ts b/src/internal/redaction.ts new file mode 100644 index 0000000..fe66988 --- /dev/null +++ b/src/internal/redaction.ts @@ -0,0 +1,125 @@ +import type { IpGeolocationClientConfig } from "../config"; + +const MASK = "[REDACTED]"; +export const configJson = Symbol.for("ipgeolocation.clientConfig.toJSON"); + +export function redactApiKey(text: string, key?: string): string { + if (!key) return text; + if (/^[A-Za-z0-9._-]+$/.test(key)) return text.split(key).join(MASK); + + const form = new URLSearchParams({ k: key }); + const encoded = encodeURIComponent(form.get("k")!); + let result = text; + for (const token of new Set([ + encoded.replace(/'/g, "%27"), + encoded, + form.toString().slice(2), + ])) { + if (token === key || token.length > result.length) continue; + const upperEscapes = (s: string) => + s.replace(/%[a-f\d]{2}/gi, (escape) => escape.toUpperCase()); + const haystack = upperEscapes(result); + const needle = upperEscapes(token); + let start = 0; + const parts: string[] = []; + for ( + let at = haystack.indexOf(needle); + at >= 0; + at = haystack.indexOf(needle, start) + ) { + parts.push(result.slice(start, at), MASK); + start = at + token.length; + } + if (parts.length) result = parts.join("") + result.slice(start); + } + const escaped = JSON.stringify(key).slice(1, -1); + if (escaped !== key) result = result.split(escaped).join(MASK); + return result.split(key).join(MASK); +} + +const nativeStackGetter = Object.getOwnPropertyDescriptor( + new Error(), + "stack", +)?.get; +const inspectSymbol = Symbol.for("nodejs.util.inspect.custom"); + +export function hasSafeErrorDetails( + value: unknown, + key: string, + seen = new Set(), +): boolean { + if (typeof value === "string") return redactApiKey(value, key) === value; + if (value === null || typeof value !== "object") { + return typeof value !== "function" && typeof value !== "symbol"; + } + if (seen.has(value)) return true; + const proto = Object.getPrototypeOf(value); + if ( + seen.size >= 32 || + "toJSON" in value || + Symbol.toPrimitive in value || + inspectSymbol in value || + (!(value instanceof Error) && + !Array.isArray(value) && + proto !== null && + proto !== Object.prototype) + ) { + return false; + } + seen.add(value); + if (value instanceof Error) { + for ( + let p = proto; + p && p !== Error.prototype; + p = Object.getPrototypeOf(p) + ) { + for (const name of Reflect.ownKeys(p)) { + if (name === "constructor") continue; + const field = Object.getOwnPropertyDescriptor(p, name)!; + if ( + typeof name !== "string" || + name === "toString" || + name === "valueOf" || + !("value" in field) || + redactApiKey(name, key) !== name + ) + return false; + if ( + typeof field.value !== "function" && + !hasSafeErrorDetails(field.value, key, seen) + ) + return false; + } + } + } + for (const name of Reflect.ownKeys(value)) { + if (typeof name !== "string" || redactApiKey(name, key) !== name) + return false; + const property = Object.getOwnPropertyDescriptor(value, name)!; + let detail = property.value; + if (!("value" in property)) { + if ( + name !== "stack" || + !(value instanceof Error) || + !nativeStackGetter || + property.get !== nativeStackGetter + ) + return false; + detail = nativeStackGetter.call(value); + } + if (!hasSafeErrorDetails(detail, key, seen)) return false; + } + return true; +} + +export function configForOutput(config: IpGeolocationClientConfig) { + const key = config.apiKey; + return { + apiKey: key === undefined ? undefined : MASK, + requestOrigin: + config.requestOrigin && redactApiKey(config.requestOrigin, key), + baseUrl: redactApiKey(config.baseUrl, key), + connectTimeoutMs: config.connectTimeoutMs, + readTimeoutMs: config.readTimeoutMs, + }; +} diff --git a/src/internal/serde.ts b/src/internal/serde.ts index d21e216..70c4e91 100644 --- a/src/internal/serde.ts +++ b/src/internal/serde.ts @@ -1,5 +1,6 @@ import { LocationConfidence } from "../enums"; import { SerializationError } from "../errors"; +import { redactApiKey } from "./redaction"; import { isPlainObjectRecord } from "./shared"; import type { Abuse, @@ -22,29 +23,33 @@ import type { UserAgentOperatingSystem, } from "../models"; -export function parseSingleLookup(body: string): IpGeolocationResponse { - const payload = parseJson(body, "Failed to deserialize API response"); +export function parseSingleLookup(body: string, apiKey?: string): IpGeolocationResponse { + const payload = parseJson(body, "Failed to deserialize API response", apiKey); if (!isPlainObjectRecord(payload)) { throw new SerializationError("Failed to deserialize API response"); } return parseIpGeolocationResponse(payload); } -export function parseBulkLookup(body: string): readonly BulkLookupResult[] { - const payload = parseJson(body, "Failed to deserialize bulk lookup response"); +export function parseBulkLookup(body: string, expectedCount?: number, apiKey?: string): readonly BulkLookupResult[] { + const payload = parseJson(body, "Failed to deserialize bulk lookup response", apiKey); if (!Array.isArray(payload)) { throw new SerializationError( "Failed to deserialize bulk response: expected an array payload", ); } + if (expectedCount !== undefined && payload.length !== expectedCount) { + throw new SerializationError(`Expected ${expectedCount} bulk results but received ${payload.length}`); + } return Object.freeze( payload.map((item) => { if (isBulkErrorItem(item)) { + const message = coerceOptionalString(item.message, "message"); return freezeDefined({ success: false, error: freezeDefined({ - message: coerceOptionalString(item.message, "message"), + message: message === undefined ? undefined : redactApiKey(message, apiKey), }), }); } @@ -184,6 +189,8 @@ function parseNetwork(node: Record): Network { connectionType: coerceOptionalString(node.connection_type, "network.connection_type"), route: coerceOptionalString(node.route, "network.route"), isAnycast: coerceOptionalBoolean(node.is_anycast, "network.is_anycast"), + isCdn: coerceOptionalBoolean(node.is_cdn, "network.is_cdn"), + cdnProviderName: coerceOptionalString(node.cdn_provider_name, "network.cdn_provider_name"), }); } @@ -201,6 +208,9 @@ function parseSecurity(node: Record): Security { "security.proxy_confidence_score", ), proxyLastSeen: coerceOptionalString(node.proxy_last_seen, "security.proxy_last_seen"), + proxyDaysSeen7d: coerceOptionalNumber(node.proxy_days_seen_7d, "security.proxy_days_seen_7d"), + proxyDaysSeen30d: coerceOptionalNumber(node.proxy_days_seen_30d, "security.proxy_days_seen_30d"), + proxyProviderCount30d: coerceOptionalNumber(node.proxy_provider_count_30d, "security.proxy_provider_count_30d"), isResidentialProxy: coerceOptionalBoolean( node.is_residential_proxy, "security.is_residential_proxy", @@ -223,6 +233,14 @@ function parseSecurity(node: Record): Security { "security.is_known_attacker", ), isBot: coerceOptionalBoolean(node.is_bot, "security.is_bot"), + botConfidenceScore: coerceOptionalNumber(node.bot_confidence_score, "security.bot_confidence_score"), + botOperatorName: coerceOptionalString(node.bot_operator_name, "security.bot_operator_name"), + botType: coerceOptionalString(node.bot_type, "security.bot_type"), + botLastSeen: coerceOptionalString(node.bot_last_seen, "security.bot_last_seen"), + isKnownGoodBot: coerceOptionalBoolean(node.is_known_good_bot, "security.is_known_good_bot"), + isCorporateGateway: coerceOptionalBoolean(node.is_corporate_gateway, "security.is_corporate_gateway"), + corporateGatewayType: coerceOptionalString(node.corporate_gateway_type, "security.corporate_gateway_type"), + corporateGatewayProviderName: coerceOptionalString(node.corporate_gateway_provider_name, "security.corporate_gateway_provider_name"), isSpam: coerceOptionalBoolean(node.is_spam, "security.is_spam"), isCloudProvider: coerceOptionalBoolean( node.is_cloud_provider, @@ -339,11 +357,11 @@ function freezeDefined(value: Record): T { return Object.freeze(result) as T; } -function parseJson(body: string, message: string): unknown { +function parseJson(body: string, message: string, apiKey?: string): unknown { try { return JSON.parse(body); } catch (error) { - throw new SerializationError(message, { cause: error }); + throw new SerializationError(message, apiKey ? undefined : { cause: error }); } } diff --git a/src/internal/shared.ts b/src/internal/shared.ts index 8255c9f..9c4a529 100644 --- a/src/internal/shared.ts +++ b/src/internal/shared.ts @@ -8,12 +8,16 @@ import { NotFoundError, PayloadTooLargeError, RateLimitError, + RequestTimeoutError, + SerializationError, ServerError, + TransportError, UnauthorizedError, UnsupportedMediaTypeError, ValidationError, } from "../errors"; import { ApiResponseMetadata, type HeaderMultiMap } from "../models"; +import { hasSafeErrorDetails, redactApiKey } from "./redaction"; export type QueryParamValue = string | null | undefined; export type QueryParams = @@ -48,7 +52,7 @@ export function buildQuery(params: QueryParams): string { export function mergeHeaders( ...headerMaps: ReadonlyArray ): HeaderMultiMap { - const merged: Record = {}; + const merged: Record = Object.create(null); const namesByLower = new Map(); for (const headerMap of headerMaps) { @@ -72,7 +76,7 @@ export function mergeHeaders( } } - return Object.freeze(merged); + return Object.freeze({ ...merged }); } export function resolveUserAgentHeader( @@ -85,13 +89,17 @@ export function resolveUserAgentHeader( return Object.freeze([resolvedUserAgent]); } -export function toApiError(statusCode: number, body: string): ApiError { - const apiMessage = extractApiMessage(body); +export function toApiError(statusCode: number, body: string, apiKey?: string): ApiError { + const apiMessage = extractApiMessage(body, apiKey); const message = apiMessage == null ? `API request failed with HTTP status ${statusCode}` : `API request failed with HTTP status ${statusCode}: ${apiMessage}`; + return createApiError(statusCode, message, apiMessage); +} + +function createApiError(statusCode: number, message: string, apiMessage?: string): ApiError { switch (statusCode) { case 400: return new BadRequestError(message, statusCode, apiMessage); @@ -119,7 +127,7 @@ export function toApiError(statusCode: number, body: string): ApiError { } } -export function extractApiMessage(body: string | null | undefined): string | undefined { +export function extractApiMessage(body: string | null | undefined, apiKey?: string): string | undefined { if (body == null) { return undefined; } @@ -128,11 +136,45 @@ export function extractApiMessage(body: string | null | undefined): string | und return undefined; } + let payload: unknown; + try { + payload = JSON.parse(normalizedBody); + } catch { + return truncateMessageBody(normalizedBody, apiKey); + } + const message = extractJsonMessage(payload); + if (message !== undefined) return redactApiKey(message, apiKey); + if (apiKey && normalizedBody.includes("\\")) { + try { + const decoded = JSON.stringify(payload); + const redacted = redactApiKey(decoded, apiKey); + if (redacted !== decoded) return truncateMessageBody(redacted, apiKey); + } catch { + return undefined; + } + } + return truncateMessageBody(normalizedBody, apiKey); +} + +export function safeTransportError(error: unknown, apiKey?: string): unknown { + if (!apiKey) return error; try { - const payload = JSON.parse(normalizedBody); - return extractJsonMessage(payload) ?? truncateMessageBody(normalizedBody); + if (hasSafeErrorDetails(error, apiKey)) return error; + if (typeof error === "string") return redactApiKey(error, apiKey); + if (!(error instanceof Error)) return new TransportError("HTTP transport error"); + const text = Object.getOwnPropertyDescriptor(error, "message")?.value; + const message = typeof text === "string" ? redactApiKey(text, apiKey) : "HTTP transport error"; + if (error instanceof ApiError) { + const status = Object.getOwnPropertyDescriptor(error, "statusCode")?.value; + const detail = Object.getOwnPropertyDescriptor(error, "apiMessage")?.value; + if (typeof status !== "number" || !Number.isInteger(status)) return new TransportError(message); + return createApiError(status, message, typeof detail === "string" ? redactApiKey(detail, apiKey) : undefined); + } + const ErrorType = [RequestTimeoutError, TransportError, SerializationError, ValidationError, + TypeError, RangeError, SyntaxError].find(type => error instanceof type) ?? Error; + return new ErrorType(message); } catch { - return truncateMessageBody(normalizedBody); + return new TransportError("HTTP transport error"); } } @@ -266,8 +308,8 @@ function toMessageText(value: unknown): string | undefined { return undefined; } -function truncateMessageBody(body: string): string | undefined { - const normalizedBody = body.replace(/\s+/g, " ").trim(); +function truncateMessageBody(body: string, apiKey?: string): string | undefined { + const normalizedBody = redactApiKey(redactApiKey(body, apiKey).replace(/\s+/g, " ").trim(), apiKey); if (normalizedBody === "") { return undefined; } diff --git a/src/internal/transport.ts b/src/internal/transport.ts index e1e9dbd..2d863ad 100644 --- a/src/internal/transport.ts +++ b/src/internal/transport.ts @@ -1,5 +1,6 @@ import { RequestTimeoutError, TransportError } from "../errors"; import type { HeaderMultiMap } from "../models"; +import { safeTransportError } from "./shared"; export interface HttpRequestData { url: string; @@ -60,34 +61,37 @@ export class FetchHttpTransport implements HttpTransport { method: request.method, headers: toFetchHeaders(request.headers), signal: controller.signal, + redirect: "manual", }; if (request.body != null) { init.body = request.body; } - const response = await this.fetchImpl(request.url, init); + const response = await fetchWithRedirects(this.fetchImpl, request.url, init, controller.signal); clearTimeout(timeoutHandle); phase = "read"; timeoutHandle = startTimeout(controller, request.readTimeoutMs); - const body = await readResponseBody( - response, - this.maxResponseBodyChars, - controller.signal, - ); + let body = ""; + try { + body = await readResponseBody(response, this.maxResponseBodyChars, controller.signal); + } catch (error) { + if (response.status < 300) throw error; + } return { statusCode: response.status, body, headers: extractHeaders(response.headers), }; } catch (error: unknown) { + const key = requestApiKey(request.url); if (isAbortError(error)) { - throw new RequestTimeoutError(toTimeoutMessage(request, phase), { cause: error }); + throw safeTransportError(new RequestTimeoutError(toTimeoutMessage(request, phase), { cause: error }), key); } if (error instanceof TransportError) { - throw error; + throw safeTransportError(error, key); } - throw new TransportError("HTTP transport error", { cause: error }); + throw safeTransportError(new TransportError("HTTP transport error", { cause: error }), key); } finally { clearTimeout(timeoutHandle); } @@ -98,15 +102,56 @@ export class FetchHttpTransport implements HttpTransport { } } -function toFetchHeaders(headers: HeaderMultiMap): Record { - const result: Record = {}; +function requestApiKey(url: string): string | undefined { + try { + return new URL(url, "https://localhost").searchParams.get("apiKey") ?? undefined; + } catch { + return undefined; + } +} + +const redirectStatuses = new Set([301, 302, 303, 307, 308]); + +async function fetchWithRedirects(fetchImpl: FetchLike, url: string, init: RequestInit, signal: AbortSignal): Promise { + for (let redirects = 0; ; redirects++) { + const response = await withAbort(fetchImpl(url, init), signal); + if (response.type === "opaqueredirect") { + throw new TransportError("The browser blocked an API redirect. Use the final API address."); + } + if (!redirectStatuses.has(response.status)) return response; + const location = response.headers.get("Location"); + if (location === null) return response; + void response.body?.cancel().catch(() => {}); + if (redirects === 20) throw new TransportError("Too many API redirects"); + const current = new URL(url, globalThis.location?.href); + const next = new URL(location, current); + if (next.protocol !== current.protocol || next.origin !== current.origin || next.username || next.password) { + throw new TransportError("The API redirected the request to another address"); + } + const method = init.method?.toUpperCase(); + if ((response.status === 303 && method !== "HEAD" && method !== "GET") || + ((response.status === 301 || response.status === 302) && method === "POST")) { + init.method = "GET"; + delete init.body; + const headers = new Headers(init.headers); + for (const name of ["content-encoding", "content-language", "content-location", "content-type", "content-length"]) { + headers.delete(name); + } + init.headers = headers; + } + url = next.href; + } +} + +function toFetchHeaders(headers: HeaderMultiMap): HeadersInit { + const result: Record = Object.create(null); for (const [key, values] of Object.entries(headers)) { if (values.length === 0) { continue; } result[key] = values.length === 1 ? values[0] ?? "" : values.join(", "); } - return result; + return "__proto__" in result ? Object.entries(result) : { ...result }; } async function readResponseBody( @@ -164,19 +209,19 @@ async function readResponseBody( return chunks.join(""); } finally { - if (shouldCancelReader) { - await cancelReaderQuietly(reader); + if (shouldCancelReader || signal.aborted) { + void cancelReaderQuietly(reader); } reader.releaseLock(); } } function extractHeaders(headers: Headers): HeaderMultiMap { - const result: Record = {}; + const result: Record = Object.create(null); headers.forEach((value, key) => { result[key] = Object.freeze([value]); }); - return Object.freeze(result); + return Object.freeze({ ...result }); } function isAbortError(error: unknown): boolean { @@ -216,10 +261,6 @@ function toTimeoutMessage(request: HttpRequestData, phase: TimeoutPhase): string } function withAbort(promise: Promise, signal: AbortSignal): Promise { - if (signal.aborted) { - return Promise.reject(new DOMException("aborted", "AbortError")); - } - return new Promise((resolve, reject) => { const onAbort = (): void => { cleanup(); @@ -231,6 +272,7 @@ function withAbort(promise: Promise, signal: AbortSignal): Promise { }; signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) onAbort(); promise.then( (value) => { cleanup(); diff --git a/src/json-output.ts b/src/json-output.ts index 3fb3285..527c1d5 100644 --- a/src/json-output.ts +++ b/src/json-output.ts @@ -4,6 +4,7 @@ import { type JsonOutputMode as JsonOutputModeType, } from "./enums"; import { SerializationError } from "./errors"; +import { configJson } from "./internal/redaction"; const OMIT = Symbol("omit"); @@ -124,7 +125,9 @@ function toPlainData( }); } - return serializeEntries(Object.entries(value), mode, seen); + const configView = (value as { [configJson]?: () => object })[configJson]; + const object = typeof configView === "function" ? configView() : value; + return serializeEntries(Object.entries(object), mode, seen); } finally { seen.delete(value); } @@ -135,7 +138,7 @@ function serializeEntries( mode: JsonOutputModeType, seen: WeakSet, ): Record { - const result: Record = {}; + const result: Record = Object.create(null); for (const [rawKey, rawValue] of entries) { const key = String(rawKey); const normalizedValue = toPlainData(rawValue, mode, seen, "object"); diff --git a/src/models.ts b/src/models.ts index 279a1d3..8f805a5 100644 --- a/src/models.ts +++ b/src/models.ts @@ -81,7 +81,7 @@ function normalizeHeaderMultiMap(input: HeaderMultiMap | null | undefined): Head return Object.freeze({}); } - const normalized: Record = {}; + const normalized: Record = Object.create(null); for (const [key, values] of Object.entries(input)) { if (typeof key !== "string" || key.trim() === "") { continue; @@ -102,7 +102,7 @@ function normalizeHeaderMultiMap(input: HeaderMultiMap | null | undefined): Head normalized[key] = Object.freeze(normalizedValues); } - return Object.freeze(normalized); + return Object.freeze({ ...normalized }); } export interface Abuse { @@ -182,6 +182,8 @@ export interface Network { connectionType?: string; route?: string; isAnycast?: boolean; + isCdn?: boolean; + cdnProviderName?: string; } export interface Security { @@ -191,6 +193,9 @@ export interface Security { proxyProviderNames?: readonly string[]; proxyConfidenceScore?: number; proxyLastSeen?: string; + proxyDaysSeen7d?: number; + proxyDaysSeen30d?: number; + proxyProviderCount30d?: number; isResidentialProxy?: boolean; isVpn?: boolean; vpnProviderNames?: readonly string[]; @@ -201,6 +206,14 @@ export interface Security { isAnonymous?: boolean; isKnownAttacker?: boolean; isBot?: boolean; + botConfidenceScore?: number; + botOperatorName?: string; + botType?: string; + botLastSeen?: string; + isKnownGoodBot?: boolean; + isCorporateGateway?: boolean; + corporateGatewayType?: string; + corporateGatewayProviderName?: string; isSpam?: boolean; isCloudProvider?: boolean; cloudProviderName?: string; diff --git a/src/request-models.ts b/src/request-models.ts index 68ea5fd..b75d861 100644 --- a/src/request-models.ts +++ b/src/request-models.ts @@ -139,14 +139,11 @@ function normalizeLanguage(value: Language | string | null | undefined): Languag } const normalizedValue = value.trim().toLowerCase(); - if (normalizedValue === "") { - throw new ValidationError(`Unsupported language code: ${value}`); - } if (LANGUAGE_VALUES.includes(normalizedValue as Language)) { return normalizedValue as Language; } - throw new ValidationError(`Unsupported language code: ${value}`); + throw new ValidationError("Unsupported language code"); } function normalizeOutput( @@ -200,7 +197,7 @@ function normalizeHeaders(value: RequestHeaders | null | undefined): NormalizedH return Object.freeze({}); } - const normalized: Record = {}; + const normalized: Record = Object.create(null); const namesByLower = new Map(); for (const [rawName, rawValue] of toHeaderEntries(value)) { @@ -301,11 +298,11 @@ function toHeaderEntry(entry: unknown): readonly [string, HeaderValue] { function freezeNormalizedHeaders( headers: Record, ): NormalizedHeaders { - const normalized: Record = {}; + const normalized: Record = Object.create(null); for (const [name, values] of Object.entries(headers)) { normalized[name] = Object.freeze([...values]); } - return Object.freeze(normalized); + return Object.freeze({ ...normalized }); } function isHeadersInstance(value: unknown): value is Headers { diff --git a/src/version.ts b/src/version.ts index 64ca547..9a4f5f4 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,4 +1,4 @@ -export const SDK_VERSION = "3.0.0"; +export const SDK_VERSION = "3.0.2"; export function getVersion(): string { return SDK_VERSION; diff --git a/tests/live/live-field-parity.test.ts b/tests/live/live-field-parity.test.ts index 37738f0..059e842 100644 --- a/tests/live/live-field-parity.test.ts +++ b/tests/live/live-field-parity.test.ts @@ -248,7 +248,7 @@ function toApiShape(value: unknown): unknown { } function camelToSnake(value: string): string { - return value.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); + return value.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`).replace(/(\d+d)$/, "_$1"); } function isPlainObject(value: unknown): value is Record { diff --git a/tests/unit/redirects.test.ts b/tests/unit/redirects.test.ts new file mode 100644 index 0000000..677db40 --- /dev/null +++ b/tests/unit/redirects.test.ts @@ -0,0 +1,145 @@ +import { createServer, type Server } from "node:http"; +import { once } from "node:events"; +import type { AddressInfo } from "node:net"; +import { afterAll, beforeAll, expect, it } from "vitest"; +import { + FetchHttpTransport, + IpGeolocationClient, + TransportError, +} from "../../src"; + +let origin: string; +let destination: string; +let foreignHits = 0; +const requests: { + method: string; + body: string; + token?: string; + contentType?: string; +}[] = []; +const receiver = createServer((_req, res) => { + foreignHits++; + res.end("[]"); +}); +const api = createServer(async (req, res) => { + const path = new URL(req.url!, origin).pathname; + const route = path.split("/")[1]; + if (route === "final") { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + requests.push({ + method: req.method!, + body: Buffer.concat(chunks).toString(), + token: req.headers["x-lookup-token"] as string, + contentType: req.headers["content-type"], + }); + res.end('[{"ip":"192.0.2.1"}]'); + return; + } + const parts = route!.split("-"); + const location = + parts[0] === "cross" + ? destination + : parts[0] === "loop" + ? req.url! + : "/final"; + res.writeHead(Number(parts[1]), { Location: location }); + res.end(); +}); + +async function start(server: Server): Promise { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + return `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +} + +beforeAll(async () => { + destination = await start(receiver); + origin = await start(api); +}); +afterAll(async () => { + await Promise.all( + [api, receiver].map( + (server) => + new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }), + ), + ); +}); + +const lookup = { + ips: ["192.0.2.1"], + headers: { "X-Lookup-Token": "test-marker" }, +}; +function client(route: string) { + return new IpGeolocationClient({ + baseUrl: `${origin}/${route}`, + apiKey: "test-only", + }); +} + +it.each([301, 302, 303, 307, 308])( + "blocks a cross-origin %i before the destination receives anything", + async (status) => { + await expect( + client(`cross-${status}`).bulkLookupIpGeolocation(lookup), + ).rejects.toBeInstanceOf(TransportError); + expect(foreignHits).toBe(0); + }, +); + +it.each([307, 308])( + "preserves the POST and headers on a same-origin %i", + async (status) => { + const result = await client(`same-${status}`).bulkLookupIpGeolocation( + lookup, + ); + expect(result.data).toEqual([ + { success: true, data: { ip: lookup.ips[0] } }, + ]); + expect(requests.at(-1)).toMatchObject({ + method: "POST", + body: JSON.stringify({ ips: lookup.ips }), + token: "test-marker", + }); + }, +); + +it.each([301, 302, 303])( + "follows a same-origin %i as a GET without the POST body", + async (status) => { + await client(`same-${status}`).bulkLookupIpGeolocation(lookup); + expect(requests.at(-1)).toMatchObject({ + method: "GET", + body: "", + contentType: undefined, + token: "test-marker", + }); + }, +); + +it("stops a redirect loop", async () => { + await expect( + client("loop-307").bulkLookupIpGeolocation(lookup), + ).rejects.toThrow("Too many API redirects"); +}); + +it.each([ + ["post", 302, "GET"], + ["head", 303, "HEAD"], +] as const)( + "preserves Fetch redirect semantics for %s and %i", + async (method, status, expected) => { + await new FetchHttpTransport().send({ + method, + url: `${origin}/same-${status}/v3/ipgeo`, + body: method === "post" ? "payload" : undefined, + headers: {}, + connectTimeoutMs: 1000, + readTimeoutMs: 1000, + }); + expect(requests.at(-1)).toMatchObject({ method: expected, body: "" }); + }, +); diff --git a/tests/unit/request-safety.test.ts b/tests/unit/request-safety.test.ts new file mode 100644 index 0000000..0613c56 --- /dev/null +++ b/tests/unit/request-safety.test.ts @@ -0,0 +1,335 @@ +import { randomUUID } from "node:crypto"; +import { inspect } from "node:util"; +import { expect, it } from "vitest"; +import { + FetchHttpTransport, + IpGeolocationClient, + IpGeolocationClientConfig, + Language, + RateLimitError, + SerializationError, + TransportError, + UnauthorizedError, + toJson, + toPrettyJson, +} from "../../src"; + +const ip = "192.0.2.1"; +const key = `fixture-${randomUUID()} /+`; +const config = new IpGeolocationClientConfig({ apiKey: key }); +function clientFor( + fetch: typeof globalThis.fetch, + maxResponseBodyChars?: number, +) { + return new IpGeolocationClient( + config, + new FetchHttpTransport({ fetch, maxResponseBodyChars }), + ); +} + +it("keeps the configured key usable and hides it when displaying a client that has made a request", async () => { + const transport = { + requests: [] as string[], + async send(request: { url: string }) { + this.requests.push(request.url); + return { body: "{}", statusCode: 200, headers: {} }; + }, + async close() {}, + }; + const client = new IpGeolocationClient(config, transport); + await client.lookupIpGeolocation({ ip }); + expect(new URL(transport.requests[0]!).searchParams.get("apiKey")).toBe(key); + expect({ ...config }.apiKey).toBe(key); + for (const value of [config, client]) { + for (const output of [ + JSON.stringify(value), + toJson(value), + toPrettyJson(value, "full"), + inspect(value, { showHidden: true, getters: true }), + ]) { + expect(output).toContain("[REDACTED]"); + expect(output).not.toContain(key); + expect(output).not.toContain(encodeURIComponent(key)); + expect(output).not.toContain("/v3/ipgeo?"); + } + } + expect( + JSON.parse(toJson({ config }, "full")).config.requestOrigin, + ).toBeNull(); +}); + +it("masks keys before truncating text and after decoding JSON", async () => { + const formKey = new URLSearchParams({ k: key }).toString().slice(2); + const errors = [ + "x".repeat(509) + key, + JSON.stringify({ + message: `bad ${encodeURIComponent(key).replace(/%2F/g, "%2f")} and ${formKey}`, + }), + JSON.stringify({ error: { message: key } }).replace( + key, + `\\u0066${key.slice(1)}`, + ), + ]; + for (const body of errors) { + const client = clientFor(async () => new Response(body, { status: 401 })); + const error = await client.lookupIpGeolocationRaw({ ip }).catch((e) => e); + expect(error).toBeInstanceOf(UnauthorizedError); + expect(error.apiMessage).not.toContain(key.slice(0, 3)); + expect(error.message).toContain("[RE"); + expect(error.statusCode).toBe(401); + } +}); + +it("keeps request URLs and response snippets out of error causes", async () => { + const transport = { + async send(request: { url: string }) { + throw Object.assign(new TypeError(`failed ${request.url}`), { + cause: { key }, + request, + }); + }, + async close() {}, + }; + const client = new IpGeolocationClient(config, transport); + const error = await client.lookupIpGeolocation({ ip }).catch((e) => e); + expect(error).toBeInstanceOf(TypeError); + expect(error.message).toContain("[REDACTED]"); + expect(inspect(error, { showHidden: true })).not.toContain(key.slice(0, 8)); + const invalid = await clientFor(async () => new Response("bad: " + key)) + .lookupIpGeolocation({ ip }) + .catch((e) => e); + expect(invalid).toBeInstanceOf(SerializationError); + expect(invalid.cause).toBeUndefined(); +}); + +it("preserves an HTTP error if its body is oversized, unreadable or late", async () => { + const responses = [ + new Response("too large", { status: 429 }), + new Response( + new ReadableStream({ + start(c) { + c.error(new Error("broken")); + }, + }), + { status: 429 }, + ), + new Response(new ReadableStream({}), { status: 429 }), + ]; + for (const response of responses) { + const client = new IpGeolocationClient( + { apiKey: key, readTimeoutMs: 25 }, + new FetchHttpTransport({ + fetch: async () => response, + maxResponseBodyChars: 4, + }), + ); + const error = await client + .bulkLookupIpGeolocation({ ips: [ip] }) + .catch((e) => e); + expect(error).toBeInstanceOf(RateLimitError); + expect(error.statusCode).toBe(429); + } + await expect( + clientFor(async () => new Response("too large"), 4).lookupIpGeolocationRaw({ + ip, + }), + ).rejects.toBeInstanceOf(TransportError); +}); + +it("sends Arabic on both endpoints and leaves successful data and raw response text intact", async () => { + const raw = JSON.stringify([{ message: key }, { ip: key }], null, 2); + const requests: URL[] = []; + const client = clientFor(async (url) => { + const request = new URL(String(url)); + requests.push(request); + return new Response( + request.pathname.endsWith("-bulk") + ? raw + : JSON.stringify({ domain: key }), + ); + }); + const single = await client.lookupIpGeolocation({ ip, lang: Language.AR }); + const request = { ips: [ip, ip], lang: "ar" }; + const bulk = await client.bulkLookupIpGeolocation(request); + expect(single.data.domain).toBe(key); + expect(bulk.data).toEqual([ + { success: false, error: { message: "[REDACTED]" } }, + { success: true, data: { ip: key } }, + ]); + expect((await client.bulkLookupIpGeolocationRaw(request)).data).toBe(raw); + expect(requests.map((url) => url.searchParams.get("lang"))).toEqual([ + "ar", + "ar", + "ar", + ]); +}); + +it.each([0, 2])( + "rejects %i rows for one IP but preserves raw JSON", + async (count) => { + const body = JSON.stringify(Array.from({ length: count }, () => ({ ip }))); + const client = clientFor(async () => new Response(body)); + await expect( + client.bulkLookupIpGeolocation({ ips: [ip] }), + ).rejects.toBeInstanceOf(SerializationError); + expect((await client.bulkLookupIpGeolocationRaw({ ips: [ip] })).data).toBe( + body, + ); + }, +); + +it("preserves special header names and JSON keys without changing object prototypes", async () => { + const headers = new Map([ + ["__proto__", "one"], + ["constructor", "two"], + ]); + const client = clientFor(async (_url, init) => { + expect(new Headers(init!.headers).get("__proto__")).toBe("one"); + return new Response("{}", { headers: [...headers] }); + }); + const { metadata } = await client.lookupIpGeolocation({ ip, headers }); + expect(metadata.firstHeaderValue("__proto__")).toBe("one"); + const value = JSON.parse('{"__proto__":{"keep":true},"constructor":"two"}'); + expect(JSON.parse(toJson(value))).toEqual(value); + expect(Object.getPrototypeOf(metadata.rawHeaders)).toBe(Object.prototype); +}); + +it("returns all 50,000 typed bulk rows in order, including empty and false field values", async () => { + const ips = Array.from( + { length: 50_000 }, + (_, n) => `198.18.${Math.floor(n / 256)}.${n % 256}`, + ); + const client = clientFor(async (_url, init) => { + expect(JSON.parse(String(init!.body)).ips).toEqual(ips); + return Response.json( + ips.map((ip, i) => + i === 1 + ? { message: "invalid" } + : { + ip, + network: { is_cdn: false, cdn_provider_name: "" }, + security: { proxy_days_seen_7d: 0, is_corporate_gateway: false }, + }, + ), + ); + }); + const result = await client.bulkLookupIpGeolocation({ + ips, + lang: Language.AR, + }); + expect( + result.data.map((row) => (row.success ? row.data.ip : "invalid")), + ).toEqual(ips.map((value, i) => (i === 1 ? "invalid" : value))); + expect(result.data[0]).toEqual({ + success: true, + data: { + ip: ips[0], + network: { isCdn: false, cdnProviderName: "" }, + security: { proxyDaysSeen7d: 0, isCorporateGateway: false }, + }, + }); +}); + +it("preserves a custom transport's retry error and safe details", async () => { + class RetryError extends Error { + readonly code = "ECONNRESET"; + readonly retryable = true; + readonly cause = { detail: new Error("connection reset") }; + } + const failure = new RetryError("connection refused"); + const client = new IpGeolocationClient(config, { + async send() { + throw failure; + }, + async close() {}, + }); + await expect(client.lookupIpGeolocation({ ip })).rejects.toBe(failure); + expect(failure.cause.detail).toBeInstanceOf(Error); +}); + +it("hides the key in normalized text, unrecognized JSON and the URL actually sent by Fetch", async () => { + const samples = [ + { apiKey: "fixture proof", body: "errors: fixture \n proof" }, + { + apiKey: "fixtureKeyZ", + body: '{"nested":{"context":"fixture\\u004beyZ"}}', + }, + { apiKey: "fixture'!proof", body: undefined }, + ]; + for (const { apiKey, body } of samples) { + const client = new IpGeolocationClient( + { apiKey }, + new FetchHttpTransport({ + fetch: async (input, init) => + new Response( + body ?? JSON.stringify({ message: new Request(input, init).url }), + { status: 401 }, + ), + }), + ); + const error = await client.lookupIpGeolocation({ ip }).catch((e) => e); + expect(error).toBeInstanceOf(UnauthorizedError); + expect(error.apiMessage).toContain("[REDACTED]"); + expect(decodeURIComponent(error.apiMessage)).not.toContain(apiKey); + if (body?.startsWith("{")) + expect(JSON.parse(error.apiMessage).nested.context).toBe("[REDACTED]"); + } +}); + +it("retains common network error codes on authenticated requests", async () => { + const cause = Object.assign(new Error("connection refused"), { + code: "ECONNREFUSED", + }); + const client = clientFor(async () => { + throw cause; + }); + const error = await client.lookupIpGeolocation({ ip }).catch((e) => e); + expect(error).toBeInstanceOf(TransportError); + expect(error.cause).toBe(cause); +}); + +it("does not retain private request URLs exposed by custom error formatting", async () => { + class RequestError extends Error { + #url: string; + constructor(url: string) { + super("request failed"); + this.#url = url; + } + toString() { + return `${this.message}: ${this.#url}`; + } + } + const client = new IpGeolocationClient(config, { + async send(request) { + throw new RequestError(request.url); + }, + async close() {}, + }); + const error = await client.lookupIpGeolocation({ ip }).catch((e) => e); + expect(String(error)).toContain("request failed"); + expect(String(error)).not.toContain("apiKey="); +}); + +it.each(["apiMessage", "statusCode"])("does not read a custom error's %s getter", async (field) => { + let reads = 0; + const client = new IpGeolocationClient(config, { + async send(request) { + throw Object.defineProperty(new UnauthorizedError("upstream failed", 401), field, { + get() { + reads++; + throw new Error(request.url); + }, + }); + }, + async close() {}, + }); + const error = await client.lookupIpGeolocation({ ip }).catch((e) => e); + expect(reads).toBe(0); + expect(error.message).toBe("upstream failed"); + if (field === "apiMessage") { + expect(error).toBeInstanceOf(UnauthorizedError); + expect(error.statusCode).toBe(401); + } else { + expect(error).toBeInstanceOf(TransportError); + } +});