CustomSessionStore.mjs 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. import session from 'express-session'
  2. import RedisStoreFactory from 'connect-redis'
  3. import logger from '@overleaf/logger'
  4. import Settings from '@overleaf/settings'
  5. import SessionManager from '../Features/Authentication/SessionManager.js'
  6. import Metrics from '@overleaf/metrics'
  7. const RedisStore = RedisStoreFactory(session)
  8. const MAX_SESSION_SIZE_THRESHOLD = 4096
  9. // Define a custom session store to record session metrics and log large
  10. // anonymous sessions for debugging purposes
  11. // Also make the SET calls more robust/consistent by adding flags
  12. // - XX: ensure update in place, expect that the old session value is still in redis at that key
  13. // - NX: ensure initial set, expect that there is no other session at that key already
  14. class CustomSessionStore extends RedisStore {
  15. static largestSessionSize = 3 * 1024 // ignore sessions smaller than 3KB
  16. #initialSetStore
  17. #updateInPlaceStore
  18. constructor({ client }) {
  19. super({ client })
  20. this.#initialSetStore = new RedisStore({
  21. client: new CustomSetRedisClient(client, 'NX'),
  22. })
  23. this.#updateInPlaceStore = new RedisStore({
  24. client: new CustomSetRedisClient(client, 'XX'),
  25. })
  26. }
  27. static metric(method, sess) {
  28. let type // type of session: 'logged-in', 'anonymous', or 'na' (not available)
  29. if (sess) {
  30. type = SessionManager.isUserLoggedIn(sess) ? 'logged-in' : 'anonymous'
  31. } else {
  32. type = 'na'
  33. }
  34. const size = sess ? JSON.stringify(sess).length : 0
  35. // record the number of redis session operations
  36. Metrics.inc('session.store.count', 1, {
  37. method,
  38. type,
  39. status: size > MAX_SESSION_SIZE_THRESHOLD ? 'oversize' : 'normal',
  40. })
  41. // record the redis session bandwidth for get/set operations
  42. if (method === 'get' || method === 'set') {
  43. Metrics.count('session.store.bytes', size, { method, type })
  44. }
  45. // log the largest anonymous session seen so far
  46. if (type === 'anonymous' && size > CustomSessionStore.largestSessionSize) {
  47. CustomSessionStore.largestSessionSize = size
  48. logger.warn(
  49. { redactedSession: redactSession(sess), largestSessionSize: size },
  50. 'largest session size seen'
  51. )
  52. }
  53. }
  54. get(sid, cb) {
  55. super.get(sid, (err, sess) => {
  56. if (err || !sess || !checkValidationToken(sid, sess)) return cb(err, null)
  57. CustomSessionStore.metric('get', sess)
  58. cb(null, sess)
  59. })
  60. }
  61. set(sid, sess, cb) {
  62. // Refresh the validation token just before writing to Redis
  63. // This will ensure that the token is always matching to the sessionID that we write the session value for.
  64. // Potential reasons for missing/mismatching token:
  65. // - brand-new session
  66. // - cycling of the sessionID as part of the login flow
  67. // - upgrade from a client side session to a redis session
  68. // - accidental writes in the app code
  69. sess.validationToken = computeValidationToken(sid)
  70. CustomSessionStore.metric('set', sess)
  71. const originalId = sess.req.signedCookies[Settings.cookieName]
  72. if (sid === originalId || sid === sess.req.newSessionId) {
  73. this.#updateInPlaceStore.set(sid, sess, cb)
  74. } else {
  75. Metrics.inc('security.session', 1, { status: 'new' })
  76. // Multiple writes can get issued with the new sid. Keep track of it.
  77. Object.defineProperty(sess.req, 'newSessionId', { value: sid })
  78. this.#initialSetStore.set(sid, sess, cb)
  79. }
  80. }
  81. touch(sid, sess, cb) {
  82. CustomSessionStore.metric('touch', sess)
  83. super.touch(sid, sess, cb)
  84. }
  85. destroy(sid, cb) {
  86. // for the destroy method we don't have access to the session object itself
  87. CustomSessionStore.metric('destroy')
  88. super.destroy(sid, cb)
  89. }
  90. }
  91. function computeValidationToken(sid) {
  92. // This should be a deterministic function of the client-side sessionID,
  93. // prepended with a version number in case we want to change it later.
  94. return 'v1:' + sid.slice(-4)
  95. }
  96. function checkValidationToken(sid, sess) {
  97. const sessionToken = sess.validationToken
  98. if (sessionToken) {
  99. const clientToken = computeValidationToken(sid)
  100. // Reject sessions where the validation token is out of sync with the sessionID.
  101. // If you change the method for computing the token (above) then you need to either check or ignore previous versions of the token.
  102. if (sessionToken === clientToken) {
  103. Metrics.inc('security.session', 1, { status: 'ok' })
  104. return true
  105. } else {
  106. logger.warn(
  107. { sid, sessionToken, clientToken },
  108. 'session token validation failed'
  109. )
  110. Metrics.inc('security.session', 1, { status: 'error' })
  111. return false
  112. }
  113. } else {
  114. Metrics.inc('security.session', 1, { status: 'missing' })
  115. return false
  116. }
  117. }
  118. // Helper function to return a redacted version of session object
  119. // so we can identify the largest keys without exposing sensitive
  120. // data
  121. function redactSession(sess) {
  122. // replace all string values with '***' of the same length
  123. return JSON.parse(
  124. JSON.stringify(sess, (key, value) => {
  125. if (typeof value === 'string') {
  126. return '*'.repeat(value.length)
  127. }
  128. return value
  129. })
  130. )
  131. }
  132. class CustomSetRedisClient {
  133. #client
  134. #flag
  135. constructor(client, flag) {
  136. this.#client = client
  137. this.#flag = flag
  138. }
  139. set(args, cb) {
  140. args.push(this.#flag)
  141. this.#client.set(args, (err, ok) => {
  142. Metrics.inc('session.store.set', 1, {
  143. path: this.#flag,
  144. status: err ? 'error' : ok ? 'success' : 'failure',
  145. })
  146. cb(err, ok)
  147. })
  148. }
  149. }
  150. export default CustomSessionStore