Merge pull request #18294 from overleaf/jpa-td-invite-details

[web] avoid content reflection via query parameter on register page

GitOrigin-RevId: 43e7ba6069e0d9f3f12e5e9e680b5960b0673782
This commit is contained in:
Jakob Ackermann 2024-05-15 15:28:53 +02:00 committed by Copybot
parent 105d67bd04
commit dfe587f297
12 changed files with 143 additions and 96 deletions

View file

@ -522,7 +522,7 @@ const AuthenticationController = {
_redirectToLoginOrRegisterPage(req, res) { _redirectToLoginOrRegisterPage(req, res) {
if ( if (
req.query.zipUrl != null || req.query.zipUrl != null ||
req.query.project_name != null || req.session.sharedProjectData ||
req.path === '/user/subscription/new' req.path === '/user/subscription/new'
) { ) {
AuthenticationController._redirectToRegisterPage(req, res) AuthenticationController._redirectToRegisterPage(req, res)

View file

@ -5,13 +5,7 @@ const Settings = require('@overleaf/settings')
const CollaboratorsEmailHandler = { const CollaboratorsEmailHandler = {
_buildInviteUrl(project, invite) { _buildInviteUrl(project, invite) {
return ( return `${Settings.siteUrl}/project/${project._id}/invite/token/${invite.token}`
`${Settings.siteUrl}/project/${project._id}/invite/token/${invite.token}?` +
[
`project_name=${encodeURIComponent(project.name)}`,
`user_first_name=${encodeURIComponent(project.owner_ref.first_name)}`,
].join('&')
)
}, },
async notifyUserOfProjectInvite(projectId, email, invite, sendingUser) { async notifyUserOfProjectInvite(projectId, email, invite, sendingUser) {

View file

@ -14,6 +14,7 @@ const { RateLimiter } = require('../../infrastructure/RateLimiter')
const { expressify } = require('@overleaf/promise-utils') const { expressify } = require('@overleaf/promise-utils')
const ProjectAuditLogHandler = require('../Project/ProjectAuditLogHandler') const ProjectAuditLogHandler = require('../Project/ProjectAuditLogHandler')
const Errors = require('../Errors/Errors') const Errors = require('../Errors/Errors')
const AuthenticationController = require('../Authentication/AuthenticationController')
// This rate limiter allows a different number of requests depending on the // This rate limiter allows a different number of requests depending on the
// number of callaborators a user is allowed. This is implemented by providing // number of callaborators a user is allowed. This is implemented by providing
@ -234,23 +235,26 @@ const CollaboratorsInviteController = {
const projectId = req.params.Project_id const projectId = req.params.Project_id
const { token } = req.params const { token } = req.params
const _renderInvalidPage = function () { const _renderInvalidPage = function () {
res.status(404)
logger.debug({ projectId }, 'invite not valid, rendering not-valid page') logger.debug({ projectId }, 'invite not valid, rendering not-valid page')
res.render('project/invite/not-valid', { title: 'Invalid Invite' }) res.render('project/invite/not-valid', { title: 'Invalid Invite' })
} }
// check if the user is already a member of the project // check if the user is already a member of the project
const currentUser = SessionManager.getSessionUser(req.session) const currentUser = SessionManager.getSessionUser(req.session)
const isMember = if (currentUser) {
await CollaboratorsGetter.promises.isUserInvitedMemberOfProject( const isMember =
currentUser._id, await CollaboratorsGetter.promises.isUserInvitedMemberOfProject(
projectId currentUser._id,
) projectId
if (isMember) { )
logger.debug( if (isMember) {
{ projectId, userId: currentUser._id }, logger.debug(
'user is already a member of this project, redirecting' { projectId, userId: currentUser._id },
) 'user is already a member of this project, redirecting'
return res.redirect(`/project/${projectId}`) )
return res.redirect(`/project/${projectId}`)
}
} }
// get the invite // get the invite
@ -284,6 +288,18 @@ const CollaboratorsInviteController = {
return _renderInvalidPage() return _renderInvalidPage()
} }
if (!currentUser) {
req.session.sharedProjectData = {
project_name: project.name,
user_first_name: owner.first_name,
}
AuthenticationController.setRedirectInSession(req)
return res.redirect('/register')
}
// cleanup if set for register page
delete req.session.sharedProjectData
// finally render the invite // finally render the invite
res.render('project/invite/show', { res.render('project/invite/show', {
invite, invite,

View file

@ -26,6 +26,10 @@ const rateLimiters = {
points: 200, points: 200,
duration: 60 * 10, duration: 60 * 10,
}), }),
viewProjectInvite: new RateLimiter('view-project-invite', {
points: 20,
duration: 60,
}),
} }
module.exports = { module.exports = {
@ -128,7 +132,7 @@ module.exports = {
'collaboration', 'collaboration',
'project-invite' 'project-invite'
), ),
AuthenticationController.requireLogin(), RateLimiterMiddleware.rateLimit(rateLimiters.viewProjectInvite),
CollaboratorsInviteController.viewInvite, CollaboratorsInviteController.viewInvite,
AnalyticsRegistrationSourceMiddleware.clearSource() AnalyticsRegistrationSourceMiddleware.clearSource()
) )

View file

@ -80,6 +80,17 @@ const _buildPortalTemplatesList = affiliations => {
return portalTemplates return portalTemplates
} }
function cleanupSession(req) {
// cleanup redirects at the end of the redirect chain
delete req.session.postCheckoutRedirect
delete req.session.postLoginRedirect
delete req.session.postOnboardingRedirect
// cleanup details from register page
delete req.session.sharedProjectData
delete req.session.templateData
}
/** /**
* @param {import("express").Request} req * @param {import("express").Request} req
* @param {import("express").Response} res * @param {import("express").Response} res
@ -87,10 +98,7 @@ const _buildPortalTemplatesList = affiliations => {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async function projectListPage(req, res, next) { async function projectListPage(req, res, next) {
// cleanup redirects at the end of the redirect chain cleanupSession(req)
delete req.session.postCheckoutRedirect
delete req.session.postLoginRedirect
delete req.session.postOnboardingRedirect
// can have two values: // can have two values:
// - undefined - when there's no "saas" feature or couldn't get subscription data // - undefined - when there's no "saas" feature or couldn't get subscription data

View file

@ -211,10 +211,7 @@ const UserPagesController = {
accountSuspended: expressify(accountSuspended), accountSuspended: expressify(accountSuspended),
registerPage(req, res) { registerPage(req, res) {
const sharedProjectData = { const sharedProjectData = req.session.sharedProjectData || {}
project_name: req.query.project_name,
user_first_name: req.query.user_first_name,
}
const newTemplateData = {} const newTemplateData = {}
if (req.session.templateData != null) { if (req.session.templateData != null) {

View file

@ -4,6 +4,7 @@ const User = require('./helpers/User')
const settings = require('@overleaf/settings') const settings = require('@overleaf/settings')
const CollaboratorsEmailHandler = require('../../../app/src/Features/Collaborators/CollaboratorsEmailHandler') const CollaboratorsEmailHandler = require('../../../app/src/Features/Collaborators/CollaboratorsEmailHandler')
const Features = require('../../../app/src/infrastructure/Features') const Features = require('../../../app/src/infrastructure/Features')
const cheerio = require('cheerio')
const createInvite = (sendingUser, projectId, email, callback) => { const createInvite = (sendingUser, projectId, email, callback) => {
sendingUser.getCsrfToken(err => { sendingUser.getCsrfToken(err => {
@ -107,24 +108,6 @@ const tryAcceptInvite = (user, invite, callback) => {
}) })
} }
const tryRegisterUser = (user, email, callback) => {
user.getCsrfToken(error => {
if (error != null) {
return callback(error)
}
user.request.post(
{
url: '/register',
json: {
email,
password: 'some_weird_password',
},
},
callback
)
})
}
const tryFollowLoginLink = (user, loginLink, callback) => { const tryFollowLoginLink = (user, loginLink, callback) => {
user.getCsrfToken(error => { user.getCsrfToken(error => {
if (error != null) { if (error != null) {
@ -220,7 +203,7 @@ const expectInvalidInvitePage = (user, link, callback) => {
// view invalid invite // view invalid invite
tryFollowInviteLink(user, link, (err, response, body) => { tryFollowInviteLink(user, link, (err, response, body) => {
expect(err).not.to.exist expect(err).not.to.exist
expect(response.statusCode).to.equal(200) expect(response.statusCode).to.equal(404)
expect(body).to.match(/<title>Invalid Invite - .*<\/title>/) expect(body).to.match(/<title>Invalid Invite - .*<\/title>/)
callback() callback()
}) })
@ -232,7 +215,15 @@ const expectInviteRedirectToRegister = (user, link, callback) => {
expect(err).not.to.exist expect(err).not.to.exist
expect(response.statusCode).to.equal(302) expect(response.statusCode).to.equal(302)
expect(response.headers.location).to.match(/^\/register.*$/) expect(response.headers.location).to.match(/^\/register.*$/)
callback()
user.getSession((err, session) => {
if (err) return callback(err)
expect(session.sharedProjectData).deep.equals({
project_name: PROJECT_NAME,
user_first_name: OWNER_NAME,
})
callback()
})
}) })
} }
@ -253,11 +244,26 @@ const expectLoginRedirectToInvite = (user, link, callback) => {
}) })
} }
const expectRegistrationRedirectToInvite = (user, email, link, callback) => { const expectRegistrationRedirectToInvite = (user, link, callback) => {
tryRegisterUser(user, email, (err, response) => { user.register((err, _user, response) => {
expect(err).not.to.exist expect(err).not.to.exist
expect(response.statusCode).to.equal(200) expect(response.statusCode).to.equal(200)
callback()
if (response.body.redir === '/registration/try-premium') {
user.request.get('/registration/onboarding', (err, response) => {
if (err) return callback(err)
expect(response.statusCode).to.equal(200)
const dom = cheerio.load(response.body)
const skipUrl = dom('meta[name="ol-skipUrl"]')[0].attribs.content
expect(new URL(skipUrl, settings.siteUrl).href).to.equal(
new URL(link, settings.siteUrl).href
)
callback()
})
} else {
expect(response.body.redir).to.equal(link)
callback()
}
}) })
} }
@ -301,19 +307,26 @@ const expectInvitesInJoinProjectCount = (user, projectId, count, callback) => {
}) })
} }
// eslint-disable-next-line mocha/no-skipped-tests const PROJECT_NAME = 'project name for sharing test'
describe.skip('ProjectInviteTests', function () { const OWNER_NAME = 'sending user name'
describe('ProjectInviteTests', function () {
beforeEach(function (done) { beforeEach(function (done) {
this.sendingUser = new User() this.sendingUser = new User()
this.user = new User() this.user = new User()
this.site_admin = new User({ email: 'admin@example.com' }) this.site_admin = new User({ email: `admin+${Math.random()}@example.com` })
this.email = 'smoketestuser@example.com' this.email = `smoketestuser+${Math.random()}@example.com`
this.projectName = 'sharing test'
Async.series( Async.series(
[ [
cb => this.user.ensureUserExists(cb),
cb => this.sendingUser.login(cb), cb => this.sendingUser.login(cb),
cb => this.sendingUser.setFeatures({ collaborators: 10 }, cb), cb => this.sendingUser.setFeatures({ collaborators: 10 }, cb),
cb =>
this.sendingUser.mongoUpdate(
{
$set: { first_name: OWNER_NAME },
},
cb
),
cb => cb =>
this.sendingUser.setFeaturesOverride( this.sendingUser.setFeaturesOverride(
{ {
@ -328,15 +341,11 @@ describe.skip('ProjectInviteTests', function () {
}) })
describe('creating invites', function () { describe('creating invites', function () {
beforeEach(function () {
this.projectName = 'wat'
})
describe('creating two invites', function () { describe('creating two invites', function () {
beforeEach(function (done) { beforeEach(function (done) {
createProject( createProject(
this.sendingUser, this.sendingUser,
this.projectName, PROJECT_NAME,
(err, projectId, project) => { (err, projectId, project) => {
expect(err).not.to.exist expect(err).not.to.exist
this.projectId = projectId this.projectId = projectId
@ -504,7 +513,7 @@ describe.skip('ProjectInviteTests', function () {
beforeEach(function (done) { beforeEach(function (done) {
createProjectAndInvite( createProjectAndInvite(
this.sendingUser, this.sendingUser,
this.projectName, PROJECT_NAME,
this.email, this.email,
(err, project, invite, link) => { (err, project, invite, link) => {
expect(err).not.to.exist expect(err).not.to.exist
@ -657,12 +666,7 @@ describe.skip('ProjectInviteTests', function () {
[ [
cb => expectInviteRedirectToRegister(this.user, this.link, cb), cb => expectInviteRedirectToRegister(this.user, this.link, cb),
cb => cb =>
expectRegistrationRedirectToInvite( expectRegistrationRedirectToInvite(this.user, this.link, cb),
this.user,
'some_email@example.com',
this.link,
cb
),
cb => expectInvitePage(this.user, this.link, cb), cb => expectInvitePage(this.user, this.link, cb),
cb => expectAcceptInviteAndRedirect(this.user, this.invite, cb), cb => expectAcceptInviteAndRedirect(this.user, this.invite, cb),
cb => expectProjectAccess(this.user, this.invite.projectId, cb), cb => expectProjectAccess(this.user, this.invite.projectId, cb),
@ -689,21 +693,13 @@ describe.skip('ProjectInviteTests', function () {
) )
}) })
it('should display invalid-invite if the user registers a new account', function (done) { it('should display invalid-invite right away', function (done) {
const badLink = this.link.replace( const badLink = this.link.replace(
this.invite.token, this.invite.token,
'not_a_real_token' 'not_a_real_token'
) )
Async.series( Async.series(
[ [
cb => expectInviteRedirectToRegister(this.user, badLink, cb),
cb =>
expectRegistrationRedirectToInvite(
this.user,
'some_email@example.com',
badLink,
cb
),
cb => expectInvalidInvitePage(this.user, badLink, cb), cb => expectInvalidInvitePage(this.user, badLink, cb),
cb => expectNoProjectAccess(this.user, this.invite.projectId, cb), cb => expectNoProjectAccess(this.user, this.invite.projectId, cb),
], ],
@ -713,6 +709,10 @@ describe.skip('ProjectInviteTests', function () {
}) })
describe('login workflow with valid token', function () { describe('login workflow with valid token', function () {
beforeEach(function (done) {
this.user.ensureUserExists(done)
})
it('should redirect to the register page', function (done) { it('should redirect to the register page', function (done) {
Async.series( Async.series(
[ [
@ -762,20 +762,13 @@ describe.skip('ProjectInviteTests', function () {
) )
}) })
it('should show the invalid-invite page once the user has logged in', function (done) { it('should show the invalid-invite page right away', function (done) {
const badLink = this.link.replace( const badLink = this.link.replace(
this.invite.token, this.invite.token,
'not_a_real_token' 'not_a_real_token'
) )
Async.series( Async.series(
[ [
cb => {
return expectInviteRedirectToRegister(this.user, badLink, cb)
},
cb => {
return expectLoginPage(this.user, cb)
},
cb => expectLoginRedirectToInvite(this.user, badLink, cb),
cb => expectInvalidInvitePage(this.user, badLink, cb), cb => expectInvalidInvitePage(this.user, badLink, cb),
cb => expectNoProjectAccess(this.user, this.invite.projectId, cb), cb => expectNoProjectAccess(this.user, this.invite.projectId, cb),
], ],

View file

@ -336,7 +336,7 @@ class User {
return callback(error) return callback(error)
} }
this.setExtraAttributes(user) this.setExtraAttributes(user)
callback(null, user) callback(null, user, response)
}) })
} }
) )

View file

@ -906,7 +906,10 @@ describe('AuthenticationController', function () {
describe('they have been invited to a project', function () { describe('they have been invited to a project', function () {
beforeEach(function () { beforeEach(function () {
this.req.query.project_name = 'something' this.req.session.sharedProjectData = {
project_name: 'something',
user_first_name: 'else',
}
this.SessionManager.isUserLoggedIn = sinon.stub().returns(false) this.SessionManager.isUserLoggedIn = sinon.stub().returns(false)
this.middleware(this.req, this.res, this.next) this.middleware(this.req, this.res, this.next)
}) })

View file

@ -92,6 +92,10 @@ describe('CollaboratorsInviteController', function () {
addEntryInBackground: sinon.stub(), addEntryInBackground: sinon.stub(),
} }
this.AuthenticationController = {
setRedirectInSession: sinon.stub(),
}
this.CollaboratorsInviteController = SandboxedModule.require(MODULE_PATH, { this.CollaboratorsInviteController = SandboxedModule.require(MODULE_PATH, {
requires: { requires: {
'../Project/ProjectGetter': this.ProjectGetter, '../Project/ProjectGetter': this.ProjectGetter,
@ -105,6 +109,8 @@ describe('CollaboratorsInviteController', function () {
'../Authentication/SessionManager': this.SessionManager, '../Authentication/SessionManager': this.SessionManager,
'@overleaf/settings': this.settings, '@overleaf/settings': this.settings,
'../../infrastructure/RateLimiter': this.RateLimiter, '../../infrastructure/RateLimiter': this.RateLimiter,
'../Authentication/AuthenticationController':
this.AuthenticationController,
}, },
}) })
@ -626,6 +632,35 @@ describe('CollaboratorsInviteController', function () {
}) })
}) })
describe('when not logged in', function () {
beforeEach(function (done) {
this.SessionManager.getSessionUser.returns(null)
this.res.callback = () => done()
this.CollaboratorsInviteController.viewInvite(
this.req,
this.res,
this.next
)
})
it('should not check member status', function () {
expect(this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject)
.to.not.have.been.called
})
it('should set redirect back to invite', function () {
expect(
this.AuthenticationController.setRedirectInSession
).to.have.been.calledWith(this.req)
})
it('should redirect to the register page', function () {
expect(this.res.render).to.not.have.been.called
expect(this.res.redirect).to.have.been.calledOnce
expect(this.res.redirect).to.have.been.calledWith('/register')
})
})
describe('when user is already a member of the project', function () { describe('when user is already a member of the project', function () {
beforeEach(function (done) { beforeEach(function (done) {
this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves( this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves(

View file

@ -537,12 +537,7 @@ describe('EmailBuilder', function () {
} }
this.projectName = 'Top Secret' this.projectName = 'Top Secret'
this.opts = { this.opts = {
inviteUrl: inviteUrl: `${this.settings.siteUrl}/project/projectId123/invite/token/aToken123`,
`${this.settings.siteUrl}/project/projectId123/invite/token/aToken123?` +
[
`project_name=${encodeURIComponent(this.projectName)}`,
`user_first_name=${encodeURIComponent(this.owner.name)}`,
].join('&'),
owner: { owner: {
email: this.owner.email, email: this.owner.email,
}, },

View file

@ -119,8 +119,10 @@ describe('UserPagesController', function () {
}) })
it('should set sharedProjectData', function (done) { it('should set sharedProjectData', function (done) {
this.req.query.project_name = 'myProject' this.req.session.sharedProjectData = {
this.req.query.user_first_name = 'user_first_name_here' project_name: 'myProject',
user_first_name: 'user_first_name_here',
}
this.res.callback = () => { this.res.callback = () => {
this.res.renderedVariables.sharedProjectData.project_name.should.equal( this.res.renderedVariables.sharedProjectData.project_name.should.equal(