safe_pathname.js 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
  1. // @ts-check
  2. 'use strict'
  3. const path = require('path-browserify')
  4. /**
  5. * Regular expressions for Overleaf v2 taken from
  6. * https://github.com/overleaf/internal/blob/f7b287b6a07354000a6b463ca3a5828104e4a811/services/web/app/src/Features/Project/SafePath.js
  7. */
  8. //
  9. // Regex of characters that are invalid in filenames
  10. //
  11. // eslint-disable-next-line no-control-regex
  12. const BAD_CHAR_RX = /[/*\u0000-\u001F\u007F\u0080-\u009F\uD800-\uDFFF]/g
  13. //
  14. // Regex of filename patterns that are invalid ("." ".." and leading/trailing
  15. // whitespace)
  16. //
  17. const BAD_FILE_RX = /(^\.$)|(^\.\.$)|(^\s+)|(\s+$)/g
  18. //
  19. // Put a block on filenames which match javascript property names, as they
  20. // can cause exceptions where the code puts filenames into a hash. This is a
  21. // temporary workaround until the code in other places is made safe against
  22. // property names.
  23. //
  24. // See https://github.com/overleaf/write_latex/wiki/Using-javascript-Objects-as-Maps
  25. //
  26. const BLOCKED_FILE_RX =
  27. /^(prototype|constructor|toString|toLocaleString|valueOf|hasOwnProperty|isPrototypeOf|propertyIsEnumerable|__defineGetter__|__lookupGetter__|__defineSetter__|__lookupSetter__|__proto__)$/
  28. //
  29. // Maximum path length, in characters. This is fairly arbitrary.
  30. //
  31. const MAX_PATH = 1024
  32. /**
  33. * Replace invalid characters and filename patterns in a filename with
  34. * underscores.
  35. * @param {string} filename
  36. */
  37. function cleanPart(filename) {
  38. filename = filename.replace(BAD_CHAR_RX, '_')
  39. filename = filename.replace(BAD_FILE_RX, function (match) {
  40. return new Array(match.length + 1).join('_')
  41. })
  42. return filename
  43. }
  44. /**
  45. * All pathnames in a Snapshot must be clean. We want pathnames that:
  46. *
  47. * 1. are unambiguous (e.g. no `.`s or redundant path separators)
  48. * 2. do not allow directory traversal attacks (e.g. no `..`s or absolute paths)
  49. * 3. do not contain leading/trailing space
  50. * 4. do not contain the character '*' in filenames
  51. *
  52. * We normalise the pathname, split it by the separator and then clean each part
  53. * as a filename
  54. *
  55. * @param {string} pathname
  56. * @return {String}
  57. */
  58. exports.clean = function (pathname) {
  59. return exports.cleanDebug(pathname)[0]
  60. }
  61. /**
  62. * See clean
  63. * @param {string} pathname
  64. * @return {[string,string]}
  65. */
  66. exports.cleanDebug = function (pathname) {
  67. let prev = pathname
  68. let reason = ''
  69. /**
  70. * @param {string} label
  71. */
  72. function recordReasonIfChanged(label) {
  73. if (pathname === prev) return
  74. if (reason) reason += ','
  75. reason += label
  76. prev = pathname
  77. }
  78. pathname = path.normalize(pathname)
  79. recordReasonIfChanged('normalize')
  80. pathname = pathname.replace(/\\/g, '/')
  81. recordReasonIfChanged('workaround for IE')
  82. pathname = pathname.replace(/\/+/g, '/')
  83. recordReasonIfChanged('no multiple slashes')
  84. pathname = pathname.replace(/^(\/.*)$/, '_$1')
  85. recordReasonIfChanged('no leading /')
  86. pathname = pathname.replace(/^(.+)\/$/, '$1')
  87. recordReasonIfChanged('no trailing /')
  88. pathname = pathname.replace(/^ *(.*)$/, '$1')
  89. recordReasonIfChanged('no leading spaces')
  90. pathname = pathname.replace(/^(.*[^ ]) *$/, '$1')
  91. recordReasonIfChanged('no trailing spaces')
  92. if (pathname.length === 0) pathname = '_'
  93. recordReasonIfChanged('empty')
  94. pathname = pathname.split('/').map(cleanPart).join('/')
  95. recordReasonIfChanged('cleanPart')
  96. pathname = pathname.replace(BLOCKED_FILE_RX, '@$1')
  97. recordReasonIfChanged('BLOCKED_FILE_RX')
  98. return [pathname, reason]
  99. }
  100. /**
  101. * A pathname is clean (see clean) and not too long.
  102. *
  103. * @param {string} pathname
  104. * @return {Boolean}
  105. */
  106. exports.isClean = function pathnameIsClean(pathname) {
  107. return exports.isCleanDebug(pathname)[0]
  108. }
  109. /**
  110. * A pathname is clean (see clean) and not too long.
  111. *
  112. * @param {string} pathname
  113. * @return {[boolean,string]}
  114. */
  115. exports.isCleanDebug = function (pathname) {
  116. if (pathname.length > MAX_PATH) return [false, 'MAX_PATH']
  117. if (pathname.length === 0) return [false, 'empty']
  118. const [cleanPathname, reason] = exports.cleanDebug(pathname)
  119. if (cleanPathname !== pathname) return [false, reason]
  120. return [true, '']
  121. }