在 Node.js 的远古年代,内置了一个 url 模块,提供了一套 API。而随着 WHATWG 规范的成熟,node 在 7.0 的版本开始内置 WHATWG 版本的实现。这两个版本的实现细节有些不一致,而 node 社区已经基于老的 url 模块运行了多年,已经无法直接替换。所以 node 的策略是同时保留两个版本的实现,并在文档中将老的 url (legacy API)实现废弃。


legacy API 的安全隐患


由于兼容问题,node 并未直接移除 legacy API,同时 WHATWG 更严格的规范也导致提供的 API 用起来不如  legacy API 方便,所以仍然还有大量的开发者使用 legacy API。两套 API 尽管看起来提供一样的能力,但是底层对 url 规范的定义不一样,且实现也略有不同,legacy API 中隐藏了一些严重的安全风险


域名可以有特殊字符么?


DNS 服务并不限制域名的字符集,不管你使用任何奇怪的字符作为域名,只要能找到解析的记录,DNS 服务器就会返回。


image.png

特殊字符的 DNS 解析


为什么我们日常中很少见到这种特殊字符域名呢?因为各个浏览器自己都会在发起请求之前判断域名的合法性。然而不同的浏览器对这个合法域名的定义是不一样的,例如 safari 对域名的校验就不够严谨


image.png

safari 访问特殊字符的域名


更多细节可以参考 Advanced CORS Exploitation Techniques


legacy API 对特殊域名的处理


当遇到特殊域名的时候,legacy API 会如何处理呢?我们来测试一下:


url.parse('https://www.yuque.com!.evil.com')
Url {
  protocol: 'https:',
  slashes: true,
  auth: null,
  host: 'www.yuque.com',
  port: null,
  hostname: 'www.yuque.com',
  hash: null,
  search: null,
  query: null,
  pathname: '/!.evil.com',
  path: '/!.evil.com',
  href: 'https://www.yuque.com/!.evil.com'
}

url.parse('https://www.yuque.com%0a.evil.com')
Url {
  protocol: 'https:',
  slashes: true,
  auth: null,
  host: 'www.yuque.com',
  port: null,
  hostname: 'www.yuque.com',
  hash: null,
  search: null,
  query: null,
  pathname: '%0a.evil.com',
  path: '%0a.evil.com',
  href: 'https://www.yuque.com/%0a.evil.com' }


注意解析出的 href 字段,可以看到 legacy API 并不认为 ! 字符属于域名的一部分,所以原始的 www.yuque.com!.evil.com 域名其实是非法的,而为了不抛异常,legacy API 尝试对它进行了一次转换,把它当成 www.yuque.com/!.evil.com 来处理,解析出来 hostname 为 www.yuque.com。这带来了一个严重的安全风险:恶意用户可以通过这样的域名来绕过我们的一些安全校验,而这类域名还可以在主流浏览器(safari)中被用户访问到。


例如 CORS 的校验中,我们会根据用户传递的 Origin 字段来判断用户的请求来源是否属于白名单域名中:


const origin = ctx.get('origin') || '';
const parsedUrl = url.parse(origin);
if (isSafeDomain(parsedUrl.hostname)) return origin;
return '';


如果恶意攻击者注册了 www.yuque.com!.evil.com 域名,并引导用户在 safari 下去到此网站时,即可绕过浏览器的跨域限制被恶意网站窃取用户数据。


同样,有些情况下我们会通过 referer 来判断是否需要进行 CSRF 校验(例如对 JSONP 请求进行校验):


const referer = ctx.get('referer') || '';
const parsedUrl = url.parse(referer);
if (isSafeDomain(parsedUrl.hostname)) return referer;
return '';


为了防止重定向钓鱼(如登录成功后我们会把用户重定向到之前访问的页面),可能会校验重定向 url 的域名:


const goto = ctx.query.goto;
if (goto.startsWith('/')) return goto;
const parsedUrl = url.parse(goto);
if (parsedUrl.hostname === 'www.yuque.com') return goto;
return '/';


这些场景都由于 url legancy API 的兼容处理而会被恶意攻击者绕过。


真实项目中的安全隐患


在 github 上可以轻松搜到大量有这个安全风险的项目: https://github.com/search?q=cors+url+parse+hostname&type=Code,例如:



使用 WHATWG API


相较于 legacy API 而言,WHATWG API 的策略更加保守。它不会对传递的参数进行任何兼容和转换,例如同样传递两个特殊字符的 url 给 WHATWG API:


new URL('https://www.yuque.com!.evil.com')
URL {
  href: 'https://www.yuque.com!.evil.com/',
  origin: 'https://www.yuque.com!.evil.com',
  protocol: 'https:',
  username: '',
  password: '',
  host: 'www.yuque.com!.evil.com',
  hostname: 'www.yuque.com!.evil.com',
  port: '',
  pathname: '/',
  search: '',
  searchParams: URLSearchParams {},
  hash: '' }

new URL('https://www.yuque.com%0a.evil.com')
Thrown:
{ TypeError [ERR_INVALID_URL]: Invalid URL: https://www.yuque.com%0a.evil.com
    at onParseError (internal/url.js:241:17)
    at new URL (internal/url.js:319:5) input: 'https://www.yuque.com%0a.evil.com' }


可以看到 WHATWG API 对域名识别更加精准,切一旦遇到任何不符合规范的 URL 就会抛出异常,不会对其做猜测和兼容。当然一定程度上这使得 WHATWG API 使用起来更加繁琐(需要考虑异常问题),但是同时带来了更高的安全性。


替换 legacy API


在将问题反馈给 node 官方后,由于 url.parse() 已经被废弃,且修改影响面太大,所以 node 团队会加速推动它的废弃进程而不会对 API 进行修改


为了避免出现安全问题,推荐大家后面都开始使用 WHATWG API,并着手开始替换现有代码中的 legacy API。由于 WHATWG API 更加严格,所以替换过程中有一些需要注意的点:



image.png

Legacy vs WHATWG