CVE-2023-43256: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') (CWE-22)
value: A path traversal in Gladys Assistant v4.26.1 and below allows authenticated attackers to extract sensitive files in the host machine by exploiting a non-sanitized user input.
CWE description: The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory.
CVSS v3 base score: 6.5
Published: 2023-09-25T14:15Z
Repository: https://github.com/GladysAssistant/Gladys
Language: JavaScript
Files changed: server/services/rtsp-camera/api/rtspCamera.controller.js, server/services/rtsp-camera/utils/validateStreamParams.js, server/test/services/rtsp-camera/controllers/rtspCamera.controller.test.js
Commit date: 2023-08-25 10:53:12 +0200
Commit message: Camera Streaming: Fix validation of session_id and filename (#1847)


Diff: diff --git a/server/services/rtsp-camera/api/rtspCamera.controller.js b/server/services/rtsp-camera/api/rtspCamera.controller.js
index 8c5a0b49..c190392d 100644
--- a/server/services/rtsp-camera/api/rtspCamera.controller.js
+++ b/server/services/rtsp-camera/api/rtspCamera.controller.js
@@ -1,10 +1,11 @@
 const fs = require('fs');
 const path = require('path');
 
 const logger = require('../../../utils/logger');
-const { Error404 } = require('../../../utils/httpErrors');
+const { Error404, Error400 } = require('../../../utils/httpErrors');
+const { validateFilename, validateSessionId } = require('../utils/validateStreamParams');
 const asyncMiddleware = require('../../../api/middlewares/asyncMiddleware');
 
 module.exports = function RtspCameraController(gladys, rtspCameraHandler) {
   /**
    * @api {post} /api/v1/service/rtsp-camera/camera/test Test connection
@@ -55,17 +56,22 @@ module.exports = function RtspCameraController(gladys, rtspCameraHandler) {
    * @apiName getStreamingFile
    * @apiGroup RtspCamera
    */
   async function getStreamingFile(req, res) {
     try {
+      validateSessionId(req.params.folder);
+      validateFilename(req.params.file);
       const filePath = path.join(gladys.config.tempFolder, req.params.folder, req.params.file);
       const filestream = fs.createReadStream(filePath);
       filestream.on('error', (err) => {
         res.status(404).end();
       });
       filestream.pipe(res);
     } catch (e) {
+      if (e instanceof Error400) {
+        throw e;
+      }
       logger.warn(e);
       throw new Error404('FILE_NOT_FOUND');
     }
   }
 
diff --git a/server/services/rtsp-camera/utils/validateStreamParams.js b/server/services/rtsp-camera/utils/validateStreamParams.js
new file mode 100644
index 00000000..65853415
--- /dev/null
+++ b/server/services/rtsp-camera/utils/validateStreamParams.js
@@ -0,0 +1,28 @@
+const { Error400 } = require('../../../utils/httpErrors');
+
+const SESSION_ID_REGEX = /^camera-[a-zA-Z0-9-_]+$/;
+
+// Session_id usually looks like "camera-7835d25d-b8ce-4824-a235-23637f778f83-39-50-13"
+const validateSessionId = (sessionId) => {
+  if (!SESSION_ID_REGEX.test(sessionId)) {
+    throw new Error400('Invalid session id');
+  }
+};
+
+const AUTHORIZED_FILENAMES = ['index.m3u8', 'index.m3u8.key', 'key_info_file.txt'];
+const HLS_CHUNK_REGEX = /index[0-9]+.ts/;
+
+const validateFilename = (filename) => {
+  if (AUTHORIZED_FILENAMES.includes(filename)) {
+    return;
+  }
+
+  if (!HLS_CHUNK_REGEX.test(filename)) {
+    throw new Error400('Invalid filename');
+  }
+};
+
+module.exports = {
+  validateSessionId,
+  validateFilename,
+};
diff --git a/server/test/services/rtsp-camera/controllers/rtspCamera.controller.test.js b/server/test/services/rtsp-camera/controllers/rtspCamera.controller.test.js
index 41650a2a..84fb92f8 100644
--- a/server/test/services/rtsp-camera/controllers/rtspCamera.controller.test.js
+++ b/server/test/services/rtsp-camera/controllers/rtspCamera.controller.test.js
@@ -97,11 +97,11 @@ describe('camera controller test', () => {
       req,
       res,
     );
     assert.calledWith(rtspCameraService.liveActivePing, 'my-camera');
   });
-  it('should get streaming file', async () => {
+  it('should get index.m3u8 file', async () => {
     const rtspCameraController = RtspCameraController(gladys, rtspCameraService);
     const req = {
       params: {
         folder: 'camera-1',
         file: 'index.m3u8',
@@ -113,16 +113,32 @@ describe('camera controller test', () => {
     await rtspCameraController['get /api/v1/service/rtsp-camera/camera/streaming/:folder/:file'].controller(
       req,
       resWriteStream,
     );
   });
+  it('should get index1.ts file', async () => {
+    const rtspCameraController = RtspCameraController(gladys, rtspCameraService);
+    const req = {
+      params: {
+        folder: 'camera-1',
+        file: 'index1.ts',
+      },
+    };
+    await fse.ensureDir(path.join(gladys.config.tempFolder, 'camera-1'));
+    await fse.writeFile(path.join(gladys.config.tempFolder, 'camera-1', 'index1.ts'), 'test-toto-content');
+    const resWriteStream = fse.createWriteStream(path.join(gladys.config.tempFolder, 'camera-1', 'result.txt'));
+    await rtspCameraController['get /api/v1/service/rtsp-camera/camera/streaming/:folder/:file'].controller(
+      req,
+      resWriteStream,
+    );
+  });
   it('should return 404, file not found (res.status) ', async () => {
     const rtspCameraController = RtspCameraControllerWithFsMocked(gladys, rtspCameraService);
     const req = {
       params: {
         folder: 'camera-1',
-        file: 'FILE_NOT_FOUND',
+        file: 'index12212.ts',
       },
     };
     const resWriteStream = fse.createWriteStream(path.join(gladys.config.tempFolder, 'camera-1', 'result.txt'));
     const end = fake.returns(null);
     // @ts-ignore
@@ -145,6 +161,36 @@ describe('camera controller test', () => {
       req,
       resWriteStream,
     );
     await chaiAssert.isRejected(promise, 'FILE_NOT_FOUND');
   });
+  it('should return 400, bad request, invalid filename', async () => {
+    const rtspCameraController = RtspCameraController(gladys, rtspCameraService);
+    const req = {
+      params: {
+        folder: 'camera-1',
+        file: 'lalalalala',
+      },
+    };
+    const resWriteStream = {};
+    const promise = rtspCameraController['get /api/v1/service/rtsp-camera/camera/streaming/:folder/:file'].controller(
+      req,
+      resWriteStream,
+    );
+    await chaiAssert.isRejected(promise, 'Invalid filename');
+  });
+  it('should return 400, bad request, invalid session id', async () => {
+    const rtspCameraController = RtspCameraController(gladys, rtspCameraService);
+    const req = {
+      params: {
+        folder: '.....',
+        file: 'lalalalala',
+      },
+    };
+    const resWriteStream = {};
+    const promise = rtspCameraController['get /api/v1/service/rtsp-camera/camera/streaming/:folder/:file'].controller(
+      req,
+      resWriteStream,
+    );
+    await chaiAssert.isRejected(promise, 'Invalid session id');
+  });
 });

Vulnerable code: const { Error404 } = require('../../../utils/httpErrors');
  it('should get streaming file', async () => {
        file: 'FILE_NOT_FOUND',
Fixed code: const { Error404, Error400 } = require('../../../utils/httpErrors');
const { validateFilename, validateSessionId } = require('../utils/validateStreamParams');
      validateSessionId(req.params.folder);
      validateFilename(req.params.file);
      if (e instanceof Error400) {
        throw e;
      }
const { Error400 } = require('../../../utils/httpErrors');

const SESSION_ID_REGEX = /^camera-[a-zA-Z0-9-_]+$/;

// Session_id usually looks like "camera-7835d25d-b8ce-4824-a235-23637f778f83-39-50-13"
const validateSessionId = (sessionId) => {
  if (!SESSION_ID_REGEX.test(sessionId)) {
    throw new Error400('Invalid session id');
  }
};

const AUTHORIZED_FILENAMES = ['index.m3u8', 'index.m3u8.key', 'key_info_file.txt'];
const HLS_CHUNK_REGEX = /index[0-9]+.ts/;

const validateFilename = (filename) => {
  if (AUTHORIZED_FILENAMES.includes(filename)) {
    return;
  }

  if (!HLS_CHUNK_REGEX.test(filename)) {
    throw new Error400('Invalid filename');
  }
};

module.exports = {
  validateSessionId,
  validateFilename,
};
  it('should get index.m3u8 file', async () => {
  it('should get index1.ts file', async () => {
    const rtspCameraController = RtspCameraController(gladys, rtspCameraService);
    const req = {
      params: {
        folder: 'camera-1',
        file: 'index1.ts',
      },
    };
    await fse.ensureDir(path.join(gladys.config.tempFolder, 'camera-1'));
    await fse.writeFile(path.join(gladys.config.tempFolder, 'camera-1', 'index1.ts'), 'test-toto-content');
    const resWriteStream = fse.createWriteStream(path.join(gladys.config.tempFolder, 'camera-1', 'result.txt'));
    await rtspCameraController['get /api/v1/service/rtsp-camera/camera/streaming/:folder/:file'].controller(
      req,
      resWriteStream,
    );
  });
        file: 'index12212.ts',
  it('should return 400, bad request, invalid filename', async () => {
    const rtspCameraController = RtspCameraController(gladys, rtspCameraService);
    const req = {
      params: {
        folder: 'camera-1',
        file: 'lalalalala',
      },
    };
    const resWriteStream = {};
    const promise = rtspCameraController['get /api/v1/service/rtsp-camera/camera/streaming/:folder/:file'].controller(
      req,
      resWriteStream,
    );
    await chaiAssert.isRejected(promise, 'Invalid filename');
  });
  it('should return 400, bad request, invalid session id', async () => {
    const rtspCameraController = RtspCameraController(gladys, rtspCameraService);
    const req = {
      params: {
        folder: '.....',
        file: 'lalalalala',
      },
    };
    const resWriteStream = {};
    const promise = rtspCameraController['get /api/v1/service/rtsp-camera/camera/streaming/:folder/:file'].controller(
      req,
      resWriteStream,
    );
    await chaiAssert.isRejected(promise, 'Invalid session id');
  });