feat: add HTTP/2 multiplexing support for Node.js#4213
feat: add HTTP/2 multiplexing support for Node.js#4213maxleonard wants to merge 3 commits intomainfrom
Conversation
Add an HTTP/2-aware fetch implementation that multiplexes all requests
to the same origin over a single TCP connection, reducing latency for
concurrent workloads common in Solid pod interactions.
- New createHttp2Fetch() factory with connection pooling and idle timeout
- Session({ http2: true }) option for automatic HTTP/2 transport
- Thread customFetch through the login flow to buildAuthenticatedFetch
- Works with both DPoP and Bearer token authentication
- Export createHttp2Fetch and Http2Fetch type from package index
- Add comprehensive tests against real HTTP/2 server
- Update README with usage documentation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| fetch: typeof fetch = async (url, init) => { | ||
| if (!this.info.isLoggedIn) { | ||
| return fetch(url, init); | ||
| return (this.http2Fetch ?? fetch)(url, init); |
Check failure
Code scanning / CodeQL
Server-side request forgery Critical
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI about 23 hours ago
In general, the problem is that user-controlled resource is used as the URL argument to fetch with no constraints. To fix this without breaking existing semantics, we should validate and normalize the URL before passing it to fetch, and reject or constrain values that don’t meet safe criteria (e.g., enforce HTTPS and disallow internal IPs / localhost in the test server). Since the library Session.fetch is a general-purpose method, we keep it flexible, but we can add a minimal safeguard to ensure the unauthenticated, “raw” fetch path only accepts absolute URLs with allowed protocols. The core SSRF surface exposed over HTTP is the Express test app; there we can be stricter and apply explicit checks.
Concretely:
-
In
e2e/node/server/express.ts:- In both
/legacy/fetchand/tokens/fetch, after confirmingresourceis a string, parse it withnew URL(resource)inside a try/catch. - Enforce that
url.protocolishttp:orhttps:. - Optionally (and safely) add a basic blocklist for obviously sensitive hosts (loopback / link-local / private networks). Since we must not assume extra infrastructure, we can at minimum prevent
localhostand 127.0.0.0/8 and document that this is a safety check for the test server. - If the URL fails validation, respond with HTTP 400 and do not call
fetch. - Use the parsed and validated
url.toString()for the fetch call (or keep the original string once validated, but parsing already proves it is a valid URL).
- In both
-
In
packages/node/src/Session.ts:- Add a shallow check in the unauthenticated branch of
fetch: ensureurlis either aURLobject or a string that parses to a URL withhttp:orhttps:protocol. This is mostly defense-in-depth and satisfies CodeQL that we’re not blindly using attacker-controlled protocols; it should not affect normal usage where callers use standard URLs. - If the check fails, throw an error, as that’s better than making a dangerous request.
- Add a shallow check in the unauthenticated branch of
Implementation elements:
- Use the built-in
URLclass (global in Node 18+, orimport { URL } from "url";if needed – but Node’s global is likely already available; we can callnew URL(...)directly). - We do not add any third-party dependencies; native URL handling is sufficient.
The lines to change:
e2e/node/server/express.ts:- Around lines 146–162 and 164–188 (within
/legacy/fetchand/tokens/fetch), wrapresourcein validation logic beforefetch(resource).
- Around lines 146–162 and 164–188 (within
packages/node/src/Session.ts:- Around line 458–462, extend the unauthenticated branch to validate
urlbefore calling(this.http2Fetch ?? fetch)(url, init).
- Around line 458–462, extend the unauthenticated branch to validate
| @@ -457,7 +457,23 @@ | ||
| */ | ||
| fetch: typeof fetch = async (url, init) => { | ||
| if (!this.info.isLoggedIn) { | ||
| return (this.http2Fetch ?? fetch)(url, init); | ||
| // Basic defense-in-depth: ensure that the URL used for unauthenticated | ||
| // requests is a valid HTTP(S) URL. | ||
| let normalizedUrl = url; | ||
| if (typeof url === "string") { | ||
| try { | ||
| const parsed = new URL(url); | ||
| if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { | ||
| throw new Error("Unsupported URL protocol"); | ||
| } | ||
| normalizedUrl = parsed.toString(); | ||
| } catch (e) { | ||
| throw new Error( | ||
| `Invalid URL passed to fetch: ${(e as Error).message}`, | ||
| ); | ||
| } | ||
| } | ||
| return (this.http2Fetch ?? fetch)(normalizedUrl as any, init); | ||
| } | ||
| return this.clientAuthentication.fetch(url, init); | ||
| }; |
| @@ -151,10 +151,23 @@ | ||
| return; | ||
| } | ||
|
|
||
| let url: URL; | ||
| try { | ||
| url = new URL(resource); | ||
| } catch (_e) { | ||
| res.status(400).send("invalid resource URL").end(); | ||
| return; | ||
| } | ||
|
|
||
| if (url.protocol !== "http:" && url.protocol !== "https:") { | ||
| res.status(400).send("unsupported URL protocol").end(); | ||
| return; | ||
| } | ||
|
|
||
| const session = await getSessionFromStorage(req.session!.sessionId); | ||
|
|
||
| const { fetch } = session ?? new Session(); | ||
| const response = await fetch(resource); | ||
| const response = await fetch(url.toString()); | ||
| res | ||
| .status(response.status) | ||
| .send(await response.text()) | ||
| @@ -169,6 +179,19 @@ | ||
| return; | ||
| } | ||
|
|
||
| let url: URL; | ||
| try { | ||
| url = new URL(resource); | ||
| } catch (_e) { | ||
| res.status(400).send("invalid resource URL").end(); | ||
| return; | ||
| } | ||
|
|
||
| if (url.protocol !== "http:" && url.protocol !== "https:") { | ||
| res.status(400).send("unsupported URL protocol").end(); | ||
| return; | ||
| } | ||
|
|
||
| let session; | ||
| const sessionTokenSet = sessionTokenSets.get(req.session!.sessionId); | ||
| if (sessionTokenSet) { | ||
| @@ -180,7 +203,7 @@ | ||
|
|
||
| const { fetch } = session ?? new Session(); | ||
|
|
||
| const response = await fetch(resource); | ||
| const response = await fetch(url.toString()); | ||
| res | ||
| .status(response.status) | ||
| .send(await response.text()) |
Restrict http2Fetch to only allow https: and http: protocols, rejecting schemes like file: or data: that could enable server-side request forgery. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace ~260-line hand-rolled HTTP/2 implementation (connection pooling,
idle timers, pseudo-header mapping, body streaming, response construction)
with undici's built-in HTTP/2 support via Agent({ allowH2: true }).
Same public API (Http2Fetch type, createHttp2Fetch function, close method)
with the same options (idleTimeout, tlsOptions), now in ~40 lines.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
Add HTTP/2 multiplexing support to the Node.js authentication package, enabling all requests to the same origin to share a single TCP connection. This significantly reduces latency for concurrent workloads common in Solid pod interactions (fetching resources, ACLs, containers, profiles, etc.).
Browsers already negotiate HTTP/2 transparently — this addresses the Node.js-only limitation where
fetchdefaults to HTTP/1.1 with ~6 concurrent TCP connections per origin.Detailed Changes
New files
packages/node/src/http2Fetch.ts— HTTP/2-aware fetch implementation with connection poolingcreateHttp2Fetch()factory returns a fetch-compatible function withclose()methodMap<origin, http2.ClientHttp2Session>with configurable idle timeout (default 30s)RequestInfo | URLinput,RequestInitoptions,Headersobjects, request body streaming:method,:path) and streamsHttp2Fetchtype with JSDoc and@since 2.6.0packages/node/src/http2Fetch.spec.ts— 11 tests against a real HTTP/2 servernode:http2.createSecureServerwith self-signed certificatesclose(), URL objects,Headersobjects, response URL propertyModified files — Session integration
packages/node/src/Session.tshttp2?: booleantoISessionOptions— creates HTTP/2 fetch whentruehttp2Fetchlogout()callshttp2Fetch.close()to release connectionspackages/node/src/index.ts— AddedcreateHttp2FetchandHttp2FetchexportsModified files — Threading customFetch through the login flow
The
buildAuthenticatedFetchfunction already accepts anoptions.fetchfor the underlying transport. These changes thread the HTTP/2 fetch to that point:packages/core/src/Session.ts— AddedcustomFetch?: typeof fetchtoSessionConfigpackages/core/src/login/ILoginOptions.ts— AddedcustomFetchfieldpackages/core/src/login/oidc/IOidcOptions.ts— AddedcustomFetchfieldpackages/node/src/ClientAuthentication.ts— Passesconfig.customFetchto login handlerpackages/node/src/login/oidc/OidcLoginHandler.ts— CopiescustomFetchintoIOidcOptionspackages/node/src/login/oidc/oidcHandlers/ClientCredentialsOidcHandler.ts— Passes tobuildAuthenticatedFetchpackages/node/src/login/oidc/oidcHandlers/RefreshTokenOidcHandler.ts— Passes throughrefreshAccess()tobuildAuthenticatedFetchpackages/node/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler.ts— Passes fromSessionConfigtobuildAuthenticatedFetchDocumentation
packages/node/README.md— HTTP/2 Support section with Session usage, standalone usage, and DPoP/Bearer compatibility notesData flow
DPoP compatibility
Works with both DPoP and Bearer authentication. DPoP proofs are generated per-request by
buildAuthenticatedFetch; the HTTP/2 transport is transparent to the auth layer.Usage
Test plan
🤖 Generated with Claude Code