fetch
rusty-fetch-api is the value-type layer behind fetch — Headers, Request, Response, and the text/bytes/json body accessors. It canonicalizes every URL, refuses forbidden request headers, and rejects CR/LF injection at construction. It owns no transport: the socket, redirects, and TLS live in the host, where the credential-leak and SSRF defenses sit.
A call you write a thousand times in Node:
const res = await fetch("https://api.example.com/thing", {
headers: { authorization: `Bearer ${token}` },
});
const data = await res.json();
In Node this pulls in undici (or, historically, reqwest-shaped clients in the Rust world), one library that parses the URL, decides whether an authorization header survives a 302 to another host, and stops a hostile server from streaming an endless body. In Cruft those are two different crates, and this page is about telling them apart.
Alpha, not production-ready.rusty-fetch-apiis a Cruft crate (version0.0.10) that stands in for the value-object surface ofreqwest/undici:Headers,Request,Response, and body accessors. It has not been externally audited. The behaviors below are backed by in-tree tests, cited inline. Feed it adversarial input at your own risk.
The value layer and the transport are separate
reqwest is shaped as one library that owns the whole call: it parses the URL, opens the socket, follows redirects, and hands back a Response.
Cruft splits the job. rusty-fetch-api owns the value types you touch in JS: constructing a Request, normalizing a Headers bag, building and reading a Response, consuming a body as text/bytes/json. It owns no transport. Its own header comment says so: "no transport, no ReadableStream body, no CORS/credentials enforcement".
The actual socket, the redirect loop, the TLS handshake, the byte pump: those live in the host transport. So the answer to "who checked the redirect?" is "not this crate." That seam matters, because every security question below lands on one side of it or the other.
The URL is validated
rusty-fetch-api routes every Request URL and every Response URL through one canonicalizer, url_canon::canonicalize, which calls rusty_js_url::parse_to_href. That path applies IDNA domain-to-ASCII (UTS-46 mapping, NFC, bidi, punycode ACE) via the URL crate. A Unicode host like 例え.日本 becomes xn--r8jz45g.xn--wgv71a before anyone sees it. Inputs the URL parser rejects (relative refs, opaque non-special inputs without a base, malformed authorities) are rejected here rather than passed through as a raw string. The reason is stated directly: "A transport author must never inherit a 'canonical' URL that is just unvalidated input".
The IDNA canonicalization is backed by known-answer tests that assert Unicode hosts in both Request URLs and Response redirect URLs ACE-encode to xn-- form.
Forbidden headers are refused at the value layer
In the browser, the Fetch spec forbids setting Host, Content-Length, Cookie, Connection, and the Proxy-*/Sec-* families, because letting script set them desyncs request framing or forges identity. Node historically let you set almost anything server-side.
rusty-fetch-api keeps the browser rule at the value layer. validate_request_headers walks the bag and rejects any name in the WHATWG forbidden-request-header list, including host, content-length, cookie, connection, transfer-encoding, plus any proxy-* or sec-* prefix. Header names are validated against the RFC 7230 token charset, and values reject CR/LF/NUL and strip surrounding whitespace (validate_name / normalize_value). So request-splitting via a \r\n in a header value fails at construction, not at the socket.
The Headers/Request/Response behavior is covered by tests keyed to the WHATWG spec sections plus consumer-regression tests across the crate.
This value-layer check is real, but it is not the transport's only line of defense, and for a while it was the only one. What matters most is that the host re-checks on the wire, which brings us to the redirect.
Credential leaks on redirect are host territory
You send Authorization: Bearer <token> to your-api.com. It answers 302 Location: https://evil.com/. Does your token get re-sent to evil.com?
This is the classic cross-origin credential leak (curl CVE-2022-27774). rusty-fetch-api cannot answer it, because it does not follow redirects. The redirect loop lives in the host transport. The value layer was clean, but the transport once carried headers across a redirect unchanged and permitted an https://→http:// downgrade.
That is now fixed and enforced. Redirect planning constructs a source and target origin over scheme, host, and effective port, and strips Authorization, Cookie, and Proxy-Authorization before any cross-origin resend. The same credential headers are stripped on an https:→http: downgrade. Non-credential headers (for example X-Keep) survive, and the existing 301/302/303 method-and-body rewrite still owns Content-Length removal. Focused helper tests cover same-origin preservation, cross-origin stripping, and downgrade stripping, and live redirect gates cover the path end to end.
The rest of the transport hardening
The same host transport carried three more problems. All three are now fixed, and the last two are directly observable from the running binary:
- Request-splitting and forbidden headers on the wire. The value layer already validated headers, but the live host path once took JS
init.headersverbatim into request framing. The hostfetch()now validates names as HTTP token bytes, rejects CR/LF/NUL in values, and rejects forbidden request headers (Host,Content-Length,Connection,Transfer-Encoding,Cookie,Sec-*,Proxy-*) before URL or capability planning. Running the binary,fetch("http://...", { headers: { host: "evil.com" } })rejects withfetch: forbidden request header 'host'. Covered by the crate's tests.
- SSRF / loopback / cloud-metadata egress. Fetch once passed the raw host to the socket with no private-range guard, so
127.0.0.1,169.254.169.254,10./192.168.,::1, and decimal/hex loopback encodings were all reachable. The worker now canonicalizes each hop's host (trailing-dot trim, lowercase, IPv6 unwrap) and rejectslocalhostaliases,.localhost/.local, private/loopback/link-local/ unspecified/multicast IPv4, the metadata address169.254.169.254, and IPv6 loopback/unique-local (fc00::/7)/link-local (fe80::/10). DNS-resolved addresses get the same IP check before connect. Running the binary,fetch("http://127.0.0.1:9/"),fetch("http://169.254.169.254/"), andfetch("http://10.0.0.1/")each reject withfetch: blocked internal address '<addr>'. Covered by the crate's tests.
- No body-size cap, no timeout (OOM / slow-loris). The worker now runs under a 30s watchdog, passes that budget into the connect helper, uses the cancel-aware nonblocking read path, and counts response bytes with a 64 MiB cap before forwarding chunks to JS. Covered by the crate's tests.
Worth stating plainly: redirects are capped at 20, the http/https scheme allowlist is enforced on the initial URL and every redirect hop, fetch does not auto-decompress (so the brotli-bomb class is not amplified here), and no panic or unsafe is reachable from a malformed response.
Limitations
- The crate is value types only. No transport, no
ReadableStreambody, no CORS or credentials policy enforcement live inrusty-fetch-api. Everything that touches the network is the host's, and the host is a separate, thinner, less-tested surface thanreqwest/undici. - The interop baseline is not close.
reqwest/undicihave years of real-world redirect, proxy, connection-pool, HTTP/2, and TLS interop hardening plus large conformance suites. This crate's value-layer tests and the host's focused tests are a floor, not parity. There is near-zero interop testing against real servers at scale. - The fixes are real but young. The four transport properties above were filed and fixed within roughly a day. Each has focused tests and observable behavior, but passing new tests is not surviving adversarial traffic in the wild. Treat the SSRF egress policy in particular as a first cut: an allow/deny egress model that has not met a red team.
- Body streaming is absent at the value layer. If your code depends on incremental
ReadableStreamconsumption semantics, that lives in the host stream plumbing, not here.
Where reqwest/undici still win: maturity, connection pooling and reuse, HTTP/2 and HTTP/3, proxy support, cookie jars, breadth of TLS/interop coverage, and battle-tested redirect edge cases. Where Cruft's split earns its keep: the URL is canonicalized before anyone can act on it, forbidden headers are refused at construction, and the credential-leak, request-splitting, SSRF, and DoS surfaces each have a named, tested, gated fix rather than an implicit "the client probably handles it."