From a35dc508b0155429690737ad3bbb3fc8c52f4abc Mon Sep 17 00:00:00 2001 From: Andrew Miller Date: Thu, 4 Jun 2026 12:41:35 -0400 Subject: [PATCH] client: allow injecting an http(s).Agent + reject redirects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HTTP client built its connections with no injectable agent, so a server-side caller pointing the client at an attacker-influenced baseURL (a tenant's prime_endpoint) had no way to attach an SSRF policy at connect time. node's http.request doesn't follow redirects, but a 3xx was treated as success and its body mis-parsed. - ClientConfig accepts an optional `agent`; inject one whose connection factory refuses internal IPs (incl. DNS-rebinding defense) when the baseURL is untrusted. Default stays unguarded for trusted/CLI use — the guard belongs in the server. - A 3xx response is now an explicit error ("refusing to follow"), so a redirect can't be silently mis-handled or, via a future change, followed to an internal host. --- src/client.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/client.ts b/src/client.ts index 4004c3b..b1dbcbf 100644 --- a/src/client.ts +++ b/src/client.ts @@ -13,6 +13,15 @@ export interface ClientConfig { authKey: string; baseURL: string; timeout?: number; + /** + * Optional http(s).Agent used for every request. Inject an agent whose + * connection factory refuses to connect to internal IPs when baseURL is + * attacker-influenced (a tenant's prime_endpoint), to defend against SSRF + * (incl. DNS rebinding) at connect time. The SSRF policy belongs in the + * server that points this client at untrusted endpoints, not baked into + * the client library, so the default (no agent) is unguarded. + */ + agent?: http.Agent | https.Agent; } export class DragonchainClient { @@ -21,6 +30,7 @@ export class DragonchainClient { private readonly authKey: string; private readonly baseURL: string; private readonly timeout: number; + private readonly agent?: http.Agent | https.Agent; constructor(config: ClientConfig) { this.publicId = config.publicId; @@ -28,6 +38,7 @@ export class DragonchainClient { this.authKey = config.authKey; this.baseURL = config.baseURL.replace(/\/$/, ''); // Remove trailing slash this.timeout = config.timeout || 30000; // Default 30 seconds + this.agent = config.agent; } /** @@ -123,6 +134,7 @@ export class DragonchainClient { 'Content-Length': bodyBuffer.length, }, timeout: this.timeout, + ...(this.agent && { agent: this.agent }), }; return new Promise((resolve, reject) => { @@ -137,6 +149,19 @@ export class DragonchainClient { res.on('end', () => { const responseBody = Buffer.concat(chunks); + // Refuse redirects. node's http.request does not follow them, but + // treating a 3xx as success would mis-parse the (empty) body — and + // a followed redirect to an internal host would be an SSRF vector. + // Prime never legitimately redirects, so a 3xx is an error. + if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400) { + reject( + new Error( + `Unexpected redirect (status ${res.statusCode}) to ${res.headers.location ?? '?'}; refusing to follow` + ) + ); + return; + } + // Check for errors if (res.statusCode && res.statusCode >= 400) { const errorMessage = responseBody.toString('utf8').trim();