mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-27 02:51:57 +02:00
[web] check for redis connection being out of sync in session store GitOrigin-RevId: c271e88d4e1fbcb0f7a57f4775e8ef88b70b16a8
166 lines
5.5 KiB
JavaScript
166 lines
5.5 KiB
JavaScript
const session = require('express-session')
|
|
const RedisStore = require('connect-redis')(session)
|
|
const metrics = require('@overleaf/metrics')
|
|
const logger = require('@overleaf/logger')
|
|
const Settings = require('@overleaf/settings')
|
|
const SessionManager = require('../Features/Authentication/SessionManager')
|
|
const Metrics = require('@overleaf/metrics')
|
|
|
|
const MAX_SESSION_SIZE_THRESHOLD = 4096
|
|
|
|
// Define a custom session store to record session metrics and log large
|
|
// anonymous sessions for debugging purposes
|
|
// Also make the SET calls more robust/consistent by adding flags
|
|
// - XX: ensure update in place, expect that the old session value is still in redis at that key
|
|
// - NX: ensure initial set, expect that there is no other session at that key already
|
|
class CustomSessionStore extends RedisStore {
|
|
static largestSessionSize = 3 * 1024 // ignore sessions smaller than 3KB
|
|
#initialSetStore
|
|
#updateInPlaceStore
|
|
|
|
constructor({ client }) {
|
|
super({ client })
|
|
this.#initialSetStore = new RedisStore({
|
|
client: new CustomSetRedisClient(client, 'NX'),
|
|
})
|
|
this.#updateInPlaceStore = new RedisStore({
|
|
client: new CustomSetRedisClient(client, 'XX'),
|
|
})
|
|
}
|
|
|
|
static metric(method, sess) {
|
|
let type // type of session: 'logged-in', 'anonymous', or 'na' (not available)
|
|
if (sess) {
|
|
type = SessionManager.isUserLoggedIn(sess) ? 'logged-in' : 'anonymous'
|
|
} else {
|
|
type = 'na'
|
|
}
|
|
const size = sess ? JSON.stringify(sess).length : 0
|
|
// record the number of redis session operations
|
|
metrics.inc('session.store.count', 1, {
|
|
method,
|
|
type,
|
|
status: size > MAX_SESSION_SIZE_THRESHOLD ? 'oversize' : 'normal',
|
|
})
|
|
// record the redis session bandwidth for get/set operations
|
|
if (method === 'get' || method === 'set') {
|
|
metrics.count('session.store.bytes', size, { method, type })
|
|
}
|
|
// log the largest anonymous session seen so far
|
|
if (type === 'anonymous' && size > CustomSessionStore.largestSessionSize) {
|
|
CustomSessionStore.largestSessionSize = size
|
|
logger.warn(
|
|
{ redactedSession: redactSession(sess), largestSessionSize: size },
|
|
'largest session size seen'
|
|
)
|
|
}
|
|
}
|
|
|
|
get(sid, cb) {
|
|
super.get(sid, (err, sess) => {
|
|
if (err || !sess || !checkValidationToken(sid, sess)) return cb(err, null)
|
|
CustomSessionStore.metric('get', sess)
|
|
cb(null, sess)
|
|
})
|
|
}
|
|
|
|
set(sid, sess, cb) {
|
|
// Refresh the validation token just before writing to Redis
|
|
// This will ensure that the token is always matching to the sessionID that we write the session value for.
|
|
// Potential reasons for missing/mismatching token:
|
|
// - brand-new session
|
|
// - cycling of the sessionID as part of the login flow
|
|
// - upgrade from a client side session to a redis session
|
|
// - accidental writes in the app code
|
|
sess.validationToken = computeValidationToken(sid)
|
|
|
|
CustomSessionStore.metric('set', sess)
|
|
const originalId = sess.req.signedCookies[Settings.cookieName]
|
|
if (sid === originalId || sid === sess.req.newSessionId) {
|
|
this.#updateInPlaceStore.set(sid, sess, cb)
|
|
} else {
|
|
Metrics.inc('security.session', 1, { status: 'new' })
|
|
// Multiple writes can get issued with the new sid. Keep track of it.
|
|
Object.defineProperty(sess.req, 'newSessionId', { value: sid })
|
|
this.#initialSetStore.set(sid, sess, cb)
|
|
}
|
|
}
|
|
|
|
touch(sid, sess, cb) {
|
|
CustomSessionStore.metric('touch', sess)
|
|
super.touch(sid, sess, cb)
|
|
}
|
|
|
|
destroy(sid, cb) {
|
|
// for the destroy method we don't have access to the session object itself
|
|
CustomSessionStore.metric('destroy')
|
|
super.destroy(sid, cb)
|
|
}
|
|
}
|
|
|
|
function computeValidationToken(sid) {
|
|
// This should be a deterministic function of the client-side sessionID,
|
|
// prepended with a version number in case we want to change it later.
|
|
return 'v1:' + sid.slice(-4)
|
|
}
|
|
|
|
function checkValidationToken(sid, sess) {
|
|
const sessionToken = sess.validationToken
|
|
if (sessionToken) {
|
|
const clientToken = computeValidationToken(sid)
|
|
// Reject sessions where the validation token is out of sync with the sessionID.
|
|
// If you change the method for computing the token (above) then you need to either check or ignore previous versions of the token.
|
|
if (sessionToken === clientToken) {
|
|
Metrics.inc('security.session', 1, { status: 'ok' })
|
|
return true
|
|
} else {
|
|
logger.warn(
|
|
{ sid, sessionToken, clientToken },
|
|
'session token validation failed'
|
|
)
|
|
Metrics.inc('security.session', 1, { status: 'error' })
|
|
return false
|
|
}
|
|
} else {
|
|
Metrics.inc('security.session', 1, { status: 'missing' })
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Helper function to return a redacted version of session object
|
|
// so we can identify the largest keys without exposing sensitive
|
|
// data
|
|
function redactSession(sess) {
|
|
// replace all string values with '***' of the same length
|
|
return JSON.parse(
|
|
JSON.stringify(sess, (key, value) => {
|
|
if (typeof value === 'string') {
|
|
return '*'.repeat(value.length)
|
|
}
|
|
return value
|
|
})
|
|
)
|
|
}
|
|
|
|
class CustomSetRedisClient {
|
|
#client
|
|
#flag
|
|
constructor(client, flag) {
|
|
this.#client = client
|
|
this.#flag = flag
|
|
}
|
|
|
|
set(args, cb) {
|
|
args.push(this.#flag)
|
|
this.#client.set(args, (err, ok) => {
|
|
metrics.inc('session.store.set', 1, {
|
|
path: this.#flag,
|
|
status: err ? 'error' : ok ? 'success' : 'failure',
|
|
})
|
|
cb(err, ok)
|
|
})
|
|
}
|
|
}
|
|
|
|
module.exports = CustomSessionStore
|