mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-11-13 12:23:34 +01:00
Merge pull request #5686 from mikiher/fix-upload-auth
Stop unauthenticated multipart uploads from leaving temp files on disk
This commit is contained in:
6 files changed
+342
-19
No files matched your search
@@ -0,0 +1,221 @@
|
||||
const { expect } = require('chai')
|
||||
const express = require('express')
|
||||
const fs = require('fs')
|
||||
const http = require('http')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
const sinon = require('sinon')
|
||||
|
||||
const Logger = require('../../../server/Logger')
|
||||
const { fileUploadParser, requireUploadPermission, sweepUploadTempFiles } = require('../../../server/utils/fileUploadMiddleware')
|
||||
|
||||
function listTmpFiles(dir) {
|
||||
if (!fs.existsSync(dir)) return []
|
||||
return fs.readdirSync(dir).filter((name) => name.startsWith('tmp-'))
|
||||
}
|
||||
|
||||
function sendMultipart(port, urlPath, extraHeaders = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const boundary = '----AbsUploadTestBoundary'
|
||||
const payload = [
|
||||
`--${boundary}`,
|
||||
'Content-Disposition: form-data; name="f"; filename="probe.txt"',
|
||||
'Content-Type: text/plain',
|
||||
'',
|
||||
'probe',
|
||||
`--${boundary}--`,
|
||||
''
|
||||
].join('\r\n')
|
||||
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: '127.0.0.1',
|
||||
port,
|
||||
path: urlPath,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
'Content-Length': Buffer.byteLength(payload),
|
||||
...extraHeaders
|
||||
}
|
||||
},
|
||||
(res) => {
|
||||
res.resume()
|
||||
res.on('end', () => resolve(res.statusCode))
|
||||
}
|
||||
)
|
||||
req.on('error', reject)
|
||||
req.write(payload)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
function listen(app) {
|
||||
const server = http.createServer(app)
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
resolve({ server, port: server.address().port })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function closeServer(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((err) => (err ? reject(err) : resolve()))
|
||||
})
|
||||
}
|
||||
|
||||
async function waitFor(predicate, timeoutMs = 2000) {
|
||||
const started = Date.now()
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
if (predicate()) return
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
}
|
||||
throw new Error('Timed out waiting for condition')
|
||||
}
|
||||
|
||||
describe('fileUploadMiddleware', () => {
|
||||
let metadataDir
|
||||
let tmpDir
|
||||
let previousMetadataPath
|
||||
|
||||
beforeEach(() => {
|
||||
previousMetadataPath = global.MetadataPath
|
||||
metadataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'abs-upload-meta-'))
|
||||
tmpDir = path.join(metadataDir, 'tmp')
|
||||
global.MetadataPath = metadataDir
|
||||
sinon.stub(Logger, 'info')
|
||||
sinon.stub(Logger, 'warn')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
global.MetadataPath = previousMetadataPath
|
||||
fs.rmSync(metadataDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('requireUploadPermission', () => {
|
||||
it('returns 403 when the user cannot upload', () => {
|
||||
const req = { user: { canUpload: false, username: 'guest' } }
|
||||
const res = { sendStatus: sinon.stub() }
|
||||
const next = sinon.stub()
|
||||
|
||||
requireUploadPermission(req, res, next)
|
||||
|
||||
expect(res.sendStatus.calledOnceWithExactly(403)).to.equal(true)
|
||||
expect(next.called).to.equal(false)
|
||||
})
|
||||
|
||||
it('calls next when the user can upload', () => {
|
||||
const req = { user: { canUpload: true, username: 'uploader' } }
|
||||
const res = { sendStatus: sinon.stub() }
|
||||
const next = sinon.stub()
|
||||
|
||||
requireUploadPermission(req, res, next)
|
||||
|
||||
expect(next.calledOnce).to.equal(true)
|
||||
expect(res.sendStatus.called).to.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sweepUploadTempFiles', () => {
|
||||
it('removes leftover tmp-* files and leaves other names', async () => {
|
||||
fs.mkdirSync(tmpDir, { recursive: true })
|
||||
fs.writeFileSync(path.join(tmpDir, 'tmp-1-123'), 'stale')
|
||||
fs.writeFileSync(path.join(tmpDir, 'tmp-2-456'), 'stale')
|
||||
fs.writeFileSync(path.join(tmpDir, 'comic-extract-keep'), 'keep')
|
||||
fs.mkdirSync(path.join(tmpDir, 'tmp-not-a-file'))
|
||||
|
||||
await sweepUploadTempFiles()
|
||||
|
||||
expect(fs.existsSync(path.join(tmpDir, 'tmp-1-123'))).to.equal(false)
|
||||
expect(fs.existsSync(path.join(tmpDir, 'tmp-2-456'))).to.equal(false)
|
||||
expect(fs.existsSync(path.join(tmpDir, 'comic-extract-keep'))).to.equal(true)
|
||||
expect(fs.existsSync(path.join(tmpDir, 'tmp-not-a-file'))).to.equal(true)
|
||||
})
|
||||
|
||||
it('returns when the temp directory does not exist', async () => {
|
||||
await sweepUploadTempFiles()
|
||||
expect(fs.existsSync(tmpDir)).to.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('upload parser mounting', () => {
|
||||
it('does not write tmp-* files for a route without the parser', async () => {
|
||||
const app = express()
|
||||
app.use((req, res) => res.sendStatus(404))
|
||||
const { server, port } = await listen(app)
|
||||
|
||||
try {
|
||||
const status = await sendMultipart(port, '/not-a-route')
|
||||
expect(status).to.equal(404)
|
||||
expect(listTmpFiles(tmpDir)).to.deep.equal([])
|
||||
} finally {
|
||||
await closeServer(server)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not write tmp-* files when permission is rejected before parsing', async () => {
|
||||
const app = express()
|
||||
app.use((req, res, next) => {
|
||||
req.user = { canUpload: false, username: 'guest' }
|
||||
next()
|
||||
})
|
||||
app.post('/upload', requireUploadPermission, fileUploadParser, (req, res) => {
|
||||
res.sendStatus(200)
|
||||
})
|
||||
const { server, port } = await listen(app)
|
||||
|
||||
try {
|
||||
const status = await sendMultipart(port, '/upload')
|
||||
expect(status).to.equal(403)
|
||||
expect(listTmpFiles(tmpDir)).to.deep.equal([])
|
||||
} finally {
|
||||
await closeServer(server)
|
||||
}
|
||||
})
|
||||
|
||||
it('removes the temp file when the handler rejects without moving it', async () => {
|
||||
const app = express()
|
||||
app.use((req, res, next) => {
|
||||
req.user = { canUpload: true, username: 'uploader' }
|
||||
next()
|
||||
})
|
||||
app.post('/upload', requireUploadPermission, fileUploadParser, (req, res) => {
|
||||
res.status(400).send('Invalid request no file or url')
|
||||
})
|
||||
const { server, port } = await listen(app)
|
||||
|
||||
try {
|
||||
const status = await sendMultipart(port, '/upload')
|
||||
expect(status).to.equal(400)
|
||||
await waitFor(() => listTmpFiles(tmpDir).length === 0)
|
||||
} finally {
|
||||
await closeServer(server)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a moved file and leaves no tmp-* leftover', async () => {
|
||||
const dest = path.join(metadataDir, 'moved.txt')
|
||||
const app = express()
|
||||
app.use((req, res, next) => {
|
||||
req.user = { canUpload: true, username: 'uploader' }
|
||||
next()
|
||||
})
|
||||
app.post('/upload', requireUploadPermission, fileUploadParser, async (req, res) => {
|
||||
await req.files.f.mv(dest)
|
||||
res.sendStatus(200)
|
||||
})
|
||||
const { server, port } = await listen(app)
|
||||
|
||||
try {
|
||||
const status = await sendMultipart(port, '/upload')
|
||||
expect(status).to.equal(200)
|
||||
expect(fs.readFileSync(dest, 'utf8')).to.equal('probe')
|
||||
await waitFor(() => listTmpFiles(tmpDir).length === 0)
|
||||
} finally {
|
||||
await closeServer(server)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user