HaveIBeenPwned.js 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
  1. /*
  2. This module is operating on raw user passwords. Be very defensive.
  3. Pay special attention when passing the password or even a hash/prefix around.
  4. We need to ensure that no parts of it get logged or returned on either the
  5. happy path or via an error (message or attributes).
  6. */
  7. const { callbackify } = require('util')
  8. const { fetchString } = require('@overleaf/fetch-utils')
  9. const crypto = require('crypto')
  10. const Settings = require('@overleaf/settings')
  11. const Metrics = require('@overleaf/metrics')
  12. const logger = require('@overleaf/logger')
  13. const HEX_CHARS_UPPER = '1234567890ABCDEF'
  14. const API_ERROR = new Error('cannot contact HaveIBeenPwned api')
  15. const INVALID_PREFIX = new Error(
  16. 'This is not a valid hex prefix. Rejecting to pass it to HaveIBeenPwned'
  17. )
  18. const INVALID_RESPONSE = new Error('cannot consume HaveIBeenPwned api response')
  19. const INVALID_SCORE = new Error(
  20. 'non integer score returned by HaveIBeenPwned api'
  21. )
  22. const CODED_ERROR_MESSAGES = [
  23. API_ERROR,
  24. INVALID_PREFIX,
  25. INVALID_RESPONSE,
  26. INVALID_SCORE,
  27. ].map(err => err.message)
  28. async function getScoresForPrefix(prefix) {
  29. if (
  30. typeof prefix !== 'string' ||
  31. prefix.length !== 5 ||
  32. Array.from(prefix).some(c => !HEX_CHARS_UPPER.includes(c))
  33. ) {
  34. // Make sure we do not pass arbitrary objects to the api.
  35. throw INVALID_PREFIX
  36. }
  37. try {
  38. return await fetchString(
  39. `${Settings.apis.haveIBeenPwned.url}/range/${prefix}`,
  40. {
  41. headers: {
  42. 'User-Agent': 'www.overleaf.com',
  43. // Docs: https://haveibeenpwned.com/API/v3#PwnedPasswordsPadding
  44. 'Add-Padding': true,
  45. },
  46. signal: AbortSignal.timeout(Settings.apis.haveIBeenPwned.timeout),
  47. }
  48. )
  49. } catch (_errorWithPotentialReferenceToPrefix) {
  50. // NOTE: Do not leak request details by passing the original error up.
  51. throw API_ERROR
  52. }
  53. }
  54. async function isPasswordReused(password) {
  55. const sha1 = crypto
  56. .createHash('sha1')
  57. .update(password)
  58. .digest('hex')
  59. .toUpperCase()
  60. const prefix = sha1.slice(0, 5)
  61. const body = await getScoresForPrefix(prefix)
  62. let score = 0
  63. try {
  64. for (const line of body.split('\r\n')) {
  65. const [candidate, scoreRaw] = line.split(':')
  66. if (prefix + candidate === sha1) {
  67. score = parseInt(scoreRaw)
  68. break
  69. }
  70. }
  71. } catch (_errorWithPotentialReferenceToHash) {
  72. // NOTE: Do not leak password details by logging the original error.
  73. throw INVALID_RESPONSE
  74. }
  75. if (Number.isNaN(score)) {
  76. // NOTE: Do not leak password details by logging the score.
  77. throw INVALID_SCORE
  78. }
  79. return score > 0
  80. }
  81. async function checkPasswordForReuse(password) {
  82. if (!Settings.apis.haveIBeenPwned.enabled) {
  83. return
  84. }
  85. try {
  86. const isReused = await isPasswordReused(password)
  87. Metrics.inc('password_re_use', {
  88. status: isReused ? 're-used' : 'unique',
  89. })
  90. return isReused
  91. } catch (err) {
  92. let error = err
  93. // Make sure we do not leak any password details.
  94. if (!CODED_ERROR_MESSAGES.includes(err.message)) {
  95. error = new Error('hidden message')
  96. }
  97. error = new Error(error.message)
  98. Metrics.inc('password_re_use', { status: 'failure' })
  99. throw error
  100. }
  101. }
  102. function checkPasswordForReuseInBackground(password) {
  103. checkPasswordForReuse(password).catch(error => {
  104. logger.err({ error }, 'cannot check password for re-use')
  105. })
  106. }
  107. module.exports = {
  108. checkPasswordForReuse: callbackify(checkPasswordForReuse),
  109. checkPasswordForReuseInBackground,
  110. promises: {
  111. checkPasswordForReuse,
  112. },
  113. }