client: allow injecting an http(s).Agent + reject redirects
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.
This commit is contained in:
@@ -13,6 +13,15 @@ export interface ClientConfig {
|
|||||||
authKey: string;
|
authKey: string;
|
||||||
baseURL: string;
|
baseURL: string;
|
||||||
timeout?: number;
|
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 {
|
export class DragonchainClient {
|
||||||
@@ -21,6 +30,7 @@ export class DragonchainClient {
|
|||||||
private readonly authKey: string;
|
private readonly authKey: string;
|
||||||
private readonly baseURL: string;
|
private readonly baseURL: string;
|
||||||
private readonly timeout: number;
|
private readonly timeout: number;
|
||||||
|
private readonly agent?: http.Agent | https.Agent;
|
||||||
|
|
||||||
constructor(config: ClientConfig) {
|
constructor(config: ClientConfig) {
|
||||||
this.publicId = config.publicId;
|
this.publicId = config.publicId;
|
||||||
@@ -28,6 +38,7 @@ export class DragonchainClient {
|
|||||||
this.authKey = config.authKey;
|
this.authKey = config.authKey;
|
||||||
this.baseURL = config.baseURL.replace(/\/$/, ''); // Remove trailing slash
|
this.baseURL = config.baseURL.replace(/\/$/, ''); // Remove trailing slash
|
||||||
this.timeout = config.timeout || 30000; // Default 30 seconds
|
this.timeout = config.timeout || 30000; // Default 30 seconds
|
||||||
|
this.agent = config.agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -123,6 +134,7 @@ export class DragonchainClient {
|
|||||||
'Content-Length': bodyBuffer.length,
|
'Content-Length': bodyBuffer.length,
|
||||||
},
|
},
|
||||||
timeout: this.timeout,
|
timeout: this.timeout,
|
||||||
|
...(this.agent && { agent: this.agent }),
|
||||||
};
|
};
|
||||||
|
|
||||||
return new Promise<T>((resolve, reject) => {
|
return new Promise<T>((resolve, reject) => {
|
||||||
@@ -137,6 +149,19 @@ export class DragonchainClient {
|
|||||||
res.on('end', () => {
|
res.on('end', () => {
|
||||||
const responseBody = Buffer.concat(chunks);
|
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
|
// Check for errors
|
||||||
if (res.statusCode && res.statusCode >= 400) {
|
if (res.statusCode && res.statusCode >= 400) {
|
||||||
const errorMessage = responseBody.toString('utf8').trim();
|
const errorMessage = responseBody.toString('utf8').trim();
|
||||||
|
|||||||
Reference in New Issue
Block a user