security-normalization.test.js 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. 'use strict'
  2. const test = require('tape')
  3. const fastURI = require('..')
  4. test('parse preserves reserved path escapes as data', (t) => {
  5. const components = fastURI.parse('http://example.com/a%2Fb/public/%2e%2e/admin')
  6. t.equal(components.path, '/a%2Fb/public/%2E%2E/admin')
  7. t.end()
  8. })
  9. test('normalize preserves percent-encoded path separators and dot segments', (t) => {
  10. t.equal(
  11. fastURI.normalize('http://example.com/public/%2e%2e/admin'),
  12. 'http://example.com/public/%2E%2E/admin'
  13. )
  14. t.equal(
  15. fastURI.normalize('http://example.com/a%2Fb'),
  16. 'http://example.com/a%2Fb'
  17. )
  18. t.end()
  19. })
  20. test('equal does not treat reserved path escapes as live path syntax', (t) => {
  21. t.equal(
  22. fastURI.equal('http://example.com/public/%2e%2e/admin', 'http://example.com/admin', {}),
  23. false
  24. )
  25. t.equal(
  26. fastURI.equal('http://example.com/a%2Fb', 'http://example.com/a/b', {}),
  27. false
  28. )
  29. t.end()
  30. })
  31. test('serialize preserves literal RFC 3986 reserved path characters', (t) => {
  32. // GHSA-7mh8-fcmq-x23c: literal reserved path chars must not be rewritten to
  33. // percent escapes by the escape()-style safe set.
  34. const cases = [
  35. 'http://example.com/a;b',
  36. 'http://example.com/a=b',
  37. 'http://example.com/a&b',
  38. 'http://example.com/a$b',
  39. 'http://example.com/a:b',
  40. 'http://example.com/a@b'
  41. ]
  42. t.plan(cases.length)
  43. cases.forEach((uri) => {
  44. t.equal(fastURI.serialize(fastURI.parse(uri)), uri, uri)
  45. })
  46. t.end()
  47. })
  48. test('serialize preserves existing reserved path escapes as data', (t) => {
  49. // GHSA-7mh8-fcmq-x23c: an existing %3A escape must stay %3A instead of being
  50. // rewritten into a live colon.
  51. const cases = [
  52. 'http://example.com/a%3Ab',
  53. 'http://example.com/a%3Bb',
  54. 'http://example.com/a%3D%3D'
  55. ]
  56. t.plan(cases.length)
  57. cases.forEach((uri) => {
  58. t.equal(fastURI.serialize(fastURI.parse(uri)), uri, uri)
  59. })
  60. t.end()
  61. })
  62. test('serialize keeps path-noscheme colon escaping', (t) => {
  63. // Without a scheme a literal colon would be reparsed as a scheme separator,
  64. // so it must stay percent-escaped while an existing %3A is preserved.
  65. t.equal(fastURI.serialize({ path: 'foo:bar' }), 'foo%3Abar')
  66. t.equal(fastURI.serialize({ path: 'a%3Ab' }), 'a%3Ab')
  67. t.end()
  68. })
  69. test('hostname normalization never decodes an escape more than once', (t) => {
  70. const encodedLocalhost = 'http://%256c%256f%2563%2561%256c%2568%256f%2573%2574/'
  71. const encodedLoopback = '//127%252e0%252e0%252e1/private'
  72. const encodedMetadataAddress = '//169%252E254%252E169%252E254/latest/meta-data/'
  73. t.equal(fastURI.normalize(encodedLocalhost), encodedLocalhost, 'nested hostname letters remain encoded')
  74. t.equal(fastURI.normalize(encodedLoopback), encodedLoopback, 'nested IPv4 dots remain encoded')
  75. t.equal(
  76. fastURI.resolve('https://safe.example/', encodedLoopback),
  77. 'https://127%252e0%252e0%252e1/private',
  78. 'resolve does not turn nested dots into a loopback address'
  79. )
  80. t.equal(
  81. fastURI.resolve('https://allowed.com/api/v1/', encodedMetadataAddress),
  82. 'https://169%252e254%252e169%252e254/latest/meta-data/',
  83. 'resolve does not turn nested dots into a metadata address'
  84. )
  85. t.equal(
  86. fastURI.normalize('http://allowed.com%255Cevil.com/'),
  87. 'http://allowed.com%255Cevil.com/',
  88. 'normalize does not activate a nested backslash'
  89. )
  90. t.equal(
  91. fastURI.serialize({ scheme: 'http', host: '%256cocalhost', path: '/' }),
  92. 'http://%256cocalhost/',
  93. 'component serialization preserves an encoded percent sign'
  94. )
  95. t.equal(
  96. fastURI.equal(encodedLocalhost, 'http://localhost/', {}),
  97. false,
  98. 'nested escapes do not compare equal to their twice-decoded target'
  99. )
  100. t.end()
  101. })
  102. test('hostname normalization decodes only current unreserved escapes', (t) => {
  103. t.equal(fastURI.normalize('x://%6cocalhost/'), 'x://localhost/', 'a current unreserved escape is decoded')
  104. t.equal(fastURI.normalize('x://%256cocalhost/'), 'x://%256cocalhost/', 'an encoded percent is preserved')
  105. t.equal(fastURI.normalize('x://host%2540evil/'), 'x://host%2540evil/', 'a nested authority delimiter stays inert')
  106. t.equal(fastURI.normalize('x://%2525/'), 'x://%2525/', 'nested encoded percent signs stay encoded')
  107. t.end()
  108. })
  109. test('host conversion failures are not treated as comparable URLs', (t) => {
  110. const malformedHost = 'http://trusted.test%2540evil.test/'
  111. t.equal(fastURI.normalize(malformedHost), malformedHost, 'normalization preserves the failing input')
  112. t.equal(fastURI.equal(malformedHost, malformedHost, {}), false, 'equal rejects a failed host conversion')
  113. t.throws(
  114. () => fastURI.resolve(malformedHost, 'child', { domainHost: true }),
  115. /Host's domain name can not be converted to ASCII/,
  116. 'resolve propagates a host conversion failure'
  117. )
  118. t.end()
  119. })