const { expect } = require('chai') const { Sequelize } = require('sequelize') const sinon = require('sinon') const Database = require('../../../server/Database') const ApiRouter = require('../../../server/routers/ApiRouter') const MeController = require('../../../server/controllers/MeController') const Auth = require('../../../server/Auth') const Logger = require('../../../server/Logger') const SocketAuthority = require('../../../server/SocketAuthority') describe('MeController - IDOR Security Tests', () => { /** @type {ApiRouter} */ let apiRouter beforeEach(async () => { global.ServerSettings = {} Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '') await Database.buildModels() // Create mock server object with required dependencies const mockServer = { auth: new Auth(), playbackSessionManager: { sessions: [] }, abMergeManager: {}, backupManager: {}, podcastManager: {}, audioMetadataManager: {}, cronManager: {}, emailManager: {}, apiCacheManager: { middleware: (req, res, next) => next() } } apiRouter = new ApiRouter(mockServer) sinon.stub(Logger, 'info') sinon.stub(Logger, 'error') sinon.stub(SocketAuthority, 'clientEmitter') }) afterEach(async () => { sinon.restore() // Clear all tables await Database.sequelize.sync({ force: true }) }) describe('removeMediaProgress - IDOR Protection', () => { let user1, user2 let mediaProgress1, mediaProgress2 beforeEach(async () => { // Create two users user1 = await Database.userModel.create({ username: 'user1', pash: 'hashed_password_1', type: 'user', isActive: true }) user2 = await Database.userModel.create({ username: 'user2', pash: 'hashed_password_2', type: 'user', isActive: true }) // Create library and book const library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' }) const libraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: library.id }) const book = await Database.bookModel.create({ title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) const libraryItem = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: book.id, mediaType: 'book', libraryId: library.id, libraryFolderId: libraryFolder.id }) // Create media progress for each user mediaProgress1 = await Database.mediaProgressModel.create({ userId: user1.id, mediaItemId: book.id, mediaItemType: 'book', duration: 1000, currentTime: 500, isFinished: false }) mediaProgress2 = await Database.mediaProgressModel.create({ userId: user2.id, mediaItemId: book.id, mediaItemType: 'book', duration: 1000, currentTime: 300, isFinished: false }) // Load media progresses into users user1.mediaProgresses = await user1.getMediaProgresses() user2.mediaProgresses = await user2.getMediaProgresses() }) it('should allow user to delete their own media progress', async () => { const fakeReq = { user: user1, params: { id: mediaProgress1.id } } const fakeRes = { sendStatus: sinon.spy(), status: sinon.stub().returnsThis(), send: sinon.spy() } await MeController.removeMediaProgress(fakeReq, fakeRes) expect(fakeRes.sendStatus.calledWith(200)).to.be.true // Verify media progress was deleted const deletedProgress = await Database.mediaProgressModel.findByPk(mediaProgress1.id) expect(deletedProgress).to.be.null }) it('should prevent user from deleting another users media progress (IDOR)', async () => { const fakeReq = { user: user1, params: { id: mediaProgress2.id } // Trying to delete user2's progress } const fakeRes = { sendStatus: sinon.spy(), status: sinon.stub().returnsThis(), send: sinon.spy() } await MeController.removeMediaProgress(fakeReq, fakeRes) expect(fakeRes.sendStatus.calledWith(404)).to.be.true // Verify media progress was NOT deleted const existingProgress = await Database.mediaProgressModel.findByPk(mediaProgress2.id) expect(existingProgress).to.not.be.null expect(existingProgress.userId).to.equal(user2.id) }) it('should return 404 for non-existent media progress', async () => { const fakeReq = { user: user1, params: { id: 'non-existent-id' } } const fakeRes = { sendStatus: sinon.spy(), status: sinon.stub().returnsThis(), send: sinon.spy() } await MeController.removeMediaProgress(fakeReq, fakeRes) expect(fakeRes.sendStatus.calledWith(404)).to.be.true }) }) describe('current user progress and bookmark reads', () => { let user1, user2 let bookLibraryItem, podcastLibraryItem, podcastEpisode beforeEach(async () => { user1 = await Database.userModel.create({ username: 'user1', pash: 'hashed_password_1', type: 'user', isActive: true, permissions: { accessAllLibraries: true, accessAllTags: true } }) user2 = await Database.userModel.create({ username: 'user2', pash: 'hashed_password_2', type: 'user', isActive: true, permissions: { accessAllLibraries: false, accessAllTags: true, librariesAccessible: [] } }) const bookLibrary = await Database.libraryModel.create({ name: 'Book Library', mediaType: 'book' }) const bookLibraryFolder = await Database.libraryFolderModel.create({ path: '/books', libraryId: bookLibrary.id }) const book = await Database.bookModel.create({ title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) bookLibraryItem = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: book.id, mediaType: 'book', libraryId: bookLibrary.id, libraryFolderId: bookLibraryFolder.id }) const podcastLibrary = await Database.libraryModel.create({ name: 'Podcast Library', mediaType: 'podcast' }) const podcastLibraryFolder = await Database.libraryFolderModel.create({ path: '/podcasts', libraryId: podcastLibrary.id }) const podcast = await Database.podcastModel.create({ title: 'Test Podcast', tags: [], genres: [] }) podcastLibraryItem = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: podcast.id, mediaType: 'podcast', libraryId: podcastLibrary.id, libraryFolderId: podcastLibraryFolder.id }) podcastEpisode = await Database.podcastEpisodeModel.create({ podcastId: podcast.id, title: 'Episode 1', index: 1, audioFile: { ino: '1', metadata: { filename: 'episode-1.mp3', ext: '.mp3', path: '/podcasts/episode-1.mp3', relPath: 'episode-1.mp3' } } }) await Database.mediaProgressModel.create({ userId: user1.id, mediaItemId: book.id, mediaItemType: 'book', duration: 1000, currentTime: 500, isFinished: false, extraData: { libraryItemId: bookLibraryItem.id, progress: 0.5 } }) await Database.mediaProgressModel.create({ userId: user1.id, mediaItemId: podcastEpisode.id, podcastId: podcast.id, mediaItemType: 'podcastEpisode', duration: 1000, currentTime: 250, isFinished: false, extraData: { libraryItemId: podcastLibraryItem.id, progress: 0.25 } }) await Database.mediaProgressModel.create({ userId: user2.id, mediaItemId: book.id, mediaItemType: 'book', duration: 1000, currentTime: 750, isFinished: false, extraData: { libraryItemId: bookLibraryItem.id, progress: 0.75 } }) user1.mediaProgresses = await user1.getMediaProgresses() user2.mediaProgresses = await user2.getMediaProgresses() user1.bookmarks = [ { libraryItemId: bookLibraryItem.id, time: 100, title: 'Book Bookmark' }, { libraryItemId: podcastLibraryItem.id, episodeId: podcastEpisode.id, time: 200, title: 'Episode Bookmark' } ] user2.bookmarks = [{ libraryItemId: bookLibraryItem.id, time: 300, title: 'Other User Bookmark' }] }) it('should register the current-user progress and bookmark read routes', () => { const routes = apiRouter.router._router.stack.filter((layer) => layer.route).map((layer) => `${Object.keys(layer.route.methods)[0].toUpperCase()} ${layer.route.path}`) expect(routes).to.include('GET /me/progress') expect(routes).to.include('GET /me/bookmarks') expect(routes).to.include('GET /me/bookmarks/:libraryItemId') }) it("should return only the authenticated user's media progress, including podcast episode progress", () => { const fakeRes = { json: sinon.spy() } MeController.getAllMediaProgress({ user: user1 }, fakeRes) const mediaProgress = fakeRes.json.firstCall.args[0] expect(mediaProgress).to.have.length(2) expect(mediaProgress.every((progress) => progress.userId === user1.id)).to.be.true const podcastProgress = mediaProgress.find((progress) => progress.mediaItemId === podcastEpisode.id) expect(podcastProgress).to.include({ id: user1.mediaProgresses.find((progress) => progress.mediaItemId === podcastEpisode.id).id, userId: user1.id, libraryItemId: podcastLibraryItem.id, episodeId: podcastEpisode.id, mediaItemId: podcastEpisode.id, mediaItemType: 'podcastEpisode', duration: 1000, progress: 0.25, currentTime: 250, isFinished: false, hideFromContinueListening: false, ebookLocation: null, ebookProgress: null, finishedAt: null }) expect(podcastProgress.lastUpdate).to.be.a('number') expect(podcastProgress.startedAt).to.be.a('number') }) it("should return only the authenticated user's bookmarks", () => { const fakeRes = { json: sinon.spy() } MeController.getAllBookmarks({ user: user1 }, fakeRes) expect(fakeRes.json.calledWith(user1.bookmarks)).to.be.true expect(fakeRes.json.firstCall.args[0]).to.not.deep.include(user2.bookmarks[0]) }) it('should return podcast episode bookmarks using the parent podcast library item id', async () => { const fakeRes = { json: sinon.spy(), sendStatus: sinon.spy() } await MeController.getBookmarksForLibraryItem({ user: user1, params: { libraryItemId: podcastLibraryItem.id } }, fakeRes) expect(fakeRes.sendStatus.notCalled).to.be.true expect(fakeRes.json.calledWith([user1.bookmarks[1]])).to.be.true }) it('should prevent a user from reading bookmarks for an inaccessible library item', async () => { const fakeRes = { json: sinon.spy(), sendStatus: sinon.spy() } await MeController.getBookmarksForLibraryItem({ user: user2, params: { libraryItemId: podcastLibraryItem.id } }, fakeRes) expect(fakeRes.sendStatus.calledWith(403)).to.be.true expect(fakeRes.json.notCalled).to.be.true }) }) describe('Bookmark Operations - Authorization Checks', () => { let user1, user2 let library1, library2 let libraryItem1, libraryItem2 beforeEach(async () => { // Create two users with different library access user1 = await Database.userModel.create({ username: 'user1', pash: 'hashed_password_1', type: 'user', isActive: true, librariesAccessible: null // Access to all libraries }) user2 = await Database.userModel.create({ username: 'user2', pash: 'hashed_password_2', type: 'user', isActive: true, librariesAccessible: [] // Will be set to specific library }) // Create two libraries library1 = await Database.libraryModel.create({ name: 'Library 1', mediaType: 'book' }) library2 = await Database.libraryModel.create({ name: 'Library 2', mediaType: 'book' }) // User2 only has access to library1 user2.librariesAccessible = [library1.id] await user2.save() const libraryFolder1 = await Database.libraryFolderModel.create({ path: '/test1', libraryId: library1.id }) const libraryFolder2 = await Database.libraryFolderModel.create({ path: '/test2', libraryId: library2.id }) const book1 = await Database.bookModel.create({ title: 'Book 1', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) const book2 = await Database.bookModel.create({ title: 'Book 2', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) libraryItem1 = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: book1.id, mediaType: 'book', libraryId: library1.id, libraryFolderId: libraryFolder1.id }) libraryItem2 = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: book2.id, mediaType: 'book', libraryId: library2.id, libraryFolderId: libraryFolder2.id }) // Initialize bookmarks user1.bookmarks = [] user2.bookmarks = [] }) describe('createBookmark', () => { it('should allow user to create bookmark for accessible library item', async () => { const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id) const bookmark = { libraryItemId: libraryItem1.id, time: 100, title: 'Test Bookmark', createdAt: Date.now() } const fakeReq = { user: { ...user2.toJSON(), id: user2.id, username: user2.username, checkCanAccessLibraryItem: () => true, createBookmark: sinon.stub().resolves(bookmark), toOldJSONForBrowser: () => ({ id: user2.id, username: user2.username }) }, params: { id: libraryItem1.id }, body: { time: 100, title: 'Test Bookmark' } } const fakeRes = { sendStatus: sinon.spy(), status: sinon.stub().returnsThis(), send: sinon.spy(), json: sinon.spy() } sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem) await MeController.createBookmark(fakeReq, fakeRes) expect(fakeRes.json.calledOnce).to.be.true expect(fakeRes.json.calledWith(bookmark)).to.be.true Database.libraryItemModel.getExpandedById.restore() }) it('should prevent user from creating bookmark for inaccessible library item (IDOR)', async () => { const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem2.id) const fakeReq = { user: user2, // user2 doesn't have access to library2 params: { id: libraryItem2.id }, body: { time: 100, title: 'Test Bookmark' } } const fakeRes = { sendStatus: sinon.spy(), status: sinon.stub().returnsThis(), send: sinon.spy(), json: sinon.spy() } // Mock getExpandedById sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem) await MeController.createBookmark(fakeReq, fakeRes) expect(fakeRes.sendStatus.calledWith(403)).to.be.true expect(fakeRes.json.called).to.be.false Database.libraryItemModel.getExpandedById.restore() }) it('should return 404 for non-existent library item', async () => { const fakeReq = { user: user1, params: { id: 'non-existent-id' }, body: { time: 100, title: 'Test Bookmark' } } const fakeRes = { sendStatus: sinon.spy(), status: sinon.stub().returnsThis(), send: sinon.spy(), json: sinon.spy() } // Mock getExpandedById to return null sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(null) await MeController.createBookmark(fakeReq, fakeRes) expect(fakeRes.sendStatus.calledWith(404)).to.be.true Database.libraryItemModel.getExpandedById.restore() }) it('should validate bookmark time parameter', async () => { const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id) const fakeReq = { user: { ...user1.toJSON(), id: user1.id, username: user1.username, checkCanAccessLibraryItem: () => true }, params: { id: libraryItem1.id }, body: { time: null, title: 'Test Bookmark' } // null time is invalid } const fakeRes = { sendStatus: sinon.spy(), status: sinon.stub().returnsThis(), send: sinon.spy(), json: sinon.spy() } sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem) await MeController.createBookmark(fakeReq, fakeRes) expect(fakeRes.status.calledWith(400)).to.be.true expect(fakeRes.send.calledWith('Invalid time')).to.be.true Database.libraryItemModel.getExpandedById.restore() }) }) describe('updateBookmark', () => { beforeEach(async () => { // Add existing bookmark to user1 user1.bookmarks = [{ libraryItemId: libraryItem1.id, time: 100, title: 'Original Title' }] await user1.save() }) it('should allow user to update bookmark for accessible library item', async () => { const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id) const bookmark = { libraryItemId: libraryItem1.id, time: 100, title: 'Updated Title' } const fakeReq = { user: { ...user1.toJSON(), id: user1.id, username: user1.username, checkCanAccessLibraryItem: () => true, updateBookmark: sinon.stub().resolves(bookmark), toOldJSONForBrowser: () => ({ id: user1.id, username: user1.username }) }, params: { id: libraryItem1.id }, body: { time: 100, title: 'Updated Title' } } const fakeRes = { sendStatus: sinon.spy(), status: sinon.stub().returnsThis(), send: sinon.spy(), json: sinon.spy() } sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem) await MeController.updateBookmark(fakeReq, fakeRes) expect(fakeRes.json.calledOnce).to.be.true expect(fakeRes.json.calledWith(bookmark)).to.be.true Database.libraryItemModel.getExpandedById.restore() }) it('should prevent user from updating bookmark for inaccessible library item (IDOR)', async () => { const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem2.id) const fakeReq = { user: user2, // user2 doesn't have access to library2 params: { id: libraryItem2.id }, body: { time: 100, title: 'Updated Title' } } const fakeRes = { sendStatus: sinon.spy(), status: sinon.stub().returnsThis(), send: sinon.spy(), json: sinon.spy() } sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem) await MeController.updateBookmark(fakeReq, fakeRes) expect(fakeRes.sendStatus.calledWith(403)).to.be.true Database.libraryItemModel.getExpandedById.restore() }) }) describe('removeBookmark', () => { beforeEach(async () => { // Add existing bookmark to user1 user1.bookmarks = [{ libraryItemId: libraryItem1.id, time: 100, title: 'Test Bookmark' }] await user1.save() }) it('should allow user to remove bookmark for accessible library item', async () => { const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id) const fakeReq = { user: { ...user1.toJSON(), id: user1.id, username: user1.username, checkCanAccessLibraryItem: () => true, findBookmark: sinon.stub().returns({ libraryItemId: libraryItem1.id, time: 100, title: 'Test Bookmark' }), removeBookmark: sinon.stub().resolves(true), toOldJSONForBrowser: () => ({ id: user1.id, username: user1.username }) }, params: { id: libraryItem1.id, time: '100' } } const fakeRes = { sendStatus: sinon.spy(), status: sinon.stub().returnsThis(), send: sinon.spy() } sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem) await MeController.removeBookmark(fakeReq, fakeRes) expect(fakeRes.sendStatus.calledWith(200)).to.be.true Database.libraryItemModel.getExpandedById.restore() }) it('should prevent user from removing bookmark for inaccessible library item (IDOR)', async () => { const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem2.id) const fakeReq = { user: user2, // user2 doesn't have access to library2 params: { id: libraryItem2.id, time: '100' } } const fakeRes = { sendStatus: sinon.spy(), status: sinon.stub().returnsThis(), send: sinon.spy() } sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem) await MeController.removeBookmark(fakeReq, fakeRes) expect(fakeRes.sendStatus.calledWith(403)).to.be.true Database.libraryItemModel.getExpandedById.restore() }) it('should validate time parameter is a number', async () => { const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id) const fakeReq = { user: { ...user1.toJSON(), id: user1.id, username: user1.username, checkCanAccessLibraryItem: () => true }, params: { id: libraryItem1.id, time: 'not-a-number' } } const fakeRes = { sendStatus: sinon.spy(), status: sinon.stub().returnsThis(), send: sinon.spy() } sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem) await MeController.removeBookmark(fakeReq, fakeRes) expect(fakeRes.status.calledWith(400)).to.be.true expect(fakeRes.send.calledWith('Invalid time')).to.be.true Database.libraryItemModel.getExpandedById.restore() }) }) }) describe('getItemListeningSessions - Authorization Check', () => { let user1, user2 let library1, library2 let libraryItem1, libraryItem2 beforeEach(async () => { // Create two users with different library access user1 = await Database.userModel.create({ username: 'user1', pash: 'hashed_password_1', type: 'user', isActive: true, librariesAccessible: null // Access to all libraries }) user2 = await Database.userModel.create({ username: 'user2', pash: 'hashed_password_2', type: 'user', isActive: true, librariesAccessible: [] // Will be set to specific library }) // Create two libraries library1 = await Database.libraryModel.create({ name: 'Library 1', mediaType: 'book' }) library2 = await Database.libraryModel.create({ name: 'Library 2', mediaType: 'book' }) // User2 only has access to library1 user2.librariesAccessible = [library1.id] await user2.save() const libraryFolder1 = await Database.libraryFolderModel.create({ path: '/test1', libraryId: library1.id }) const libraryFolder2 = await Database.libraryFolderModel.create({ path: '/test2', libraryId: library2.id }) const book1 = await Database.bookModel.create({ title: 'Book 1', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) const book2 = await Database.bookModel.create({ title: 'Book 2', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) libraryItem1 = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: book1.id, mediaType: 'book', libraryId: library1.id, libraryFolderId: libraryFolder1.id }) libraryItem2 = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: book2.id, mediaType: 'book', libraryId: library2.id, libraryFolderId: libraryFolder2.id }) }) it('should allow user to view listening sessions for accessible library item', async () => { const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id) // Create mock context with getUserItemListeningSessionsHelper const mockContext = { getUserItemListeningSessionsHelper: sinon.stub().resolves([{ id: 'session1', timeListening: 300, startedAt: Date.now() }]) } const fakeReq = { user: { ...user1.toJSON(), id: user1.id, username: user1.username, checkCanAccessLibraryItem: () => true }, params: { libraryItemId: libraryItem1.id }, query: {} } const fakeRes = { sendStatus: sinon.spy(), status: sinon.stub().returnsThis(), send: sinon.spy(), json: sinon.spy() } sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem) sinon.stub(Database.podcastEpisodeModel, 'findByPk').resolves(null) await MeController.getItemListeningSessions.bind(mockContext)(fakeReq, fakeRes) expect(fakeRes.json.calledOnce).to.be.true expect(fakeRes.sendStatus.called).to.be.false // Verify the payload structure const payload = fakeRes.json.firstCall.args[0] expect(payload).to.have.property('total') expect(payload).to.have.property('sessions') Database.libraryItemModel.getExpandedById.restore() Database.podcastEpisodeModel.findByPk.restore() }) it('should prevent user from viewing listening sessions for inaccessible library item (IDOR)', async () => { const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem2.id) const fakeReq = { user: user2, // user2 doesn't have access to library2 params: { libraryItemId: libraryItem2.id }, query: {} } const fakeRes = { sendStatus: sinon.spy(), status: sinon.stub().returnsThis(), send: sinon.spy(), json: sinon.spy() } sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem) sinon.stub(Database.podcastEpisodeModel, 'findByPk').resolves(null) await MeController.getItemListeningSessions.bind(apiRouter)(fakeReq, fakeRes) expect(fakeRes.sendStatus.calledWith(403)).to.be.true expect(fakeRes.json.called).to.be.false Database.libraryItemModel.getExpandedById.restore() Database.podcastEpisodeModel.findByPk.restore() }) it('should return 404 for non-existent library item', async () => { const fakeReq = { user: user1, params: { libraryItemId: 'non-existent-id' }, query: {} } const fakeRes = { sendStatus: sinon.spy(), status: sinon.stub().returnsThis(), send: sinon.spy(), json: sinon.spy() } sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(null) await MeController.getItemListeningSessions.bind(apiRouter)(fakeReq, fakeRes) expect(fakeRes.sendStatus.calledWith(404)).to.be.true Database.libraryItemModel.getExpandedById.restore() }) }) })