Summary
When http.request() / https.request() is called with a single an options object that contains URL fields like hash, href, and origin (as produced by Node's urlToHttpOptions()) and no method, @mswjs/interceptors misclassifies it as a legacy url.parse() URL and rebuilds the request from href alone — silently discarding headers and other request options on passthrough.
This reproduces with MSW alone, but is hit in the wild whenever OpenTelemetry's HTTP instrumentation is also active, because it normaliSes outgoing request(url, options) calls into exactly this shape. It broke the LaunchDarkly Node SDK for us: the SDK's streaming GET reached the server with no Authorisation header → 401. (Its analytics POST was unaffected — it carries a method, so it dodges the heuristic.)
Environment
@mswjs/interceptors: 0.40.0 — also confirmed on 0.41.9 (latest); the relevant code is unchanged.
Node: 24.13
(In production it surfaced via @opentelemetry/instrumentation-http + msw 2.x.)
Reproduction (MSW only — no OTel/SDK needed)
import nodeHttp from "node:http";
import { urlToHttpOptions } from "node:url";
import { http, passthrough } from "msw";
import { setupServer } from "msw/node";
setupServer(http.all("*", () => passthrough())).listen({ onUnhandledRequest: "bypass" });
const echo = nodeHttp.createServer((req, res) => {
console.log("server received authorization:", req.headers.authorization ?? "(none)");
res.end("ok");
process.exit(0);
});
echo.listen(0, "127.0.0.1", () => {
const { port } = echo.address();
const url = new URL(`http://127.0.0.1:${port}/x`);
// The shape OpenTelemetry's instrumentation-http produces for request(url, options):
// urlToHttpOptions(url) merged with the caller's options, one object, GET (no `method`).
const options = {
...urlToHttpOptions(url), // includes hash, href, host, path, ...
headers: { authorization: "Bearer secret-key" },
};
nodeHttp.request(options).end();
});
Actual: server received authorization: (none)
Expected: server received authorization: Bearer secret-key
For comparison, passing the URL as a string — nodeHttp.request(url.href, { headers: {...} }) — preserves the header; it's specifically the single-options-object form with URL fields that fails.
Root cause
In src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.ts
else if ('hash' in args[0] && !('method' in args[0])) {
// ...treats args[0] as a url.parse() URL...
const resolvedUrl = new URL(legacyUrl.href)
return normalizeClientRequestArgs(defaultProtocol, [resolvedUrl, args[1]]) // headers/options dropped
}
The heuristic 'hash' in args[0] && !('method' in args[0]) is meant to detect a legacy url.parse() Url. But an options object built via { ...urlToHttpOptions(url), headers } also has hash (always present, even as "") and, for a GET, no method — so it matches. The branch then re-normalises using only legacyUrl.href, so the caller's headers (and rejectUnauthorized, etc.) never make it to the passthrough request. The object should instead fall through to the else if (isObject(args[0])) branch, which preserves them.
Proposed fix
Exclude objects that carry request options (a real legacy Url never has headers):
- else if ('hash' in args[0] && !('method' in args[0])) {
+ else if ('hash' in args[0] && !('method' in args[0]) && !('headers' in args[0])) {
With this, the options object falls through to the RequestOptions branch and headers are preserved; genuine legacy URLs (no headers) are still handled as before. Verified against the repro above and a real OTel + LaunchDarkly setup.
Repro
Full OpenTelemetry + LaunchDarkly reproduction (with the fix as a patchedDependencies patch you can toggle): https://github.com/nicki-moody/msw-otel-bug-repro — pnpm install && pnpm run repro. Reproduces on msw@2.15.0 / @mswjs/interceptors@0.41.9. The self-contained, no-dependencies snippet in the issue above shows the same header loss without any external services.
Happy to open a PR with the one-line fix if that's useful.
Summary
When http.request() / https.request() is called with a single an options object that contains URL fields like hash, href, and origin (as produced by Node's urlToHttpOptions()) and no method, @mswjs/interceptors misclassifies it as a legacy url.parse() URL and rebuilds the request from href alone — silently discarding headers and other request options on passthrough.
This reproduces with MSW alone, but is hit in the wild whenever OpenTelemetry's HTTP instrumentation is also active, because it normaliSes outgoing request(url, options) calls into exactly this shape. It broke the LaunchDarkly Node SDK for us: the SDK's streaming GET reached the server with no Authorisation header → 401. (Its analytics POST was unaffected — it carries a method, so it dodges the heuristic.)
Environment
@mswjs/interceptors: 0.40.0 — also confirmed on 0.41.9 (latest); the relevant code is unchanged.
Node: 24.13
(In production it surfaced via @opentelemetry/instrumentation-http + msw 2.x.)
Reproduction (MSW only — no OTel/SDK needed)
Actual: server received authorization: (none)
Expected: server received authorization: Bearer secret-key
For comparison, passing the URL as a string — nodeHttp.request(url.href, { headers: {...} }) — preserves the header; it's specifically the single-options-object form with URL fields that fails.
Root cause
In
src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.tsThe heuristic
'hash' in args[0] && !('method' in args[0])is meant to detect a legacy url.parse() Url. But an options object built via { ...urlToHttpOptions(url), headers } also has hash (always present, even as "") and, for a GET, no method — so it matches. The branch then re-normalises using only legacyUrl.href, so the caller's headers (and rejectUnauthorized, etc.) never make it to the passthrough request. The object should instead fall through to theelse if (isObject(args[0]))branch, which preserves them.Proposed fix
Exclude objects that carry request options (a real legacy Url never has headers):
With this, the options object falls through to the RequestOptions branch and headers are preserved; genuine legacy URLs (no headers) are still handled as before. Verified against the repro above and a real OTel + LaunchDarkly setup.
Repro
Full OpenTelemetry + LaunchDarkly reproduction (with the fix as a patchedDependencies patch you can toggle): https://github.com/nicki-moody/msw-otel-bug-repro — pnpm install && pnpm run repro. Reproduces on msw@2.15.0 / @mswjs/interceptors@0.41.9. The self-contained, no-dependencies snippet in the issue above shows the same header loss without any external services.
Happy to open a PR with the one-line fix if that's useful.