Secure by default.
An HTTP/1.1 and HTTP/2 client for code that sends requests to URLs it does not fully control: webhooks, link previews, integrations, internal service clients. It blocks SSRF (including DNS rebinding), caps memory and time, and never silently ignores a mistake in its options. The safe choice is the default, and the risky ones are opt-in.
Secure Defaults
Every protection is on unless you turn it off, and some cannot be turned off.
Zero Runtime Dependencies
Built only on Node's own modules: node:https, node:http2, node:dns, node:net, node:tls and node:zlib. HTTP_PROXY and http.globalAgent are never used: each client has its own connection pool.
SSRF Protection
Private, loopback, link-local, cloud-metadata and other reserved IPs are blocked in IP literals of every spelling, DNS answers and every redirect hop. Each address is validated once and the connection is pinned to it, which blocks DNS rebinding. Allow only the ranges you need with permitReservedIps.
Memory & Time Limits
Response bodies are capped at 2 MB (maxBodySize), requests at 30 s in total including DNS and every hop (timeoutMs), and idle connections at 10 s (socketTimeoutMs). A truncated response is rejected, never resolved as a short body.
Strict Protocol Handling
Redirects are not followed unless you set maxRedirects; https → http is rejected, and credentials are dropped on a cross-origin hop. TLS verification is always on. CRLF/NUL in headers, and unknown or mistyped options, throw before connecting.
Everything Else You Need
An everyday client, not just a guard. Every feature goes through the same checks and limits.
Queries, Forms & Uploads
query builds the query string, json serializes, form sends urlencoded fields for OAuth and older APIs, and a FormData body goes as multipart with its boundary set for you. A stream body is sent as it is read, with an optional maxRequestBodySize. Values that cannot be encoded throw instead of being dropped.
Safe Decompression
gzip, deflate and brotli are decoded by default, with maxBodySize applied to the decoded bytes too, so a decompression bomb stops at the limit. A corrupt or cut-short body is an error, never a short success.
Bounded Streaming, Events & WebSocket
client.stream() resolves on the headers and hands you a ReadableStream. The size limit, content-length check, decoding and idle timeout keep applying as you read, so large downloads never sit in memory. client.events() reads Server-Sent Events the same way with for await, each event capped by maxEventSize (64 KiB); it never reconnects on its own. client.websocket() opens a WebSocket over the same pinned path, each message capped by maxMessageSize (1 MiB) before it is buffered, with no compression and no redirects.
Drop-in fetch()
client.fetch takes the same arguments as fetch and returns a standard Response. Hand it to any SDK that accepts a custom fetch, and its requests get the SSRF policy and limits without code changes.
Opt-in Retries
retry: 2 retries 408, 429 and 5xx gateway errors and transient network failures, with jittered backoff and Retry-After respected. Only idempotent methods, one timeoutMs budget for every attempt, and DNS validated again each time.
HTTP & SOCKS5 Proxies
proxy: 'http://egress:3128' or 'socks5://bastion:1080'. UltraTon validates the target first and sends the proxy the IP, never a name it could resolve elsewhere. TLS runs to the target through the tunnel, with its certificate checked.
Watch It Work
Every request is checked on its way out, and every response on its way back. Pick an attack, or let them play.
On the way out
On the way back
Choosing a Client
UltraTon does not try to be the fastest HTTP client. It is the one for URLs you do not fully control, and for safe defaults you do not have to write.
Your URL, max throughput
Use Node's built-in fetch (undici). It is Node's own engine, with nothing to install, and up to 1.4× faster than UltraTon over HTTP/1.1 with small bodies; the SSRF checks it lacks are ones your own URLs do not need.
Their URL, or safe defaults
Use UltraTon. It blocked all 32 cases of the SSRF corpus, all of which fetch, axios, got and node-fetch reach on their own. It is also 1.4–1.9× faster than axios, got and node-fetch in the benchmarks.
Already on axios or got
Keep them, and plug in createSecureAgents(). Every connection is then validated and pinned, at no measurable cost. With axios, pass proxy: false.
Not for browsers, Bun or Deno, and no cookie jar or HTTP cache. The full comparison lists each library's defaults, including where UltraTon falls short.
Secure, and Built to Be Fast
Every check costs something. We measure that cost on every pull request, profile where it goes, and keep cutting it, without dropping a single check.
Requests per second over HTTP/1.1, higher is better
Every client speaks the same protocol to the same server, so the bars compare the clients themselves. GET, 16 requests in flight, each client with its own defaults.
UltraTon, every check onOther clients
Show as a table
And with HTTP/2? UltraTon against itself, http2: true on and off
Not comparable with the bars above: here HTTP/2 also runs against a different test server (node:http2), and fetch and the other clients use HTTP/1.1, which is what Node's fetch does by default. Turn it on when the server you call speaks HTTP/2.
HTTP/1.1, 1 KiB bodies
77%
of Node's own http.request throughput, with every check on. Before our last performance pass it was 62–67%, depending on the run.
CPU per request
1.29×
the CPU of http.request per request, down from 1.47–1.61× on the same runner hardware.
HTTP/2, 64 KiB bodies
1.5×
the throughput of before, from 48% to 74% of http.request, by sizing the flow-control windows.
That pass: larger HTTP/2 windows, response bodies read with events, the address allowlist validated once, and fewer copies of each request's options. Each change has its own pull request with before and after numbers.
How we measured, and what these numbers do not say
- Where: a shared GitHub Actions runner (AMD EPYC 7763, 4 vCPUs, Linux), Node 24.21.0, on
mainat commit9ccf8ac(2026-10-21), after 2.4.0. Clients: undici 7.29.1 (Node'sfetch), axios 1.20.0, got 16.1.0, node-fetch 3.3.2. - How: each client runs in its own process with its own defaults and keep-alive, keeps 16 GET requests in flight and reads the whole body: a 2 s warm-up, then the median of 3 rounds of 5 s. Node's
http.requestis the baseline, with no checks at all. - No TLS, no DNS, no network. Plain HTTP (h2c for HTTP/2) on loopback, to an IP literal. TLS and DNS are left out, though UltraTon resolves and validates DNS on every new connection, with no cache. Without network latency, per-request overhead looks much larger than it is: against a real server, the gap to
http.requestis a small fraction of a request's time. - HTTP/2 is shown apart on purpose: it runs against its own server (
node:http2) over one multiplexed connection, so its number measures the protocol as much as the client. The chart compares clients over HTTP/1.1 only. - Runners are noisy and land on different CPUs from run to run: compare bars within one chart, not across runs or with your own machine. On other runs,
fetchand UltraTon over HTTP/1.1 have been closer. - Reproduce it: the harness and its full results (latency, CPU and memory too) are in the repository, and the Benchmarks workflow runs it in CI on every pull request that touches the code.
import {
UltraTonClient,
SecureHttpError,
UltraTonNetworkTimeoutError,
} from 'ultraton';
// One client, reused. Its options are defaults for every request.
const api = new UltraTonClient({
baseUrl: 'https://api.example.com/v1/',
headers: { authorization: `Bearer ${process.env.API_TOKEN}` },
timeoutMs: 5000,
retry: 2, // idempotent methods only, Retry-After respected
});
try {
// A non-2xx response resolves: check res.ok or res.statusCode.
const res = await api.get('users/7');
if (res.ok) {
const user = res.json<{ id: number; name: string }>();
console.log(user.name);
}
// Every method is (url, options).
await api.get('users', { query: { page: 2 } });
await api.post('users', { json: { name: 'Ada' } });
await api.post('oauth/token', { form: { grant_type: 'client_credentials' } });
} catch (err) {
if (err instanceof UltraTonNetworkTimeoutError) { /* retry later */ }
else if (err instanceof SecureHttpError &&
err.code === 'ERR_ULTRATON_RESERVED_IP') { /* blocked target */ }
else throw err;
} finally {
await api.close();
}
One client.
One call shape.
Create one client and reuse it. A non-2xx response resolves; the promise only rejects when no complete response arrives, or when a policy or a limit stops the request. Works from ESM, CommonJS and TypeScript.
new UltraTonClient({ baseUrl, headers, http2, tls, retry, proxy, … }), withclose()when you are doneget,post,put,patch,delete,head,options: all(url, { query | json | form | body | headers | auth | signal | retry | … })- Response
{ statusCode, ok, headers, data, json<T>(), text() }. The body is never parsed automatically. stream(url, { method, … })for aReadableStreambody, andclient.fetchfor a standardResponse- Policy errors carry a stable
ERR_ULTRATON_*code; network errors carry Node's code
Full options, errors and recipes are in the README, including a guide for migrating from 1.x.
Limits and Things to Know
What it does not do
No environment proxies. UltraTon never reads HTTP_PROXY/HTTPS_PROXY, NODE_USE_ENV_PROXY or http.globalAgent: a proxy is used only when you pass proxy, so the SSRF policy cannot be bypassed through a shared pool. HTTP CONNECT and SOCKS5 are supported, not SOCKS4. Streams are read once. A request with a stream body is never retried, and a 307/308 comes back as the response; a File or Blob body is read whole, so pass a stream for a large upload. Plain http: only reaches loopback, and never through a proxy.
Where the policy ends
Redirects into ranges your permitReservedIps allows are followed, so keep allowlists narrow, or keep maxRedirects: 0 on clients that allow private ranges. A hostname in the allowlist trusts whatever that name resolves to. With resolve: 'proxy' (or a socks5h: URL) the proxy resolves names, so SSRF protection for hostnames depends on the proxy, and SOCKS5 credentials cross the network to the proxy in cleartext. DNS lookups cannot be cancelled: timeoutMs and signal reject the request on time, but the lookup keeps running until the resolver answers. json() strips __proto__, constructor and prototype keys, so a legitimate field with one of those names disappears.