security.test.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431
  1. 'use strict'
  2. const test = require('tape')
  3. const fastURI = require('..')
  4. test('parse marks malformed authority and port inputs as errors', (t) => {
  5. const malformedCases = [
  6. {
  7. input: 'http://[::1]foo',
  8. expectedError: 'URI path must start with "/" when authority is present.'
  9. },
  10. {
  11. input: 'http://[::1]:80abc/path',
  12. expectedError: 'URI path must start with "/" when authority is present.'
  13. },
  14. {
  15. input: 'http://example.com:80abc/path',
  16. expectedError: 'URI path must start with "/" when authority is present.'
  17. },
  18. {
  19. input: 'http://[::1]:65536',
  20. expectedError: 'URI port is malformed.'
  21. }
  22. ]
  23. t.plan(malformedCases.length)
  24. malformedCases.forEach(({ input, expectedError }) => {
  25. t.equal(fastURI.parse(input).error, expectedError, input)
  26. })
  27. })
  28. test('normalize does not canonicalize malformed URLs into different valid URLs', (t) => {
  29. const malformedCases = [
  30. 'http://[::1]foo',
  31. 'http://[::1]:80abc/path',
  32. 'http://example.com:80abc/path',
  33. 'http://[::1]:65536'
  34. ]
  35. t.plan(malformedCases.length)
  36. malformedCases.forEach((input) => {
  37. t.equal(fastURI.normalize(input), input, input)
  38. })
  39. })
  40. test('equal returns false when either side is malformed', (t) => {
  41. const malformedPairs = [
  42. ['http://[::1]foo', 'http://[::1]/foo'],
  43. ['http://[::1]:80abc/path', 'http://[::1]/abc/path'],
  44. ['http://example.com:80abc/path', 'http://example.com/abc/path'],
  45. ['http://[::1]:65536', 'http://[::1]:65536/']
  46. ]
  47. t.plan(malformedPairs.length)
  48. malformedPairs.forEach(([left, right]) => {
  49. t.equal(fastURI.equal(left, right), false, `${left} != ${right}`)
  50. })
  51. })
  52. test('normalize preserves encoded authority delimiters in host', (t) => {
  53. const cases = [
  54. ['http://trusted.com%40evil.com/', 'http://trusted.com%40evil.com/'],
  55. ['http://example.com%3A8080/', 'http://example.com%3A8080/'],
  56. ['http://example.com%2Fevil.com/path', 'http://example.com%2Fevil.com/path'],
  57. ['http://example.com%23fragment/path', 'http://example.com%23fragment/path'],
  58. ['http://example.com%3Fq=evil/path', 'http://example.com%3Fq=evil/path'],
  59. ['http://user%3Apass%40evil.com/', 'http://user%3Apass%40evil.com/'],
  60. ['http://user@trusted.com%40evil.com/', 'http://user@trusted.com%40evil.com/'],
  61. ['https://trusted.com%40evil.com/', 'https://trusted.com%40evil.com/'],
  62. ['ws://trusted.com%40evil.com/chat', 'ws://trusted.com%40evil.com/chat'],
  63. ['wss://trusted.com%40evil.com/chat', 'wss://trusted.com%40evil.com/chat']
  64. ]
  65. t.plan(cases.length)
  66. cases.forEach(([input, expected]) => {
  67. t.equal(fastURI.normalize(input), expected, input)
  68. })
  69. })
  70. test('parse preserves encoded authority delimiters in host', (t) => {
  71. const cases = [
  72. ['http://trusted.com%40evil.com/', 'trusted.com%40evil.com'],
  73. ['http://example.com%3A8080/', 'example.com%3A8080'],
  74. ['http://user%3Apass%40evil.com/', 'user%3Apass%40evil.com']
  75. ]
  76. t.plan(cases.length)
  77. cases.forEach(([input, expectedHost]) => {
  78. t.equal(fastURI.parse(input).host, expectedHost, input)
  79. })
  80. })
  81. test('equal returns false when encoded delimiters differ from live delimiters', (t) => {
  82. const pairs = [
  83. ['http://trusted.com%40evil.com/', 'http://trusted.com@evil.com/'],
  84. ['http://example.com%3A8080/', 'http://example.com:8080/']
  85. ]
  86. t.plan(pairs.length)
  87. pairs.forEach(([left, right]) => {
  88. t.equal(fastURI.equal(left, right, {}), false, `${left} != ${right}`)
  89. })
  90. })
  91. test('resolve preserves encoded authority delimiters', (t) => {
  92. const result = fastURI.resolve('http://base.com/', '//trusted.com%40evil.com/path')
  93. const parsed = fastURI.parse(result)
  94. t.plan(1)
  95. t.notEqual(parsed.host, 'evil.com', '//trusted.com%40evil.com/path')
  96. })
  97. test('serialize escapes authority delimiters in host field', (t) => {
  98. const result = fastURI.serialize({ scheme: 'http', host: 'trusted.com@evil.com', path: '/' })
  99. const parsed = fastURI.parse(result)
  100. t.plan(1)
  101. t.notEqual(parsed.host, 'evil.com', 'host: trusted.com@evil.com')
  102. })
  103. test('normalize does not double-decode %2540 into a live @', (t) => {
  104. const input = 'http://trusted.com%2540evil.com/'
  105. const result = fastURI.normalize(input)
  106. const parsed = fastURI.parse(result)
  107. t.plan(2)
  108. t.equal(result, input, 'the encoded percent sign is preserved')
  109. t.notEqual(parsed.host, 'trusted.com@evil.com', input)
  110. })
  111. test('parse canonicalises IDN / Unicode hosts to their ASCII form', (t) => {
  112. const cases = [
  113. {
  114. input: 'http://127。0。0。1/',
  115. expectedHost: '127.0.0.1',
  116. description: 'full-width ideographic stops as octet separators'
  117. },
  118. {
  119. input: 'http://example.com/',
  120. expectedHost: 'example.com',
  121. description: 'fullwidth e as first letter'
  122. },
  123. {
  124. input: 'http://納豆.example.org/',
  125. expectedHost: 'xn--99zt52a.example.org',
  126. description: 'CJK label requiring punycode'
  127. }
  128. ]
  129. t.plan(cases.length * 2)
  130. cases.forEach(({ input, expectedHost, description }) => {
  131. const parsed = fastURI.parse(input)
  132. t.notOk(parsed.error, `parse should not set error: ${description}`)
  133. t.equal(parsed.host, expectedHost, `host canonicalised to ASCII: ${description}`)
  134. })
  135. })
  136. test('resolve canonicalises the host using the final resolved scheme', (t) => {
  137. const cases = [
  138. {
  139. base: 'http://trusted.example/base',
  140. relative: '//127\u30020\u30020\u30021/private',
  141. expected: 'http://127.0.0.1/private',
  142. expectedHost: '127.0.0.1',
  143. description: 'scheme-relative loopback host'
  144. },
  145. {
  146. base: 'https://ex\u00ADample.com/base',
  147. relative: 'child',
  148. expected: 'https://example.com/child',
  149. expectedHost: 'example.com',
  150. description: 'host inherited from the base'
  151. },
  152. {
  153. base: 'http://trusted.example/base',
  154. relative: 'http://ex\u200Bample.com/',
  155. expected: 'http://example.com/',
  156. expectedHost: 'example.com',
  157. description: 'absolute relative reference'
  158. }
  159. ]
  160. t.plan(cases.length * 2)
  161. cases.forEach(({ base, relative, expected, expectedHost, description }) => {
  162. const resolved = fastURI.resolve(base, relative)
  163. t.equal(resolved, expected, description)
  164. t.equal(fastURI.parse(resolved).host, expectedHost, `${description} reparses consistently`)
  165. })
  166. })
  167. test('resolve applies domain canonicalisation only when the effective scheme opts in', (t) => {
  168. const host = 'ex\u00ADample.com'
  169. t.plan(2)
  170. t.equal(
  171. fastURI.resolve('uri://trusted.example/', `//${host}/`),
  172. `uri://${host}/`,
  173. 'an unsupported scheme preserves the host'
  174. )
  175. t.equal(
  176. fastURI.resolve('http://trusted.example/', `//${host}/`, { unicodeSupport: true }),
  177. `http://${host}/`,
  178. 'unicodeSupport preserves the Unicode host'
  179. )
  180. })
  181. test('resolve throws when the final scheme cannot canonicalise the host', (t) => {
  182. const invalidHost = '\u200D.example'
  183. const cases = [
  184. ['http://trusted.example/', `//${invalidHost}/`],
  185. [`https://${invalidHost}/base`, 'child'],
  186. ['http://trusted.example/', `http://${invalidHost}/`],
  187. ['http://trusted.example/', `//${invalidHost}%2Etest/`]
  188. ]
  189. t.plan(cases.length)
  190. cases.forEach(([base, relative]) => {
  191. t.throws(
  192. () => fastURI.resolve(base, relative),
  193. /Host's domain name can not be converted to ASCII/,
  194. `${base} + ${relative}`
  195. )
  196. })
  197. })
  198. test('parse rejects a literal backslash in the authority as malformed (RFC 3986)', (t) => {
  199. // Regression for the host-confusion bypass: a literal "\" is invalid RFC 3986
  200. // syntax and must be flagged malformed, not silently rewritten. Otherwise "\"
  201. // acts as a host delimiter here while Node's native URL parses a different
  202. // host, defeating a host-based SSRF/redirect/origin allowlist.
  203. const cases = [
  204. 'http://evil.com\\@allowed.com',
  205. 'https://169.254.169.254\\@trusted.example.com',
  206. 'http://127.0.0.1\\@public.example.com',
  207. 'https://attacker.com\\@api.internal',
  208. 'http://a\\@b',
  209. 'ws://evil.com\\@allowed.com/chat',
  210. 'wss://evil.com\\@allowed.com/chat',
  211. 'http://evil.com\\%40allowed.com',
  212. '//evil.com\\@allowed.com'
  213. ]
  214. t.plan(cases.length)
  215. cases.forEach((input) => {
  216. t.equal(
  217. fastURI.parse(input).error,
  218. 'URI authority must not contain a literal backslash.',
  219. input
  220. )
  221. })
  222. })
  223. test('normalize does not canonicalize a literal-backslash URI into a different valid URL', (t) => {
  224. const cases = [
  225. 'http://evil.com\\@allowed.com',
  226. 'https://attacker.com\\@api.internal'
  227. ]
  228. t.plan(cases.length)
  229. cases.forEach((input) => {
  230. t.equal(fastURI.normalize(input), input, input)
  231. })
  232. })
  233. test('parse leaves percent-encoded %5C untouched as encoded data (not rejected)', (t) => {
  234. // Only the literal "\" byte is rejected; %5C stays valid encoded data and
  235. // does not diverge from the native URL parser, so it must not be flagged.
  236. const input = 'http://evil.com%5C@allowed.com'
  237. const parsed = fastURI.parse(input)
  238. t.plan(2)
  239. t.notOk(parsed.error, '%5C is valid encoded data, not malformed')
  240. t.equal(parsed.host, new URL(input).hostname, '%5C host matches native URL (no divergence)')
  241. })
  242. test('parse does not reject a literal backslash in the query or fragment', (t) => {
  243. // The rejection is scoped to the authority/path (the host-confusion surface);
  244. // a backslash after "?"/"#" is normalized as encoded data as before.
  245. const parsed = fastURI.parse('http://host.example.com/?x=\\y#z\\w')
  246. t.plan(2)
  247. t.notOk(parsed.error, 'backslash in query/fragment does not mark the URI malformed')
  248. t.equal(parsed.host, 'host.example.com', 'host parsed normally')
  249. })
  250. test('parse rejects a malformed authority introducer (\\\\, /\\, \\/) in place of //', (t) => {
  251. // Regression: "\\", "/\\", "\\/" after the scheme colon are not valid authority
  252. // introducers. Node's URL treats "\\" as interchangeable with "/" on special
  253. // schemes, so "http:\\\\evil.com/path" would be parsed as host "evil.com" by
  254. // Node, but fast-uri must reject it as malformed to prevent SSRF/redirect bypass.
  255. const cases = [
  256. 'http:\\\\evil.com/path',
  257. 'http:/\\evil.com/path',
  258. 'http:\\/evil.com/path',
  259. 'ws:\\\\evil.com/chat',
  260. 'wss:\\\\evil.com/chat',
  261. 'ftp:\\\\evil.com/',
  262. '\\\\evil.com/path'
  263. ]
  264. t.plan(cases.length)
  265. cases.forEach((input) => {
  266. t.equal(
  267. fastURI.parse(input).error,
  268. 'URI authority must not contain a literal backslash.',
  269. input
  270. )
  271. })
  272. })
  273. test('normalize does not canonicalize a malformed-authority-introducer URI', (t) => {
  274. const cases = [
  275. 'http:\\\\evil.com/path',
  276. 'http:/\\evil.com/path'
  277. ]
  278. t.plan(cases.length)
  279. cases.forEach((input) => {
  280. t.equal(fastURI.normalize(input), input, input)
  281. })
  282. })
  283. test('equal returns false for malformed-authority-introducer URIs', (t) => {
  284. const pairs = [
  285. ['http:\\\\evil.com/path', 'http://evil.com/path'],
  286. ['http:/\\evil.com/path', 'http://evil.com/path']
  287. ]
  288. t.plan(pairs.length)
  289. pairs.forEach(([left, right]) => {
  290. t.equal(fastURI.equal(left, right), false, `${left} != ${right}`)
  291. })
  292. })
  293. test('resolve throws on malformed authority introducer', (t) => {
  294. // resolve() returns a plain string with no error field, so the only safe
  295. // behavior is to throw when either component has a malformed authority.
  296. const pairs = [
  297. ['https://allowed.com/', '\\\\evil.com/path'],
  298. ['\\\\evil.com/path', 'https://allowed.com/'],
  299. ['https://allowed.com/', 'http:/\\evil.com/path'],
  300. ['https://allowed.com/', 'http:\\/evil.com/path']
  301. ]
  302. t.plan(pairs.length)
  303. pairs.forEach(([base, rel]) => {
  304. t.throws(
  305. () => fastURI.resolve(base, rel),
  306. /URI authority must not contain a literal backslash/,
  307. `${base} + ${rel}`
  308. )
  309. })
  310. })
  311. test('parse rejects a whitespace-split authority introducer (TAB, LF, CR)', (t) => {
  312. // The WHATWG URL parser removes TAB (U+0009), LF (U+000A) and CR (U+000D) from
  313. // the input before parsing, so a stripped character wedged into the introducer
  314. // ("/<TAB>\\", "/<TAB>/", or a leading "<TAB>//") reaches an authority in Node
  315. // while fast-uri would otherwise fold it into the path. These must be rejected
  316. // like the adjacent "\\", "/\\", "\\/" forms.
  317. const cases = [
  318. { input: '/\t\\evil.com/path', expectedError: 'URI authority must not contain a literal backslash.' },
  319. { input: '/\t/evil.com/path', expectedError: 'URI authority introducer must not contain whitespace.' },
  320. { input: '/\n\\evil.com/path', expectedError: 'URI authority must not contain a literal backslash.' },
  321. { input: '/\r\\evil.com/path', expectedError: 'URI authority must not contain a literal backslash.' },
  322. { input: '\t//evil.com/path', expectedError: 'URI authority introducer must not contain whitespace.' },
  323. { input: '\t/\\evil.com/path', expectedError: 'URI authority must not contain a literal backslash.' },
  324. { input: 'https:/\t/evil.com/path', expectedError: 'URI authority introducer must not contain whitespace.' }
  325. ]
  326. t.plan(cases.length)
  327. cases.forEach(({ input, expectedError }) => {
  328. t.equal(fastURI.parse(input).error, expectedError, JSON.stringify(input))
  329. })
  330. })
  331. test('resolve throws on a whitespace-split authority introducer', (t) => {
  332. const pairs = [
  333. ['https://allowed.com/', '/\t\\evil.com/path'],
  334. ['https://allowed.com/', '/\t/evil.com/path'],
  335. ['https://allowed.com/', '/\n\\evil.com/path'],
  336. ['/\t/evil.com/path', 'https://allowed.com/']
  337. ]
  338. t.plan(pairs.length)
  339. pairs.forEach(([base, rel]) => {
  340. t.throws(
  341. () => fastURI.resolve(base, rel),
  342. /URI authority (must not contain a literal backslash|introducer must not contain whitespace)/,
  343. `${JSON.stringify(base)} + ${JSON.stringify(rel)}`
  344. )
  345. })
  346. })
  347. test('parse does not reject valid authority introducer patterns', (t) => {
  348. // No false positives: "//" introducer and scheme-less "//" must be valid.
  349. const cases = [
  350. 'http://good.com/',
  351. 'https://good.com/',
  352. 'ws://good.com/chat',
  353. 'wss://good.com/chat',
  354. 'ftp://good.com/',
  355. '//good.com/path',
  356. '/absolute/path',
  357. 'relative/path'
  358. ]
  359. t.plan(cases.length)
  360. cases.forEach((input) => {
  361. const parsed = fastURI.parse(input)
  362. t.notOk(parsed.error, input)
  363. })
  364. })