CVE-2021-31590 (published 2021-07-19T20:15Z)

PwnDoc all versions until 0.4.0 (2021-08-23) has incorrect JSON Webtoken handling, leading to incorrect access control. With a valid JSON Webtoken that is used for authentication and authorization, a user can keep his admin privileges even if he is downgraded to the "user" privilege. Even after a user\'s account is deleted, the user can still access the administration panel (and add or delete users) and has complete access to the system.

Severity: HIGH
CVSS v2 base score: 9.0 (AV:N/AC:L/Au:S/C:C/I:C/A:C)
CVSS v3 base score: 8.8, HIGH (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H)

Weakness information:
cwe_id: NVD-CWE-Other
cwe_name: Other
description: NVD is only using a subset of CWE for mapping instead of the entire CWE, and the weakness type is not covered by that subset.
extended_description: Insufficient Information

Fix information:
repo_url: https://github.com/pwndoc/pwndoc
commit_details: repo_url: https://github.com/pwndoc/pwndoc
author: yeln4ts
author_date: 2021-06-21 15:39:49-04:00
msg: Update JWT generation

JWT is now dynamic
config files have moved
repository: repo_url: https://github.com/pwndoc/pwndoc
repo_name: pwndoc/pwndoc
description: Pentest Report Generator
repo_language: JavaScript
owner: pwndoc
file_changes: filename: .gitignore
change_type: ModificationType.MODIFY
diff: @@ -9,6 +9,10 @@ mongo-data*
 .quasar
 report-templates
 custom-generator.js
+
+# Configuration files
+config.json
+report.json
 roles.json
 app-settings.json
 

programming_language: Markdown
filename: app.js
change_type: ModificationType.MODIFY
diff: @@ -10,7 +10,7 @@ var cookieParser = require('cookie-parser')
 
 // Get configuration
 var env = process.env.NODE_ENV || 'dev';
-var config = require('./config.json')[env];
+var config = require('./config/config.json')[env];
 global.__basedir = __dirname;
 
 // Database connection

programming_language: JavaScript
filename: config.json
change_type: ModificationType.RENAME
diff: @@ -6,7 +6,8 @@
             "name": "pwndoc",
             "server": "mongo-pwndoc-dev",
             "port": "27017"
-        }
+        },
+        "jwtSecret": "eb942782a7f0842c51b112d37ff2993de45307831eea267a3acc012bf7550711"
     },
     "prod": {
         "port": 4242,
@@ -26,4 +27,4 @@
             "port": "27017"
         }
     }
-}
+}
\ No newline at end of file

programming_language: JavaScript
filename: auth.js
change_type: ModificationType.MODIFY
diff: @@ -1,6 +1,16 @@
-// Secret for JWT tokens creation (make it dynamic for production)
-var jwtSecret = "ASy4FVjsXNLQl09LbieroWsjO5UXjvX5";
-exports.jwtSecret = jwtSecret;
+// Dynamic generation of JWT Secret if not exist (different for each environnment)
+var fs = require('fs')
+var env = process.env.NODE_ENV || 'dev'
+var config = require('../config/config.json')
+
+if (!config[env].jwtSecret) {
+    config[env].jwtSecret = require('crypto').randomBytes(32).toString('hex')
+    var configString = JSON.stringify(config, null, 4)
+    fs.writeFileSync(`${__basedir}/config/config.json`, configString)
+}
+
+var jwtSecret = config[env].jwtSecret
+exports.jwtSecret = jwtSecret
 
 /*  ROLES LOGIC
 
@@ -62,7 +72,7 @@ var builtInRoles = {
 }
 
 try {
-    var customRoles = require('./roles.json')}
+    var customRoles = require('../config/roles.json')}
 catch(error) {
     var customRoles = []
 }

programming_language: CoffeeScript
filename: report-generator.js
change_type: ModificationType.MODIFY
diff: @@ -9,7 +9,7 @@ var utils = require('./utils');
 var html2ooxml = require('./html2ooxml');
 var _ = require('lodash');
 var Image = require('mongoose').model('Image');
-var reportConfig = require('./report.json');
+var reportConfig = require('../config/report.json');
 
 // Generate document with docxtemplater
 async function generateDoc(audit) {
@@ -19,7 +19,7 @@ async function generateDoc(audit) {
 
     var zip = new JSZip(content);
 
-    var settings = JSON.parse(fs.readFileSync(`${__basedir}/lib/app-settings.json`));
+    var settings = JSON.parse(fs.readFileSync(`${__basedir}/config/app-settings.json`));
 
     var opts = {};
     // opts.centered = true;

programming_language: JavaScript
filename: settings.js
change_type: ModificationType.MODIFY
diff: @@ -12,7 +12,7 @@ module.exports = function(app) {
     // Get settings
     app.get("/api/settings", acl.hasPermission('settings:read'), function(req, res) {
         try {
-            var settings = JSON.parse(fs.readFileSync(`${__basedir}/lib/app-settings.json`))            
+            var settings = JSON.parse(fs.readFileSync(`${__basedir}/config/app-settings.json`))            
             Object.keys(defaultSettings).forEach(key => {
                 if (!(key in settings))
                     settings[key] = defaultSettings[key]
@@ -34,7 +34,7 @@ module.exports = function(app) {
         })
         var settingsString = JSON.stringify(settings, null, 4)
         try {
-            fs.writeFileSync(`${__basedir}/lib/app-settings.json`, settingsString)
+            fs.writeFileSync(`${__basedir}/config/app-settings.json`, settingsString)
             Response.Ok(res, settings)
         }
         catch(err) {

programming_language: JavaScript
filename: docker-compose.yml
change_type: ModificationType.MODIFY
diff: @@ -19,7 +19,7 @@ services:
     container_name: pwndoc-backend
     volumes:
       - ./backend/report-templates:/app/report-templates
-      - ./backend/src/lib:/app/src/lib
+      - ./backend/src/config:/app/src/config
     depends_on:
       - mongodb
     restart: always

programming_language: Ruby
filename: roles.md
change_type: ModificationType.MODIFY
diff: @@ -55,7 +55,7 @@ This role has full permissions access
 
 ## Create additional Roles
 
-Custom roles can be defined in `backend/src/lib/roles.json`
+Custom roles can be defined in `backend/src/config/roles.json`
 The format is:
 
 ```

programming_language: Markdown
repo_url: https://github.com/pwndoc/pwndoc
commit_details: repo_url: https://github.com/pwndoc/pwndoc
author: yeln4ts
author_date: 2021-07-13 11:21:24-04:00
msg: Update Session management using refresh token

Added refresh token with lifetime of 7 days
Token has now a lifetime of 15min
Refresh token is updated on the frontend each time application is loaded or every 14min
Each refresh token is associated with a sessionId allowing to have multiple sessions on different devices
repository: repo_url: https://github.com/pwndoc/pwndoc
repo_name: pwndoc/pwndoc
description: Pentest Report Generator
repo_language: JavaScript
owner: pwndoc
file_changes: filename: user.js
change_type: ModificationType.MODIFY
diff: @@ -98,7 +98,7 @@ UserSchema.statics.updateProfile = function (username, user) {
                 throw({fn: 'Unauthorized', message: 'Current password is invalid'});
         })
         .then(function() {
-            var token = jwt.sign(payload, auth.jwtSecret, {expiresIn: '24h'});
+            var token = jwt.sign(payload, auth.jwtSecret, {expiresIn: '15 minutes'});
             resolve({token: `JWT ${token}`});
         })
         .catch(function(err) {
@@ -139,7 +139,7 @@ UserSchema.statics.updateRefreshToken = function (refreshToken, userAgent) {
         var newRefreshToken = ""
         try {
             var decoded = jwt.verify(refreshToken, auth.jwtRefreshSecret)
-            var username = decoded.username
+            var userId = decoded.userId
             var sessionId = decoded.sessionId
             var expiration = decoded.exp
         }
@@ -149,7 +149,7 @@ UserSchema.statics.updateRefreshToken = function (refreshToken, userAgent) {
             else
                 throw({fn: 'Unauthorized', message: 'Invalid refreshToken'})
         }
-        var query = this.findOne({username: username})
+        var query = this.findById(userId)
         query.exec()
         .then(row => {
             if (row) {
@@ -185,17 +185,17 @@ UserSchema.statics.updateRefreshToken = function (refreshToken, userAgent) {
                 var foundIndex = row.refreshTokens.findIndex(e => e.sessionId === sessionId)
                 if (foundIndex === -1) { // Not found
                     sessionId = generateUUID()
-                    newRefreshToken = jwt.sign({sessionId: sessionId, username: username}, auth.jwtRefreshSecret, {expiresIn: '7 days'})
+                    newRefreshToken = jwt.sign({sessionId: sessionId, userId: userId}, auth.jwtRefreshSecret, {expiresIn: '7 days'})
                     row.refreshTokens.push({sessionId: sessionId, userAgent: userAgent, token:newRefreshToken})
                  }
                 else {
-                    newRefreshToken = jwt.sign({sessionId: sessionId, username: username, exp: expiration}, auth.jwtRefreshSecret)
+                    newRefreshToken = jwt.sign({sessionId: sessionId, userId: userId, exp: expiration}, auth.jwtRefreshSecret)
                     row.refreshTokens[foundIndex].token = newRefreshToken
                 }
                 return row.save()
             }
             else
-                reject({fn: 'NotFound', message: 'User not found'})
+                reject({fn: 'NotFound', message: 'Session not found'})
         })
         .then(() => {
             resolve({token: token, refreshToken: newRefreshToken})
@@ -210,9 +210,9 @@ UserSchema.statics.updateRefreshToken = function (refreshToken, userAgent) {
 }
 
 // Remove session
-UserSchema.statics.removeSession = function (username, sessionId) {
+UserSchema.statics.removeSession = function (userId, sessionId) {
     return new Promise((resolve, reject) => {
-        var query = this.findOne({username: username})
+        var query = this.findById(userId)
         query.exec()
         .then(row => {
             if (row) {
@@ -243,12 +243,11 @@ UserSchema.statics.removeSession = function (username, sessionId) {
 UserSchema.methods.getToken = function (userAgent) {
     return new Promise((resolve, reject) => {
         var user = this;
-        var token = ""
         var query = User.findOne({username: user.username});
         query.exec()
         .then(function(row) {
             if (row && bcrypt.compareSync(user.password, row.password)) {
-                var refreshToken = jwt.sign({sessionId: null, username: row.username}, auth.jwtRefreshSecret)
+                var refreshToken = jwt.sign({sessionId: null, userId: row._id}, auth.jwtRefreshSecret)
                 return User.updateRefreshToken(refreshToken, userAgent)
             }
             else

programming_language: JavaScript
filename: user.js
change_type: ModificationType.MODIFY
diff: @@ -32,7 +32,7 @@ module.exports = function(app) {
     });
 
     // Remove token cookie
-    app.get("/api/users/destroytoken", function(req, res) {
+    app.delete("/api/users/refreshtoken", function(req, res) {
         var token = req.cookies['refreshToken']
         try {
             var decoded = jwt.verify(token, jwtRefreshSecret)
@@ -46,7 +46,7 @@ module.exports = function(app) {
                 Response.Unauthorized(res, 'Invalid refreshToken')
             return
         }
-        User.removeSession(decoded.username, decoded.sessionId)
+        User.removeSession(decoded.userId, decoded.sessionId)
         .then(msg => {
             res.clearCookie('token')
             res.clearCookie('refreshToken')
@@ -150,9 +150,10 @@ module.exports = function(app) {
                     newUser.username = req.body.username;
                     newUser.password = req.body.password;
 
-                    newUser.getToken()
+                    newUser.getToken(req.headers['user-agent'])
                     .then(msg => {
-                        res.cookie('token', msg.token, {secure: true, httpOnly: true})
+                        res.cookie('token', `JWT ${msg.token}`, {secure: true, httpOnly: true})
+                        res.cookie('refreshToken', msg.refreshToken, {secure: true, httpOnly: true, path: '/api/users/refreshtoken'})
                         Response.Created(res, msg)
                     })
                     .catch(err => Response.Internal(res, err))

programming_language: JavaScript
filename: auth.js
change_type: ModificationType.MODIFY
diff: @@ -1,15 +1,37 @@
 import User from '@/services/user';
 
-export default ({ app, router, Vue }) => {
+export default async ({ urlPath, router, redirect }) => {
+  // Launch refresh token countdown 840000=14min if not on login page
+  setInterval(() => {
+    User.refreshToken()
+    .then()
+    .catch(err => {
+      if (!router.currentRoute.path.startsWith('/login'))
+        if (err === 'Expired refreshToken')
+          redirect('/login?tokenError=2')
+        else
+          redirect('/login')
+    })
+  }, 840000)
+
+  // Call refreshToken when loading app and redirect to login if error
+  try {
+    await User.refreshToken()
+  }
+  catch(err) {
+    if (!urlPath.startsWith('/login'))
+      if (err === 'Expired refreshToken')
+        redirect('/login?tokenError=2')
+      else
+        redirect('/login')
+  }
+
   router.beforeEach((to, from, next) => {
     if (to.path === '/login') {
-      User.checkToken()
-      .then(() => {
+      if (User.isAuth())
         next('/')
-      })
-      .catch((error) => {
+      else
         next()
-      })
     }
     else {
       next()

programming_language: TypeScript
filename: axios.js
change_type: ModificationType.MODIFY
diff: @@ -1,23 +1,59 @@
 import axios from 'axios'
 import User from '@/services/user'
+import Router from '../router'
 
 const axiosInstance = axios.create({
   baseURL: `${window.location.origin}/api`
 })
 
-export default ({ Vue }) => {
+var refreshPending = false
+var requestsQueue = []
 
-  // Redirect to login if response is 401 (Unauthenticated)
-  axiosInstance.interceptors.response.use(response => {
+// Redirect to login if response is 401 (Unauthenticated)
+axiosInstance.interceptors.response.use(
+  response => {
     return response
-  }, error => {
-   if (error.response.status === 401) {
-     if (window.location.pathname !== '/login')
-      window.location = '/login'
-   }
-  return error
-  })
+  }, 
+  error => {
+    const originalRequest = error.config
+
+    // 401 after User.refreshToken function call
+    if (error.response.status === 401 && originalRequest.url.endsWith('/users/refreshtoken')) {
+      User.clear()
+      return Promise.reject(error)
+    }
+
+    // 401 after login request
+    if (error.response.status === 401 && originalRequest.url.endsWith('/users/token')) {
+      return Promise.reject(error)
+    }
 
+    // All other 401 calls
+    if (error.response.status === 401) {
+      if (!refreshPending) {
+        refreshPending = true
+        axiosInstance.get('/users/refreshtoken')
+        .then(() => {
+          requestsQueue.forEach(e => e())
+          requestsQueue = []
+        })
+        .catch(err => {
+          Router.push('/login')
+          return Promise.reject(error)
+          
+        })
+        .finally(() => {
+          refreshPending = false
+        })
+      }
+      return new Promise((resolve) => {
+        requestsQueue.push(() => resolve(axiosInstance.request(originalRequest)))
+      })
+    }
+    return Promise.reject(error)
+  }
+)
+
+export default ({ Vue }) => {
   Vue.prototype.$axios = axiosInstance
-  User.refreshToken()
 }

programming_language: JavaScript
filename: profile.js
change_type: ModificationType.MODIFY
diff: @@ -44,7 +44,7 @@ export default {
 
             UserService.updateProfile(this.user)
             .then((data) => {
-                UserService.checkToken()
+                UserService.refreshToken()
                 Notify.create({
                     message: 'Profile updated successfully',
                     color: 'positive',

programming_language: TypeScript
filename: user.js
change_type: ModificationType.MODIFY
diff: @@ -1,10 +1,17 @@
 var jwtDecode = require('jwt-decode');
 import Vue from 'vue';
+import User from '@/services/user';
 
-import Router from '../router'
+import Router from '@/router'
 
 export default {
-    user: null,
+    user: {
+        username: "",
+        role: "",
+        firstname: "",
+        lastname: "",
+        roles: ""
+    },
 
     getToken(username, password) {
         return new Promise((resolve, reject) => {
@@ -13,63 +20,39 @@ export default {
             .then((response) => {
                 var token = response.data.datas.token;
                 this.user = jwtDecode(token);
-                var countdown = this.user.exp*1000 - Date.now() - 60000 // Countdown to expiration less 1 minute
-                setTimeout(() => {
-                    this.refreshToken()
-                }, countdown)
                 resolve();
             })
             .catch((error) => {
-                console.log(error)
                 reject(error);
             })
         })
     },
 
     refreshToken() {
-        Vue.prototype.$axios.get('users/refreshtoken')
-        .then((response) => {
-            var token = response.data.datas.token;
-            this.user = jwtDecode(token);
-            var countdown = this.user.exp*1000 - Date.now() - 60000 // Countdown to expiration less 1 minute
-            setTimeout(() => {
-                this.refreshToken()
-            }, countdown)
-        })
-        .catch(err => {
-            console.log(err)
-            if (err.response && err.response.data.datas.includes('Expired'))
-                Router.push('/login?tokenError=2')
-            else
-                Router.push('/login')
+        return new Promise((resolve, reject) => {
+            Vue.prototype.$axios.get('users/refreshtoken')
+            .then((response) => {
+                var token = response.data.datas.token;
+                this.user = jwtDecode(token);
+                resolve()
+            })
+            .catch(err => {
+                if (err.response && err.response.data)
+                    reject(err.response.data.datas)
+                else 
+                    reject('Invalid Token')
+            })
         })
     },
 
     destroyToken() {
-        Vue.prototype.$axios.get('users/destroytoken')
+        Vue.prototype.$axios.delete('users/refreshtoken')
         .then(() => {
+            User.clear()
             Router.push('/login');
         })
-        .catch(err => Router.push('/login'))
-    },
-
-    checkToken() {
-        return new Promise((resolve, reject) => {
-            Vue.prototype.$axios.get(`users/checktoken`)
-            .then(data => {
-                var token = data.data.datas
-                var decoded = jwtDecode(token);
-                if (decoded) {
-                    this.user = decoded;
-                    resolve();
-                }
-                else
-                    reject('InvalidToken');
-                resolve()
-            })
-            .catch((error) => {
-                reject(error);
-            })
+        .catch(err => {
+            console.log(err)
         })
     },
 
@@ -94,7 +77,20 @@ export default {
     },
 
     isAuth() {
-        return (this.user !== null);
+        if (this.user && this.user.username)
+            return true
+        return false
+    },
+
+    // Reset user variable to default empty
+    clear() {
+        this.user = {
+            username: "",
+            role: "",
+            firstname: "",
+            lastname: "",
+            roles: ""
+        }
     },
 
     isAllowed(role) {

programming_language: JavaScript