CVE-2022-0133: Improper Access Control (CWE-284)
value: peertube is vulnerable to Improper Access Control
CWE description: The product does not restrict or incorrectly restricts access to a resource from an unauthorized actor.
CVSS v3 base score: 7.5
Published: 2022-01-10T14:12Z
Repository: https://github.com/chocobozzz/peertube
Language: TypeScript
Files changed: server/controllers/api/videos/captions.ts, server/controllers/api/videos/files.ts, server/middlewares/validators/shared/videos.ts, server/middlewares/validators/videos/video-captions.ts, server/middlewares/validators/videos/videos.ts, server/tests/api/check-params/video-captions.ts
Commit date: 2022-01-06 13:27:29 +0100
Commit message: Prevent caption listing of private videos

Diff: diff --git a/server/controllers/api/videos/captions.ts b/server/controllers/api/videos/captions.ts
index 2d2213327c..aa7259ee9c 100644
--- a/server/controllers/api/videos/captions.ts
+++ b/server/controllers/api/videos/captions.ts
@@ -46,11 +46,11 @@ export {
 }
 
 // ---------------------------------------------------------------------------
 
 async function listVideoCaptions (req: express.Request, res: express.Response) {
-  const data = await VideoCaptionModel.listVideoCaptions(res.locals.videoId.id)
+  const data = await VideoCaptionModel.listVideoCaptions(res.locals.onlyVideo.id)
 
   return res.json(getFormattedObjects(data, data.length))
 }
 
 async function addVideoCaption (req: express.Request, res: express.Response) {
diff --git a/server/controllers/api/videos/files.ts b/server/controllers/api/videos/files.ts
index a8b32411d5..0fbda280e8 100644
--- a/server/controllers/api/videos/files.ts
+++ b/server/controllers/api/videos/files.ts
@@ -8,17 +8,19 @@ import {
   asyncMiddleware,
   authenticate,
   ensureUserHasRight,
   videoFileMetadataGetValidator,
   videoFilesDeleteHLSValidator,
-  videoFilesDeleteWebTorrentValidator
+  videoFilesDeleteWebTorrentValidator,
+  videosGetValidator
 } from '../../../middlewares'
 
 const lTags = loggerTagsFactory('api', 'video')
 const filesRouter = express.Router()
 
 filesRouter.get('/:id/metadata/:videoFileId',
+  asyncMiddleware(videosGetValidator),
   asyncMiddleware(videoFileMetadataGetValidator),
   asyncMiddleware(getVideoFileMetadata)
 )
 
 filesRouter.delete('/:id/hls',
diff --git a/server/middlewares/validators/shared/videos.ts b/server/middlewares/validators/shared/videos.ts
index 71b81654f5..fc978b63ad 100644
--- a/server/middlewares/validators/shared/videos.ts
+++ b/server/middlewares/validators/shared/videos.ts
@@ -1,18 +1,22 @@
-import { Response } from 'express'
+import { Request, Response } from 'express'
 import { loadVideo, VideoLoadType } from '@server/lib/model-loaders'
+import { authenticatePromiseIfNeeded } from '@server/middlewares/auth'
+import { VideoModel } from '@server/models/video/video'
 import { VideoChannelModel } from '@server/models/video/video-channel'
 import { VideoFileModel } from '@server/models/video/video-file'
 import {
   MUser,
   MUserAccountId,
+  MVideo,
   MVideoAccountLight,
   MVideoFormattableDetails,
   MVideoFullLight,
   MVideoId,
   MVideoImmutable,
-  MVideoThumbnail
+  MVideoThumbnail,
+  MVideoWithRights
 } from '@server/types/models'
 import { HttpStatusCode, UserRight } from '@shared/models'
 
 async function doesVideoExist (id: number | string, res: Response, fetchType: VideoLoadType = 'all') {
   const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined
@@ -87,10 +91,31 @@ async function doesVideoChannelOfAccountExist (channelId: number, user: MUserAcc
 
   res.locals.videoChannel = videoChannel
   return true
 }
 
+async function checkCanSeeVideoIfPrivate (req: Request, res: Response, video: MVideo, authenticateInQuery = false) {
+  if (!video.requiresAuth()) return true
+
+  const videoWithRights = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.id)
+
+  return checkCanSeePrivateVideo(req, res, videoWithRights, authenticateInQuery)
+}
+
+async function checkCanSeePrivateVideo (req: Request, res: Response, video: MVideoWithRights, authenticateInQuery = false) {
+  await authenticatePromiseIfNeeded(req, res, authenticateInQuery)
+
+  const user = res.locals.oauth ? res.locals.oauth.token.User : null
+
+  // Only the owner or a user that have blocklist rights can see the video
+  if (!user || !user.canGetVideo(video)) {
+    return false
+  }
+
+  return true
+}
+
 function checkUserCanManageVideo (user: MUser, video: MVideoAccountLight, right: UserRight, res: Response, onlyOwned = true) {
   // Retrieve the user who did the request
   if (onlyOwned && video.isOwned() === false) {
     res.fail({
       status: HttpStatusCode.FORBIDDEN_403,
@@ -118,7 +143,9 @@ function checkUserCanManageVideo (user: MUser, video: MVideoAccountLight, right:
 
 export {
   doesVideoChannelOfAccountExist,
   doesVideoExist,
   doesVideoFileOfVideoExist,
-  checkUserCanManageVideo
+  checkUserCanManageVideo,
+  checkCanSeeVideoIfPrivate,
+  checkCanSeePrivateVideo
 }
diff --git a/server/middlewares/validators/videos/video-captions.ts b/server/middlewares/validators/videos/video-captions.ts
index 38321ccf9a..4fc4c8ec5f 100644
--- a/server/middlewares/validators/videos/video-captions.ts
+++ b/server/middlewares/validators/videos/video-captions.ts
@@ -1,13 +1,20 @@
 import express from 'express'
 import { body, param } from 'express-validator'
-import { UserRight } from '../../../../shared'
+import { HttpStatusCode, UserRight } from '../../../../shared'
 import { isVideoCaptionFile, isVideoCaptionLanguageValid } from '../../../helpers/custom-validators/video-captions'
 import { cleanUpReqFiles } from '../../../helpers/express-utils'
 import { logger } from '../../../helpers/logger'
 import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../../initializers/constants'
-import { areValidationErrors, checkUserCanManageVideo, doesVideoCaptionExist, doesVideoExist, isValidVideoIdParam } from '../shared'
+import {
+  areValidationErrors,
+  checkCanSeeVideoIfPrivate,
+  checkUserCanManageVideo,
+  doesVideoCaptionExist,
+  doesVideoExist,
+  isValidVideoIdParam
+} from '../shared'
 
 const addVideoCaptionValidator = [
   isValidVideoIdParam('videoId'),
 
   param('captionLanguage')
@@ -62,11 +69,20 @@ const listVideoCaptionsValidator = [
 
   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
     logger.debug('Checking listVideoCaptions parameters', { parameters: req.params })
 
     if (areValidationErrors(req, res)) return
-    if (!await doesVideoExist(req.params.videoId, res, 'id')) return
+    if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return
+
+    const video = res.locals.onlyVideo
+
+    if (!await checkCanSeeVideoIfPrivate(req, res, video)) {
+      return res.fail({
+        status: HttpStatusCode.FORBIDDEN_403,
+        message: 'Cannot list captions of private/internal/blocklisted video'
+      })
+    }
 
     return next()
   }
 ]
 
diff --git a/server/middlewares/validators/videos/videos.ts b/server/middlewares/validators/videos/videos.ts
index 3ebdbc33d1..782f495e83 100644
--- a/server/middlewares/validators/videos/videos.ts
+++ b/server/middlewares/validators/videos/videos.ts
@@ -49,13 +49,13 @@ import { getVideoWithAttributes } from '../../../helpers/video'
 import { CONFIG } from '../../../initializers/config'
 import { CONSTRAINTS_FIELDS, OVERVIEWS } from '../../../initializers/constants'
 import { isLocalVideoAccepted } from '../../../lib/moderation'
 import { Hooks } from '../../../lib/plugins/hooks'
 import { VideoModel } from '../../../models/video/video'
-import { authenticatePromiseIfNeeded } from '../../auth'
 import {
   areValidationErrors,
+  checkCanSeePrivateVideo,
   checkUserCanManageVideo,
   doesVideoChannelOfAccountExist,
   doesVideoExist,
   doesVideoFileOfVideoExist,
   isValidVideoIdParam
@@ -315,23 +315,16 @@ const videosCustomGetValidator = (
 
       const video = getVideoWithAttributes(res) as MVideoFullLight
 
       // Video private or blacklisted
       if (video.requiresAuth()) {
-        await authenticatePromiseIfNeeded(req, res, authenticateInQuery)
+        if (await checkCanSeePrivateVideo(req, res, video, authenticateInQuery)) return next()
 
-        const user = res.locals.oauth ? res.locals.oauth.token.User : null
-
-        // Only the owner or a user that have blocklist rights can see the video
-        if (!user || !user.canGetVideo(video)) {
-          return res.fail({
-            status: HttpStatusCode.FORBIDDEN_403,
-            message: 'Cannot get this private/internal or blocklisted video'
-          })
-        }
-
-        return next()
+        return res.fail({
+          status: HttpStatusCode.FORBIDDEN_403,
+          message: 'Cannot get this private/internal or blocklisted video'
+        })
       }
 
       // Video is public, anyone can access it
       if (video.privacy === VideoPrivacy.PUBLIC) return next()
 
diff --git a/server/tests/api/check-params/video-captions.ts b/server/tests/api/check-params/video-captions.ts
index 90f4293145..84c6c1355c 100644
--- a/server/tests/api/check-params/video-captions.ts
+++ b/server/tests/api/check-params/video-captions.ts
@@ -9,18 +9,19 @@ import {
   makeGetRequest,
   makeUploadRequest,
   PeerTubeServer,
   setAccessTokensToServers
 } from '@shared/extra-utils'
-import { HttpStatusCode, VideoCreateResult } from '@shared/models'
+import { HttpStatusCode, VideoCreateResult, VideoPrivacy } from '@shared/models'
 
 describe('Test video captions API validator', function () {
   const path = '/api/v1/videos/'
 
   let server: PeerTubeServer
   let userAccessToken: string
   let video: VideoCreateResult
+  let privateVideo: VideoCreateResult
 
   // ---------------------------------------------------------------
 
   before(async function () {
     this.timeout(30000)
@@ -28,10 +29,11 @@ describe('Test video captions API validator', function () {
     server = await createSingleServer(1)
 
     await setAccessTokensToServers([ server ])
 
     video = await server.videos.upload()
+    privateVideo = await server.videos.upload({ attributes: { privacy: VideoPrivacy.PRIVATE } })
 
     {
       const user = {
         username: 'user1',
         password: 'my super password'
@@ -202,12 +204,36 @@ describe('Test video captions API validator', function () {
         path: path + '4da6fde3-88f7-4d16-b119-108df5630b06/captions',
         expectedStatus: HttpStatusCode.NOT_FOUND_404
       })
     })
 
+    it('Should fail with a private video without token', async function () {
+      await makeGetRequest({
+        url: server.url,
+        path: path + privateVideo.shortUUID + '/captions',
+        expectedStatus: HttpStatusCode.UNAUTHORIZED_401
+      })
+    })
+
+    it('Should fail with another user token', async function () {
+      await makeGetRequest({
+        url: server.url,
+        token: userAccessToken,
+        path: path + privateVideo.shortUUID + '/captions',
+        expectedStatus: HttpStatusCode.FORBIDDEN_403
+      })
+    })
+
     it('Should success with the correct parameters', async function () {
       await makeGetRequest({ url: server.url, path: path + video.shortUUID + '/captions', expectedStatus: HttpStatusCode.OK_200 })
+
+      await makeGetRequest({
+        url: server.url,
+        path: path + privateVideo.shortUUID + '/captions',
+        token: server.accessToken,
+        expectedStatus: HttpStatusCode.OK_200
+      })
     })
   })
 
   describe('When deleting video caption', function () {
     it('Should fail without a valid uuid', async function () {

Vulnerable code:   const data = await VideoCaptionModel.listVideoCaptions(res.locals.videoId.id)
  videoFilesDeleteWebTorrentValidator
import { Response } from 'express'
  MVideoThumbnail
  checkUserCanManageVideo
import { UserRight } from '../../../../shared'
import { areValidationErrors, checkUserCanManageVideo, doesVideoCaptionExist, doesVideoExist, isValidVideoIdParam } from '../shared'
    if (!await doesVideoExist(req.params.videoId, res, 'id')) return
import { authenticatePromiseIfNeeded } from '../../auth'
        await authenticatePromiseIfNeeded(req, res, authenticateInQuery)
        const user = res.locals.oauth ? res.locals.oauth.token.User : null

        // Only the owner or a user that have blocklist rights can see the video
        if (!user || !user.canGetVideo(video)) {
          return res.fail({
            status: HttpStatusCode.FORBIDDEN_403,
            message: 'Cannot get this private/internal or blocklisted video'
          })
        }

        return next()
import { HttpStatusCode, VideoCreateResult } from '@shared/models'
Fixed code:   const data = await VideoCaptionModel.listVideoCaptions(res.locals.onlyVideo.id)
  videoFilesDeleteWebTorrentValidator,
  videosGetValidator
  asyncMiddleware(videosGetValidator),
import { Request, Response } from 'express'
import { authenticatePromiseIfNeeded } from '@server/middlewares/auth'
import { VideoModel } from '@server/models/video/video'
  MVideo,
  MVideoThumbnail,
  MVideoWithRights
async function checkCanSeeVideoIfPrivate (req: Request, res: Response, video: MVideo, authenticateInQuery = false) {
  if (!video.requiresAuth()) return true

  const videoWithRights = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.id)

  return checkCanSeePrivateVideo(req, res, videoWithRights, authenticateInQuery)
}

async function checkCanSeePrivateVideo (req: Request, res: Response, video: MVideoWithRights, authenticateInQuery = false) {
  await authenticatePromiseIfNeeded(req, res, authenticateInQuery)

  const user = res.locals.oauth ? res.locals.oauth.token.User : null

  // Only the owner or a user that have blocklist rights can see the video
  if (!user || !user.canGetVideo(video)) {
    return false
  }

  return true
}

  checkUserCanManageVideo,
  checkCanSeeVideoIfPrivate,
  checkCanSeePrivateVideo
import { HttpStatusCode, UserRight } from '../../../../shared'
import {
  areValidationErrors,
  checkCanSeeVideoIfPrivate,
  checkUserCanManageVideo,
  doesVideoCaptionExist,
  doesVideoExist,
  isValidVideoIdParam
} from '../shared'
    if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return

    const video = res.locals.onlyVideo

    if (!await checkCanSeeVideoIfPrivate(req, res, video)) {
      return res.fail({
        status: HttpStatusCode.FORBIDDEN_403,
        message: 'Cannot list captions of private/internal/blocklisted video'
      })
    }
  checkCanSeePrivateVideo,
        if (await checkCanSeePrivateVideo(req, res, video, authenticateInQuery)) return next()
        return res.fail({
          status: HttpStatusCode.FORBIDDEN_403,
          message: 'Cannot get this private/internal or blocklisted video'
        })
import { HttpStatusCode, VideoCreateResult, VideoPrivacy } from '@shared/models'
  let privateVideo: VideoCreateResult
    privateVideo = await server.videos.upload({ attributes: { privacy: VideoPrivacy.PRIVATE } })
    it('Should fail with a private video without token', async function () {
      await makeGetRequest({
        url: server.url,
        path: path + privateVideo.shortUUID + '/captions',
        expectedStatus: HttpStatusCode.UNAUTHORIZED_401
      })
    })

    it('Should fail with another user token', async function () {
      await makeGetRequest({
        url: server.url,
        token: userAccessToken,
        path: path + privateVideo.shortUUID + '/captions',
        expectedStatus: HttpStatusCode.FORBIDDEN_403
      })
    })


      await makeGetRequest({
        url: server.url,
        path: path + privateVideo.shortUUID + '/captions',
        token: server.accessToken,
        expectedStatus: HttpStatusCode.OK_200
      })