The question
When one part of a system checks a URL and another part uses it, what exactly has been approved?
The original string is only the beginning. A parser gives that string structure: scheme, credentials, host, port, path and query. A later component may interpret or normalise the input differently. A comparison between raw text and a parsed destination can therefore answer a different question from the one the developer intended.
This note examines a narrow, reproducible example: two URL APIs provided by the same Node.js runtime. There is no remote target, no network traffic and no claim of a newly discovered vulnerability. The purpose is to make a potential source of disagreement concrete.
Test environment
The experiment ran locally using Node.js v24.22.1. The corpus consists of six fixed strings using example.com. No requests are made to that domain.
For each input, the test records host and pathname from:
new URL(input), the WHATWG-compatible API;parse(input)fromnode:url, the legacy API.
The legacy parser is included for comparison, not recommended for new security-sensitive code. Node.js documents it as a legacy API and warns about its security-sensitive parsing behaviour. Consult the versioned documentation for the runtime being assessed.
Reproduction
Download the complete experiment. Inspect it, then run:
node url-parsers.mjs
The script prints the runtime version, records that it makes zero network requests, and produces structured results. Assertions check the differences discussed below. An assertion failure means the recorded interpretation should be revisited for that runtime.
The core comparison is small:
import { parse } from 'node:url';
const input = 'https://example.com/%2e%2e/admin';
const modern = new URL(input);
const legacy = parse(input);
console.log(modern.pathname); // /admin
console.log(legacy.pathname); // /%2e%2e/admin
The legacy API may emit a deprecation warning. That warning is expected and does not invalidate the observed output.
Observed results
The following values were obtained in the stated local runtime. Paths are shown separately from hosts so that the source of disagreement remains visible.
Input suffix after https: | WHATWG host | Legacy host | WHATWG path | Legacy path |
|---|---|---|---|---|
//example.com/path | example.com | example.com | /path | /path |
//example.com/%2e%2e/admin | example.com | example.com | /admin | /%2e%2e/admin |
//example.com:443/a | example.com | example.com:443 | /a | /a |
//example.com/a/../b | example.com | example.com | /b | /a/../b |
example.com/path | example.com | null | /path | example.com/path |
//example.com/%2Fadmin | example.com | example.com | /%2Fadmin | /%2Fadmin |
Encoded dot segments
For /%2e%2e/admin, the WHATWG API returns /admin. The legacy parser preserves the encoded segment in its pathname.
This distinction matters when reviewing a system that checks a path representation before a downstream component normalises it. The experiment does not show that such a system exists or that an access control has been bypassed. It identifies the exact representation difference that would need to be traced through a real application.
Default ports
For an HTTPS URL containing :443, the WHATWG API returns example.com as host; the legacy API returns example.com:443.
The representations differ even though the port is the default for the scheme. A string inequality is not automatically a different security destination. Equally, a normalised match is not proof that an entire destination policy is correct.
Literal dot segments
The input /a/../b becomes /b in the WHATWG pathname, while the legacy pathname retains /a/../b.
This is a useful control alongside the encoded example. It separates a general normalisation difference from an explanation that depends only on percent-encoding.
An input without the familiar double slash
For https:example.com/path, the WHATWG constructor identifies example.com as the host. With the legacy parser’s default arguments, host is null and the pathname is example.com/path.
The familiar appearance of an input is not a sufficient description of its parse result. Parser choice and invocation are part of the evidence.
A useful non-difference
Both APIs preserve /%2Fadmin as the pathname in this corpus. It would be incorrect to conclude that every percent-encoded character is simply decoded during URL construction.
Negative controls are valuable: they limit the explanation and stop a real observation becoming an overbroad claim.
What the experiment establishes
The same runtime exposes APIs that can assign different structured representations to the same URL text. The differences are observable without an application, a proxy or a remote service.
The security consequence is conditional. A relevant finding would need to demonstrate a complete path from an attacker-controlled input, through a particular check, to a later action that violates the intended boundary. Parser disagreement alone does not establish that outcome.
In a real investigation, the next step would be to identify the exact check and consumer, preserve their inputs and outputs, and test whether a policy decision changes. A rejection, a harmless canonicalisation or a downstream re-check may prevent any security impact.
Limits and interpretation
This is a small corpus, not a fuzzing campaign. It covers one runtime version and two specific APIs with their default invocation patterns. It does not measure browser behaviour, HTTP-client destinations, DNS resolution, proxy normalisation or operating-system path handling.
The observations are consistent with a documented difference between the modern URL API and the legacy API. They are not presented as novel Node.js vulnerabilities, vendor disclosures or bounty-eligible results.
The general lesson is precise: when a policy depends on the meaning of an input, preserve the interpretation used for the decision and verify what the eventual consumer actually does. A familiar-looking string is not a substitute for that evidence.