CVE-2022-0132: Server-Side Request Forgery (SSRF) (CWE-918)
value: peertube is vulnerable to Server-Side Request Forgery (SSRF)
CWE description: The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.
CVSS v3 base score: 7.5
Published: 2022-01-10T14:12Z
Repository: https://github.com/chocobozzz/peertube
Language: TypeScript
Files changed: server/middlewares/validators/videos/video-imports.ts, server/tests/api/check-params/video-imports.ts
Commit date: 2022-01-06 11:16:35 +0100
Commit message: Prevent video import on non unicast ips

Diff: diff --git a/server/middlewares/validators/videos/video-imports.ts b/server/middlewares/validators/videos/video-imports.ts
index 640139c73..e4b54283f 100644
--- a/server/middlewares/validators/videos/video-imports.ts
+++ b/server/middlewares/validators/videos/video-imports.ts
@@ -11,10 +11,11 @@ import { cleanUpReqFiles } from '../../../helpers/express-utils'
 import { logger } from '../../../helpers/logger'
 import { CONFIG } from '../../../initializers/config'
 import { CONSTRAINTS_FIELDS } from '../../../initializers/constants'
 import { areValidationErrors, doesVideoChannelOfAccountExist } from '../shared'
 import { getCommonVideoEditAttributes } from './videos'
+import { isValid as isIPValid, parse as parseIP } from 'ipaddr.js'
 
 const videoImportAddValidator = getCommonVideoEditAttributes().concat([
   body('channelId')
     .customSanitizer(toIntOrNull)
     .custom(isIdValid).withMessage('Should have correct video channel id'),
@@ -69,10 +70,27 @@ const videoImportAddValidator = getCommonVideoEditAttributes().concat([
       cleanUpReqFiles(req)
 
       return res.fail({ message: 'Should have a magnetUri or a targetUrl or a torrent file.' })
     }
 
+    if (req.body.targetUrl) {
+      const hostname = new URL(req.body.targetUrl).hostname
+
+      if (isIPValid(hostname)) {
+        const parsed = parseIP(hostname)
+
+        if (parsed.range() !== 'unicast') {
+          cleanUpReqFiles(req)
+
+          return res.fail({
+            status: HttpStatusCode.FORBIDDEN_403,
+            message: 'Cannot use non unicast IP as targetUrl.'
+          })
+        }
+      }
+    }
+
     if (!await isImportAccepted(req, res)) return cleanUpReqFiles(req)
 
     return next()
   }
 ])
diff --git a/server/tests/api/check-params/video-imports.ts b/server/tests/api/check-params/video-imports.ts
index d6d745488..6c31daa9b 100644
--- a/server/tests/api/check-params/video-imports.ts
+++ b/server/tests/api/check-params/video-imports.ts
@@ -106,10 +106,38 @@ describe('Test video imports API validator', function () {
       const fields = { ...baseCorrectParams, targetUrl: 'htt://hello' }
 
       await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
     })
 
+    it('Should fail with localhost', async function () {
+      const fields = { ...baseCorrectParams, targetUrl: 'http://localhost:8000' }
+
+      await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
+    })
+
+    it('Should fail with a private IP target urls', async function () {
+      const targetUrls = [
+        'http://127.0.0.1:8000',
+        'http://127.0.0.1',
+        'http://127.0.0.1/hello',
+        'https://192.168.1.42',
+        'http://192.168.1.42'
+      ]
+
+      for (const targetUrl of targetUrls) {
+        const fields = { ...baseCorrectParams, targetUrl }
+
+        await makePostBodyRequest({
+          url: server.url,
+          path,
+          token: server.accessToken,
+          fields,
+          expectedStatus: HttpStatusCode.FORBIDDEN_403
+        })
+      }
+    })
+
     it('Should fail with a long name', async function () {
       const fields = { ...baseCorrectParams, name: 'super'.repeat(65) }
 
       await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
     })

Fixed code: import { isValid as isIPValid, parse as parseIP } from 'ipaddr.js'
    if (req.body.targetUrl) {
      const hostname = new URL(req.body.targetUrl).hostname

      if (isIPValid(hostname)) {
        const parsed = parseIP(hostname)

        if (parsed.range() !== 'unicast') {
          cleanUpReqFiles(req)

          return res.fail({
            status: HttpStatusCode.FORBIDDEN_403,
            message: 'Cannot use non unicast IP as targetUrl.'
          })
        }
      }
    }

    it('Should fail with localhost', async function () {
      const fields = { ...baseCorrectParams, targetUrl: 'http://localhost:8000' }

      await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
    })

    it('Should fail with a private IP target urls', async function () {
      const targetUrls = [
        'http://127.0.0.1:8000',
        'http://127.0.0.1',
        'http://127.0.0.1/hello',
        'https://192.168.1.42',
        'http://192.168.1.42'
      ]

      for (const targetUrl of targetUrls) {
        const fields = { ...baseCorrectParams, targetUrl }

        await makePostBodyRequest({
          url: server.url,
          path,
          token: server.accessToken,
          fields,
          expectedStatus: HttpStatusCode.FORBIDDEN_403
        })
      }
    })