HaveIBeenPwned.js 3.3 KB

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