Files
overleaf-cep/services/web/test/unit/src/TokenAccess/TokenAccessHandlerTests.js
Jessica Lawshe cec07cf858 Merge pull request #15473 from overleaf/jel-remove-encoded-hash
[web] Remove encoded hashtag when checking token hash prefix

GitOrigin-RevId: 2b9c369beceb7897d0f6f165b4c3a70b79c14469
2023-10-31 09:04:22 +00:00

742 lines
21 KiB
JavaScript

const SandboxedModule = require('sandboxed-module')
const path = require('path')
const sinon = require('sinon')
const modulePath = path.join(
__dirname,
'../../../../app/src/Features/TokenAccess/TokenAccessHandler'
)
const { expect } = require('chai')
const { ObjectId } = require('mongodb')
describe('TokenAccessHandler', function () {
beforeEach(function () {
this.token = 'abcdefabcdef'
this.projectId = ObjectId()
this.project = {
_id: this.projectId,
publicAccesLevel: 'tokenBased',
owner_ref: ObjectId(),
}
this.userId = ObjectId()
this.req = {}
this.TokenAccessHandler = SandboxedModule.require(modulePath, {
requires: {
mongodb: { ObjectId },
'../../models/Project': { Project: (this.Project = {}) },
'@overleaf/metrics': (this.Metrics = { inc: sinon.stub() }),
'@overleaf/settings': (this.settings = {}),
'../V1/V1Api': (this.V1Api = {
request: sinon.stub(),
}),
crypto: (this.Crypto = require('crypto')),
'../Analytics/AnalyticsManager': (this.Analytics = {
recordEventForUser: sinon.stub(),
}),
},
})
})
describe('getTokenType', function () {
it('should determine tokens correctly', function () {
const specs = {
abcdefabcdef: 'readOnly',
aaaaaabbbbbb: 'readOnly',
'54325aaaaaa': 'readAndWrite',
'54325aaaaaabbbbbb': 'readAndWrite',
'': null,
abc123def: null,
}
for (const token of Object.keys(specs)) {
expect(this.TokenAccessHandler.getTokenType(token)).to.equal(
specs[token]
)
}
})
})
describe('getProjectByReadOnlyToken', function () {
beforeEach(function () {
this.token = 'abcdefabcdef'
this.Project.findOne = sinon.stub().callsArgWith(2, null, this.project)
})
it('should get the project', function (done) {
this.TokenAccessHandler.getProjectByReadOnlyToken(
this.token,
(err, project) => {
expect(err).to.not.exist
expect(project).to.exist
expect(this.Project.findOne.callCount).to.equal(1)
done()
}
)
})
})
describe('getProjectByReadAndWriteToken', function () {
beforeEach(function () {
sinon.spy(this.Crypto, 'timingSafeEqual')
this.token = '1234abcdefabcdef'
this.project.tokens = {
readAndWrite: this.token,
readAndWritePrefix: '1234',
}
this.Project.findOne = sinon.stub().callsArgWith(2, null, this.project)
})
afterEach(function () {
this.Crypto.timingSafeEqual.restore()
})
it('should get the project and do timing-safe comparison', function (done) {
this.TokenAccessHandler.getProjectByReadAndWriteToken(
this.token,
(err, project) => {
expect(err).to.not.exist
expect(project).to.exist
expect(this.Crypto.timingSafeEqual.callCount).to.equal(1)
expect(
this.Crypto.timingSafeEqual.calledWith(Buffer.from(this.token))
).to.equal(true)
expect(this.Project.findOne.callCount).to.equal(1)
done()
}
)
})
})
describe('addReadOnlyUserToProject', function () {
beforeEach(function () {
this.Project.updateOne = sinon.stub().callsArgWith(2, null)
})
it('should call Project.updateOne', function (done) {
this.TokenAccessHandler.addReadOnlyUserToProject(
this.userId,
this.projectId,
err => {
expect(err).to.not.exist
expect(this.Project.updateOne.callCount).to.equal(1)
expect(
this.Project.updateOne.calledWith({
_id: this.projectId,
})
).to.equal(true)
expect(
this.Project.updateOne.lastCall.args[1].$addToSet
).to.have.keys('tokenAccessReadOnly_refs')
sinon.assert.calledWith(
this.Analytics.recordEventForUser,
this.userId,
'project-joined',
{ mode: 'read-only' }
)
done()
}
)
})
it('should not produce an error', function (done) {
this.TokenAccessHandler.addReadOnlyUserToProject(
this.userId,
this.projectId,
err => {
expect(err).to.not.exist
done()
}
)
})
describe('when Project.updateOne produces an error', function () {
beforeEach(function () {
this.Project.updateOne = sinon
.stub()
.callsArgWith(2, new Error('woops'))
})
it('should produce an error', function (done) {
this.TokenAccessHandler.addReadOnlyUserToProject(
this.userId,
this.projectId,
err => {
expect(err).to.exist
done()
}
)
})
})
})
describe('addReadAndWriteUserToProject', function () {
beforeEach(function () {
this.Project.updateOne = sinon.stub().callsArgWith(2, null)
})
it('should call Project.updateOne', function (done) {
this.TokenAccessHandler.addReadAndWriteUserToProject(
this.userId,
this.projectId,
err => {
expect(err).to.not.exist
expect(this.Project.updateOne.callCount).to.equal(1)
expect(
this.Project.updateOne.calledWith({
_id: this.projectId,
})
).to.equal(true)
expect(
this.Project.updateOne.lastCall.args[1].$addToSet
).to.have.keys('tokenAccessReadAndWrite_refs')
sinon.assert.calledWith(
this.Analytics.recordEventForUser,
this.userId,
'project-joined',
{ mode: 'read-write' }
)
done()
}
)
})
it('should not produce an error', function (done) {
this.TokenAccessHandler.addReadAndWriteUserToProject(
this.userId,
this.projectId,
err => {
expect(err).to.not.exist
done()
}
)
})
describe('when Project.updateOne produces an error', function () {
beforeEach(function () {
this.Project.updateOne = sinon
.stub()
.callsArgWith(2, new Error('woops'))
})
it('should produce an error', function (done) {
this.TokenAccessHandler.addReadAndWriteUserToProject(
this.userId,
this.projectId,
err => {
expect(err).to.exist
done()
}
)
})
})
})
describe('grantSessionTokenAccess', function () {
beforeEach(function () {
this.req = { session: {}, headers: {} }
})
it('should add the token to the session', function (done) {
this.TokenAccessHandler.grantSessionTokenAccess(
this.req,
this.projectId,
this.token
)
expect(
this.req.session.anonTokenAccess[this.projectId.toString()]
).to.equal(this.token)
done()
})
})
describe('validateTokenForAnonymousAccess', function () {
describe('when a read-only project is found', function () {
beforeEach(function () {
this.TokenAccessHandler.getTokenType = sinon.stub().returns('readOnly')
this.TokenAccessHandler.getProjectByToken = sinon
.stub()
.callsArgWith(2, null, this.project)
})
it('should try to find projects with both kinds of token', function (done) {
this.TokenAccessHandler.validateTokenForAnonymousAccess(
this.projectId,
this.token,
(err, allowed) => {
expect(err).to.not.exist
expect(
this.TokenAccessHandler.getProjectByToken.callCount
).to.equal(1)
done()
}
)
})
it('should allow read-only access', function (done) {
this.TokenAccessHandler.validateTokenForAnonymousAccess(
this.projectId,
this.token,
(err, rw, ro) => {
expect(err).to.not.exist
expect(rw).to.equal(false)
expect(ro).to.equal(true)
done()
}
)
})
})
describe('when a read-and-write project is found', function () {
beforeEach(function () {
this.TokenAccessHandler.getTokenType = sinon
.stub()
.returns('readAndWrite')
this.TokenAccessHandler.getProjectByToken = sinon
.stub()
.callsArgWith(2, null, this.project)
})
describe('when Anonymous token access is not enabled', function (done) {
beforeEach(function () {
this.TokenAccessHandler.ANONYMOUS_READ_AND_WRITE_ENABLED = false
})
it('should try to find projects with both kinds of token', function (done) {
this.TokenAccessHandler.validateTokenForAnonymousAccess(
this.projectId,
this.token,
(err, rw, ro) => {
expect(err).to.not.exist
expect(
this.TokenAccessHandler.getProjectByToken.callCount
).to.equal(1)
done()
}
)
})
it('should not allow read-and-write access', function (done) {
this.TokenAccessHandler.validateTokenForAnonymousAccess(
this.projectId,
this.token,
(err, rw, ro) => {
expect(err).to.not.exist
expect(rw).to.equal(false)
expect(ro).to.equal(false)
done()
}
)
})
})
describe('when anonymous token access is enabled', function (done) {
beforeEach(function () {
this.TokenAccessHandler.ANONYMOUS_READ_AND_WRITE_ENABLED = true
})
it('should try to find projects with both kinds of token', function (done) {
this.TokenAccessHandler.validateTokenForAnonymousAccess(
this.projectId,
this.token,
(err, rw, ro) => {
expect(err).to.not.exist
expect(
this.TokenAccessHandler.getProjectByToken.callCount
).to.equal(1)
done()
}
)
})
it('should allow read-and-write access', function (done) {
this.TokenAccessHandler.validateTokenForAnonymousAccess(
this.projectId,
this.token,
(err, rw, ro) => {
expect(err).to.not.exist
expect(rw).to.equal(true)
expect(ro).to.equal(false)
done()
}
)
})
})
})
describe('when no project is found', function () {
beforeEach(function () {
this.TokenAccessHandler.getProjectByToken = sinon
.stub()
.callsArgWith(2, null, null, null)
})
it('should try to find projects with both kinds of token', function (done) {
this.TokenAccessHandler.validateTokenForAnonymousAccess(
this.projectId,
this.token,
(err, allowed) => {
expect(err).to.not.exist
expect(
this.TokenAccessHandler.getProjectByToken.callCount
).to.equal(1)
done()
}
)
})
it('should not allow any access', function (done) {
this.TokenAccessHandler.validateTokenForAnonymousAccess(
this.projectId,
this.token,
(err, rw, ro) => {
expect(err).to.not.exist
expect(rw).to.equal(false)
expect(ro).to.equal(false)
done()
}
)
})
})
describe('when findProject produces an error', function () {
beforeEach(function () {
this.TokenAccessHandler.getProjectByToken = sinon
.stub()
.callsArgWith(2, new Error('woops'))
})
it('should try to find projects with both kinds of token', function (done) {
this.TokenAccessHandler.validateTokenForAnonymousAccess(
this.projectId,
this.token,
(err, allowed) => {
expect(err).to.exist
expect(allowed).to.not.exist
expect(
this.TokenAccessHandler.getProjectByToken.callCount
).to.equal(1)
done()
}
)
})
it('should produce an error and not allow access', function (done) {
this.TokenAccessHandler.validateTokenForAnonymousAccess(
this.projectId,
this.token,
(err, rw, ro) => {
expect(err).to.exist
expect(err).to.be.instanceof(Error)
expect(rw).to.equal(undefined)
expect(ro).to.equal(undefined)
done()
}
)
})
})
describe('when project is not set to token-based access', function () {
beforeEach(function () {
this.project.publicAccesLevel = 'private'
})
describe('for read-and-write project', function () {
beforeEach(function () {
this.TokenAccessHandler.getTokenType = sinon
.stub()
.returns('readAndWrite')
this.TokenAccessHandler.getProjectByToken = sinon
.stub()
.callsArgWith(2, null, this.project)
})
it('should not allow any access', function (done) {
this.TokenAccessHandler.validateTokenForAnonymousAccess(
this.projectId,
this.token,
(err, rw, ro) => {
expect(err).to.not.exist
expect(rw).to.equal(false)
expect(ro).to.equal(false)
done()
}
)
})
})
describe('for read-only project', function () {
beforeEach(function () {
this.TokenAccessHandler.getTokenType = sinon
.stub()
.returns('readOnly')
this.TokenAccessHandler.getProjectByToken = sinon
.stub()
.callsArgWith(2, null, this.project)
})
it('should not allow any access', function (done) {
this.TokenAccessHandler.validateTokenForAnonymousAccess(
this.projectId,
this.token,
(err, rw, ro) => {
expect(err).to.not.exist
expect(rw).to.equal(false)
expect(ro).to.equal(false)
done()
}
)
})
})
describe('with nothing', function () {
beforeEach(function () {
this.TokenAccessHandler.getProjectByToken = sinon
.stub()
.callsArgWith(1, null, null, null)
})
it('should not allow any access', function (done) {
this.TokenAccessHandler.validateTokenForAnonymousAccess(
this.projectId,
null,
(err, rw, ro) => {
expect(err).to.not.exist
expect(rw).to.equal(false)
expect(ro).to.equal(false)
done()
}
)
})
})
})
})
describe('getDocPublishedInfo', function () {
beforeEach(function () {
this.callback = sinon.stub()
})
describe('when v1 api not set', function () {
beforeEach(function () {
this.settings.apis = { v1: undefined }
this.TokenAccessHandler.getV1DocPublishedInfo(this.token, this.callback)
})
it('should not check access and return default info', function () {
expect(this.V1Api.request.called).to.equal(false)
expect(
this.callback.calledWith(null, {
allow: true,
})
).to.equal(true)
})
})
describe('when v1 api is set', function () {
beforeEach(function () {
this.settings.apis = { v1: { url: 'v1Url' } }
})
describe('on V1Api.request success', function () {
beforeEach(function () {
this.V1Api.request = sinon
.stub()
.callsArgWith(1, null, null, 'mock-data')
this.TokenAccessHandler.getV1DocPublishedInfo(
this.token,
this.callback
)
})
it('should return response body', function () {
expect(
this.V1Api.request.calledWith({
url: `/api/v1/sharelatex/docs/${this.token}/is_published`,
})
).to.equal(true)
expect(this.callback.calledWith(null, 'mock-data')).to.equal(true)
})
})
describe('on V1Api.request error', function () {
beforeEach(function () {
this.V1Api.request = sinon.stub().callsArgWith(1, 'error')
this.TokenAccessHandler.getV1DocPublishedInfo(
this.token,
this.callback
)
})
it('should callback with error', function () {
expect(this.callback.calledWith('error')).to.equal(true)
})
})
})
})
describe('getV1DocInfo', function () {
beforeEach(function () {
this.callback = sinon.stub()
})
describe('when v1 api not set', function () {
beforeEach(function () {
this.TokenAccessHandler.getV1DocInfo(
this.token,
this.v2UserId,
this.callback
)
})
it('should not check access and return default info', function () {
expect(this.V1Api.request.called).to.equal(false)
expect(
this.callback.calledWith(null, {
exists: true,
exported: false,
})
).to.equal(true)
})
})
describe('when v1 api is set', function () {
beforeEach(function () {
this.settings.apis = { v1: 'v1' }
})
describe('on V1Api.request success', function () {
beforeEach(function () {
this.V1Api.request = sinon
.stub()
.callsArgWith(1, null, null, 'mock-data')
this.TokenAccessHandler.getV1DocInfo(
this.token,
this.v2UserId,
this.callback
)
})
it('should return response body', function () {
expect(
this.V1Api.request.calledWith({
url: `/api/v1/sharelatex/docs/${this.token}/info`,
})
).to.equal(true)
expect(this.callback.calledWith(null, 'mock-data')).to.equal(true)
})
})
describe('on V1Api.request error', function () {
beforeEach(function () {
this.V1Api.request = sinon.stub().callsArgWith(1, 'error')
this.TokenAccessHandler.getV1DocInfo(
this.token,
this.v2UserId,
this.callback
)
})
it('should callback with error', function () {
expect(this.callback.calledWith('error')).to.equal(true)
})
})
})
})
describe('createTokenHashPrefix', function () {
it('creates a prefix of the hash', function () {
const prefix =
this.TokenAccessHandler.createTokenHashPrefix('zxpxjrwdtsgd')
expect(prefix.length).to.equal(6)
})
})
describe('checkTokenHashPrefix', function () {
it('sends "match" to metrics when prefix matches the prefix of the hash of the token', function () {
const token = 'zxpxjrwdtsgd'
const prefix = this.TokenAccessHandler.createTokenHashPrefix(token)
this.TokenAccessHandler.checkTokenHashPrefix(
token,
`#${prefix}`,
'readOnly'
)
expect(this.Metrics.inc).to.have.been.calledWith(
'link-sharing.hash-check',
{
path: 'readOnly',
status: 'match',
}
)
})
it('sends "mismatch" to metrics when prefix does not match the prefix of the hash of the token', function () {
const token = 'zxpxjrwdtsgd'
const prefix = this.TokenAccessHandler.createTokenHashPrefix(token)
this.TokenAccessHandler.checkTokenHashPrefix(
'anothertoken',
`#${prefix}`,
'readOnly'
)
expect(this.Metrics.inc).to.have.been.calledWith(
'link-sharing.hash-check',
{
path: 'readOnly',
status: 'mismatch',
}
)
expect(this.logger.info).to.have.been.calledWith(
{ tokenHashPrefix: prefix, hashPrefixStatus: 'mismatch' },
'mismatched token hash prefix'
)
})
it('sends "missing" to metrics when prefix is undefined', function () {
this.TokenAccessHandler.checkTokenHashPrefix(
'anothertoken',
undefined,
'readOnly'
)
expect(this.Metrics.inc).to.have.been.calledWith(
'link-sharing.hash-check',
{
path: 'readOnly',
status: 'missing',
}
)
})
it('sends "missing" to metrics when URL hash is sent as "#" only', function () {
this.TokenAccessHandler.checkTokenHashPrefix(
'anothertoken',
'#',
'readOnly'
)
expect(this.Metrics.inc).to.have.been.calledWith(
'link-sharing.hash-check',
{
path: 'readOnly',
status: 'missing',
}
)
})
it('it handles encoded hashtags', function () {
const token = 'zxpxjrwdtsgd'
const prefix = this.TokenAccessHandler.createTokenHashPrefix(token)
this.TokenAccessHandler.checkTokenHashPrefix(
token,
`%23${prefix}`,
'readOnly'
)
expect(this.Metrics.inc).to.have.been.calledWith(
'link-sharing.hash-check',
{
path: 'readOnly',
status: 'match',
}
)
})
})
})