new URL(path, base) throws away the last segment of your base
published
TL;DR
new URL('users', 'https://api.example.com/v1') is https://api.example.com/users. Relative resolution keeps the base path only up to its last /, so v1 is dropped. A base that ends in / keeps it, but a path that starts with / discards the whole base path, and one that starts with // or \\ replaces the host. If any part of the path comes from input, normalise the base, strip leading slashes, and check that the parsed result still sits under the base before you send a request to it.
The problem
An API client with a versioned base URL:
const API_BASE = 'https://api.example.com/v1';
async function getUser(id) {
const res = await fetch(new URL(`users/${id}`, API_BASE));
return res.json();
}
Every request goes to https://api.example.com/users/42. Nothing throws. The server answers 404, or an old unversioned route answers 200 with a different response shape. Moving the slash to the “obvious” place does not help either:
new URL('/users', 'https://api.example.com/v1/').href;
// 'https://api.example.com/users'
Why it happens
new URL(input, base) does not concatenate. It resolves input the way a browser resolves a link on a page, and both specs that define that agree: a relative path replaces the last segment of the base path. RFC 3986 §5.2.3 calls it merging, and returns “the reference’s path component appended to all but the last segment of the base URI’s path”. The WHATWG URL parser, which Node and browsers implement, does it in two steps: its relative state sets the path “to a clone of base’s path”, then shortens it, which means “Remove path’s last item, if any.”
That is the right rule for documents. On https://example.com/docs/intro.html, a link to setup.html should open /docs/setup.html, not /docs/intro.html/setup.html. A base URL without a trailing slash looks like a file, and v1 is the file.
Results on Node.js v26.2.0:
| Input | Base | Result |
|---|---|---|
users | https://api.example.com/v1 | https://api.example.com/users |
./users | https://api.example.com/v1 | https://api.example.com/users |
users | https://api.example.com/v1/ | https://api.example.com/v1/users |
/users | https://api.example.com/v1/ | https://api.example.com/users |
users/42 | https://api.example.com/v1/accounts | https://api.example.com/v1/users/42 |
?page=2 | https://api.example.com/v1/users | https://api.example.com/v1/users?page=2 |
| empty string | https://api.example.com/v1/users?x=1#a | https://api.example.com/v1/users?x=1 |
Two rows deserve a second look. A query-only input keeps the whole base path, last segment included, so ?page=2 behaves the way people expect while users does not. And an input that is entirely empty resolves to the base itself minus its fragment, so it sends the request to the base URL instead of failing.
The base’s query string is never inherited by a path: new URL('users', 'https://api.example.com/v1/?key=abc') is https://api.example.com/v1/users, with the key gone. An API key in the base URL’s query does not survive any of these joins.
Where it stops being a typo
With fixed paths, the trailing slash is a correctness bug. The same rules become a security problem once part of the path comes from a user, a webhook payload or another service. These are the string values, resolved against https://api.example.com/v1/:
| Input | Result |
|---|---|
//evil.example/x | https://evil.example/x |
\\evil.example/x | https://evil.example/x |
/\evil.example/x | https://evil.example/x |
https://evil.example/ | https://evil.example/ |
../admin | https://api.example.com/admin |
%2e%2e/admin | https://api.example.com/admin |
.%2E/admin | https://api.example.com/admin |
- A leading
//keeps the scheme and replaces the host. It is a scheme-relative reference; RFC 3986’s own examples resolve//gagainsthttp://a/b/c/d;p?qtohttp://g. - A backslash counts as a slash. For special schemes such as
http,httpsandfile, the URL Standard’s relative slash state applies “If url is special and c is U+002F (/) or U+005C ()”. A filter that only looks for//misses\\. - Percent-encoded dots still climb. The standard defines a double-dot segment as ”..” or an ASCII case-insensitive match for ”.%2e”, “%2e.” or “%2e%2e”. A check for the literal
..misses three of the four spellings. - An absolute URL ignores the base entirely.
On a server, the host change is a request-forgery primitive. Your process makes the request, and if your client attaches credentials to every request it builds, they go to whatever host the input named.
encodeURIComponent does not close this. It encodes /, ? and #, so an encoded value cannot add segments, a query or a fragment, but . is unreserved and passes through unchanged:
encodeURIComponent('..'); // '..'
new URL(`users/${encodeURIComponent('..')}`, 'https://api.example.com/v1/').href;
// 'https://api.example.com/v1/'
An ID of .. turns GET /v1/users/:id into GET /v1/.
What to do
1. Resolve under the base, then check the result. Normalise the base to a directory, strip leading slashes and backslashes from the path so it cannot name a host, and verify the parsed URL rather than the input string:
function joinUnder(base, path) {
const root = new URL(base);
if (!root.pathname.endsWith('/')) root.pathname += '/';
root.search = '';
root.hash = '';
const url = new URL(path.replace(/^[/\\]+/, ''), root);
if (url.origin !== root.origin || !url.pathname.startsWith(root.pathname)) {
throw new RangeError(`${JSON.stringify(path)} resolves outside ${root.href}`);
}
return url;
}
joinUnder('https://api.example.com/v1', 'users').href;
// 'https://api.example.com/v1/users'
joinUnder('https://api.example.com/v1', '/users').href;
// 'https://api.example.com/v1/users'
joinUnder('https://api.example.com/v1', 'users/42?expand=1').href;
// 'https://api.example.com/v1/users/42?expand=1'
joinUnder('https://api.example.com/v1', '//evil.example/x').href;
// 'https://api.example.com/v1/evil.example/x'
joinUnder('https://api.example.com/v1', '../admin');
// RangeError: "../admin" resolves outside https://api.example.com/v1/
joinUnder('https://api.example.com/v1', '%2e%2e/admin');
// RangeError: "%2e%2e/admin" resolves outside https://api.example.com/v1/
joinUnder('https://api.example.com/v1', 'https://evil.example/');
// RangeError: "https://evil.example/" resolves outside https://api.example.com/v1/
Checking the output is what makes this short. By the time url exists, the parser has already applied .., %2e%2e, backslashes and absolute URLs, so there is no list of dot-segment spellings to keep up to date. It does not cover what the server decodes later: users%2f..%2fadmin stays inside /v1/ here, and a server that decodes %2f before routing would still traverse, which is why step 2 encodes values with segment(). A ? or # in the input also gets through, by design, as the expand=1 case shows. Because root.pathname always ends in /, the prefix test cannot be fooled by a sibling such as /v10/. A javascript: or mailto: input fails the origin test too, since its origin is the string "null".
2. Encode values as single segments and reject the dot segments. joinUnder keeps a request inside /v1/, but users/.. still resolves to /v1/ itself, which is inside the prefix and the wrong endpoint. Encode each value as one segment, and refuse the three values that encodeURIComponent leaves meaningful:
function segment(value) {
const s = String(value);
if (s === '' || s === '.' || s === '..') {
throw new RangeError(`${JSON.stringify(s)} is not a valid path segment`);
}
return encodeURIComponent(s);
}
joinUnder(API_BASE, `users/${segment(id)}`);
segment('a/b') gives a%2Fb, so a slash in the value stays inside one segment. segment('%2e%2e') gives %252e%252e: the percent sign itself is encoded, so the parser sees no dot segment. segment('..') throws.
3. Or do not resolve at all. If every path is a fixed string in your own code, plain concatenation of a base without a trailing slash and paths that start with one does exactly what it looks like: ${API_BASE}/users is https://api.example.com/v1/users. The trouble starts when the two conventions mix. A base with a trailing slash plus a path with a leading slash gives https://api.example.com/v1//users, a different path that a router may not match.
Caveats
- In a browser, relative URLs resolve against the page.
fetch('users')uses the document’s base URL and follows the same last-segment rule, so on/app/settingsit requests/app/users. Node has no document:new URL('users')throwsTypeError: Invalid URLandfetch('users')rejects withTypeError: Failed to parse URL from users. URL.parse()changes the failure mode, not the resolution. It returnsnullinstead of throwing on input it cannot parse, butURL.parse('users', 'https://api.example.com/v1')is stillhttps://api.example.com/users.file:URLs follow the same rule.new URL('users', 'file:///srv/app/config')isfile:///srv/app/users. For the filesystem version of the same escape, see the path.resolve post.- The origin check is not link sanitisation. If you build
hrefvalues for a browser from input, allow-list the protocol separately; that is a different problem from keeping an API request on its host. - Output quoted here is from Node.js v26.2.0, whose
URLimplements the WHATWG URL Standard, the same specification browsers follow.