* Added an experimental virus scanning feature using ClamAV. This has only been tested with an Ubuntu machine.

* File extensions will now be parsed with path-complete-extname module. This will ensure extensions such as .tar.gz are properly parsed.

Notice: It may take a minute or so to start the safe with virus scanning, as apparently the module takes a while to create the engine. I'm guessing since it'll be loaded to memory? Either way, once the engine is created, everything should work fine. Virus scanning should also not have that much of an impact to the upload time.
This commit is contained in:
Bobby Wibowo 2018-09-02 03:37:26 +07:00
parent bf31a59f2c
commit 36da76357e
No known key found for this signature in database
GPG Key ID: 51C3A1E1E22D26CF
8 changed files with 124 additions and 17 deletions

View File

@ -29,6 +29,16 @@ force: 0 = no force (default), 1 = overwrite existing thumbnails
For example, if you only want to generate thumbnails for image files, you can do `yarn thumbs 1`.
## Virus scanning
This project is using [clamav](https://www.clamav.net/) to scan files for viruses through [node-clam-engine](https://github.com/srijs/node-clam-engine).
You will need to install dependencies listed [here](https://github.com/srijs/node-clam-engine#installation).
If you do not want to use it, you may remove `clam-engine` from the `package.json` file.
This has only been tested with an Ubuntu machine.
## Running
1. Ensure you have at least version 8.0.0 of node installed

View File

@ -92,6 +92,11 @@ module.exports = {
*/
urlMaxSize: '32MB',
/*
Scan for virus using ClamAV.
*/
scan: false,
/*
Use DuckDuckGo's proxy when fetching any URL uploads.
This may be considered a hack and not supported by DuckDuckGo, so USE AT YOUR OWN RISK.

View File

@ -3,6 +3,7 @@ const db = require('knex')(config.database)
const EventEmitter = require('events')
const fs = require('fs')
const path = require('path')
const pce = require('path-complete-extname')
const randomstring = require('randomstring')
const utils = require('./utilsController')
const Zip = require('jszip')
@ -298,7 +299,7 @@ albumsController.get = async (req, res, next) => {
for (const file of files) {
file.file = `${config.domain}/${file.name}`
const extname = path.extname(file.name).toLowerCase()
const extname = pce(file.name).toLowerCase()
if (utils.mayGenerateThumb(extname)) {
file.thumb = `${config.domain}/thumbs/${file.name.slice(0, -extname.length)}.png`
}

View File

@ -1,10 +1,11 @@
const config = require('./../config')
const path = require('path')
const multer = require('multer')
const randomstring = require('randomstring')
const db = require('knex')(config.database)
const crypto = require('crypto')
const db = require('knex')(config.database)
const fs = require('fs')
const multer = require('multer')
const path = require('path')
const pce = require('path-complete-extname')
const randomstring = require('randomstring')
const snekfetch = require('snekfetch')
const utils = require('./utilsController')
@ -39,7 +40,7 @@ const storage = multer.diskStorage({
filename (req, file, cb) {
// If chunked uploads is disabled or the uploaded file is not a chunk
if (!chunkedUploads || (req.body.uuid === undefined && req.body.chunkindex === undefined)) {
const extension = path.extname(file.originalname)
const extension = pce(file.originalname).toLowerCase()
const length = uploadsController.getFileNameLength(req)
return uploadsController.getUniqueRandomName(length, extension)
.then(name => cb(null, name))
@ -60,7 +61,7 @@ const upload = multer({
fileSize: maxSizeBytes
},
fileFilter (req, file, cb) {
const extname = path.extname(file.originalname).toLowerCase()
const extname = pce(file.originalname).toLowerCase()
if (uploadsController.isExtensionFiltered(extname)) {
// eslint-disable-next-line standard/no-callback-literal
return cb(`${extname.substr(1).toUpperCase()} files are not permitted due to security reasons.`)
@ -178,6 +179,11 @@ uploadsController.actuallyUpload = async (req, res, user, albumid) => {
}
})
if (config.uploads.scan) {
const scan = await uploadsController.scanFiles(req, infoMap)
if (!scan) { return erred('Virus detected.') }
}
const result = await uploadsController.formatInfoMap(req, res, user, infoMap)
.catch(erred)
if (!result) { return }
@ -210,7 +216,7 @@ uploadsController.actuallyUploadByUrl = async (req, res, user, albumid) => {
const infoMap = []
for (const url of urls) {
const original = path.basename(url).split(/[?#]/)[0]
const extension = path.extname(original)
const extension = pce(original).toLowerCase()
if (uploadsController.isExtensionFiltered(extension)) {
return erred(`${extension.substr(1).toUpperCase()} files are not permitted due to security reasons.`)
}
@ -257,6 +263,11 @@ uploadsController.actuallyUploadByUrl = async (req, res, user, albumid) => {
iteration++
if (iteration === urls.length) {
if (config.uploads.scan) {
const scan = await uploadsController.scanFiles(req, infoMap)
if (!scan) { return erred('Virus detected.') }
}
const result = await uploadsController.formatInfoMap(req, res, user, infoMap)
.catch(erred)
if (!result) { return }
@ -320,7 +331,7 @@ uploadsController.actuallyFinishChunks = async (req, res, user, albumid) => {
if (error) { return erred(error) }
if (file.count < chunkNames.length) { return erred('Chunks count mismatch.') }
const extension = typeof file.original === 'string' ? path.extname(file.original) : ''
const extension = typeof file.original === 'string' ? pce(file.original).toLowerCase() : ''
if (uploadsController.isExtensionFiltered(extension)) {
return erred(`${extension.substr(1).toUpperCase()} files are not permitted due to security reasons.`)
}
@ -375,6 +386,11 @@ uploadsController.actuallyFinishChunks = async (req, res, user, albumid) => {
iteration++
if (iteration === files.length) {
if (config.uploads.scan) {
const scan = await uploadsController.scanFiles(req, infoMap)
if (!scan) { return erred('Virus detected.') }
}
const result = await uploadsController.formatInfoMap(req, res, user, infoMap)
.catch(erred)
if (!result) { return }
@ -444,7 +460,7 @@ uploadsController.cleanUpChunks = (uuidDir, chunkNames) => {
}
uploadsController.formatInfoMap = (req, res, user, infoMap) => {
return new Promise((resolve, reject) => {
return new Promise(async resolve => {
let iteration = 0
const files = []
const existingFiles = []
@ -511,6 +527,40 @@ uploadsController.formatInfoMap = (req, res, user, infoMap) => {
})
}
uploadsController.scanFiles = async (req, infoMap) => {
const dirty = await new Promise(async resolve => {
let iteration = 0
const engine = req.app.get('clam-engine')
const dirty = []
for (const info of infoMap) {
const log = message => {
console.log(`ClamAV: ${info.data.filename}: ${message}.`)
}
engine.scanFile(info.path, (error, virus) => {
if (error || virus) {
if (error) { log(error.toString()) }
if (virus) { log(`${virus} detected`) }
dirty.push(info)
} else {
// log('OK')
}
iteration++
if (iteration === infoMap.length) {
resolve(dirty)
}
})
}
})
if (!dirty.length) { return true }
// If there is at least one dirty file, delete all files
infoMap.forEach(info => utils.deleteFile(info.data.filename).catch(console.error))
}
uploadsController.processFilesForDisplay = async (req, res, files, existingFiles) => {
const responseFiles = []
@ -546,7 +596,7 @@ uploadsController.processFilesForDisplay = async (req, res, files, existingFiles
if (file.albumid && !albumids.includes(file.albumid)) {
albumids.push(file.albumid)
}
if (utils.mayGenerateThumb(path.extname(file.name).toLowerCase())) {
if (utils.mayGenerateThumb(pce(file.name).toLowerCase())) {
utils.generateThumbs(file.name)
}
}
@ -639,7 +689,7 @@ uploadsController.list = async (req, res) => {
}
}
file.extname = path.extname(file.name).toLowerCase()
file.extname = pce(file.name).toLowerCase()
if (utils.mayGenerateThumb(file.extname)) {
file.thumb = `${basedomain}/thumbs/${file.name.slice(0, -file.extname.length)}.png`
}

View File

@ -4,6 +4,7 @@ const ffmpeg = require('fluent-ffmpeg')
const fs = require('fs')
const gm = require('gm')
const path = require('path')
const pce = require('path-complete-extname')
const snekfetch = require('snekfetch')
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
@ -65,7 +66,7 @@ utilsController.authorize = async (req, res) => {
utilsController.generateThumbs = (name, force) => {
return new Promise(resolve => {
const extname = path.extname(name).toLowerCase()
const extname = pce(name).toLowerCase()
const thumbname = path.join(thumbsDir, name.slice(0, -extname.length) + '.png')
fs.access(thumbname, error => {
if (error && error.code !== 'ENOENT') {
@ -118,7 +119,7 @@ utilsController.generateThumbs = (name, force) => {
utilsController.deleteFile = file => {
return new Promise((resolve, reject) => {
const extname = path.extname(file).toLowerCase()
const extname = pce(file).toLowerCase()
return fs.unlink(path.join(uploadsDir, file), error => {
if (error && error.code !== 'ENOENT') { return reject(error) }
@ -212,7 +213,7 @@ utilsController.purgeCloudflareCache = async names => {
const thumbs = []
names = names.map(name => {
const url = `${config.domain}/${name}`
const extname = path.extname(name).toLowerCase()
const extname = pce(name).toLowerCase()
if (utilsController.mayGenerateThumb(extname)) {
thumbs.push(`${config.domain}/thumbs/${name.slice(0, -extname.length)}.png`)
}

View File

@ -84,8 +84,6 @@ safe.use((error, req, res, next) => {
res.status(500).sendFile('500.html', { root: './pages/error/' })
})
safe.listen(config.port, () => console.log(`lolisafe started on port ${config.port}`))
process.on('uncaughtException', error => {
console.error('Uncaught Exception:')
console.error(error)
@ -95,3 +93,29 @@ process.on('unhandledRejection', error => {
console.error('Unhandled Rejection (Promise):')
console.error(error)
})
async function start () {
if (config.uploads.scan) {
// Placing require() here so the package does not have to exist when the option is not enabled
const clam = require('clam-engine')
const created = await new Promise(resolve => {
process.stdout.write('Creating clam-engine...')
clam.createEngine(function (error, engine) {
if (error) {
process.stdout.write(' ERROR\n')
console.error(error)
return resolve(false)
}
safe.set('clam-engine', engine)
process.stdout.write(' OK\n')
console.log(`ClamAV ${engine.version} (${engine.signatures} sigs)`)
resolve(true)
})
})
if (!created) { return }
}
safe.listen(config.port, () => console.log(`lolisafe started on port ${config.port}`))
}
start()

View File

@ -22,6 +22,7 @@
"dependencies": {
"bcrypt": "^2.0.0",
"body-parser": "^1.18.2",
"clam-engine": "^2.0.1",
"express": "^4.16.3",
"express-rate-limit": "^2.11.0",
"fluent-ffmpeg": "^2.1.2",
@ -31,6 +32,7 @@
"knex": "^0.14.6",
"multer": "^1.3.0",
"nunjucks": "^3.1.2",
"path-complete-extname": "^1.0.0",
"randomstring": "^1.1.5",
"snekfetch": "^3.6.4",
"sqlite3": "^4.0.0"

View File

@ -360,6 +360,12 @@ circular-json@^0.3.1:
version "0.3.3"
resolved "https://registry.yarnpkg.com/circular-json/-/circular-json-0.3.3.tgz#815c99ea84f6809529d2f45791bdf82711352d66"
clam-engine@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/clam-engine/-/clam-engine-2.0.1.tgz#ad94dfa8e1d966d13e200dfd5d794f15425bccc5"
dependencies:
nan "^2.1.0"
class-utils@^0.3.5:
version "0.3.6"
resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463"
@ -1758,6 +1764,10 @@ nan@2.10.0, nan@^2.9.2:
version "2.10.0"
resolved "https://registry.yarnpkg.com/nan/-/nan-2.10.0.tgz#96d0cd610ebd58d4b4de9cc0c6828cda99c7548f"
nan@^2.1.0:
version "2.11.0"
resolved "https://registry.yarnpkg.com/nan/-/nan-2.11.0.tgz#574e360e4d954ab16966ec102c0c049fd961a099"
nan@~2.9.2:
version "2.9.2"
resolved "https://registry.yarnpkg.com/nan/-/nan-2.9.2.tgz#f564d75f5f8f36a6d9456cca7a6c4fe488ab7866"
@ -2011,6 +2021,10 @@ pascalcase@^0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14"
path-complete-extname@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/path-complete-extname/-/path-complete-extname-1.0.0.tgz#f889985dc91000c815515c0bfed06c5acda0752b"
path-dirname@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0"