diff options
Diffstat (limited to 'src/routes/api.ts')
-rw-r--r-- | src/routes/api.ts | 112 |
1 files changed, 89 insertions, 23 deletions
diff --git a/src/routes/api.ts b/src/routes/api.ts index e360709..1e9cd49 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -2,38 +2,104 @@ import express, { Request, Response } from 'express'; import csurf from 'csurf'; import cookieParser from 'cookie-parser'; import fileUpload, { UploadedFile } from 'express-fileupload'; -import slowDown from 'express-slow-down'; +import rateLimit from 'express-rate-limit'; +import { access, stat } from 'fs/promises'; +import { quote } from 'shell-quote'; +import { exec } from 'child_process'; const api = express.Router(); // For file uploads -api.use(fileUpload()); - -// Slow down everything to prevent DoS attacks -const speedLimiter = slowDown({ - windowMs: 5 * 60 * 1000, // 5 minutes - delayAfter: 50, // allow 50 requests per 5 minutes, then... - delayMs: 500 // begin adding 500ms of delay per request above 100: - // request # 101 is delayed by 500ms - // request # 102 is delayed by 1000ms - // request # 103 is delayed by 1500ms - // etc. +api.use(fileUpload({ + preserveExtension: true, // Preserve file extension on upload + safeFileNames: true, // Only allow alphanumeric characters in file names + limits: { fileSize: 1 * 1024 * 1024 }, // Limit file size to 1MB + useTempFiles: true, // Store files in temp instead of memory + tempFileDir: '/tmp/', // Store files in /tmp/ + debug: false, // Log debug information +})); + +// Slow down frequent requests to prevent DoS attacks +const rateLimiter = rateLimit({ + windowMs: 1 * 60 * 1000, // 1 minute + max: 10, // Limit each IP to 10 requests per `window` (here, per 1 minutes) + standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers + legacyHeaders: false, // Disable the `X-RateLimit-*` headers }); -api.use(speedLimiter); +api.use(rateLimiter); // CSRF protection api.use(cookieParser()); const csrf = csurf({ cookie: true }); -api.post('/upload', csrf, (req: Request, res: Response) => { - // Check if there is a file - if (!req.files || Object.keys(req.files).length === 0) - return res.status(400).json({ error: 'No file uploaded' }); - const file: UploadedFile = req.files.file as UploadedFile; // Kludge to prevent a compiler error - // Check if the file is a python file - if (file.mimetype !== 'text/x-python') - return res.status(400).json({ error: 'Not a Python file' }); - res.status(200).json({ file: file.name }); -}); +api.use(express.json()); +api.route('/upload') + .post(csrf, (req: Request, res: Response) => { + try { + // Check if anything was actually uploaded + if (!req.files || Object.keys(req.files).length === 0) + return res.status(400).json({ error: 'No file uploaded.' }); + + const file: UploadedFile = req.files.file as UploadedFile; // Kludge to prevent a compiler error, only one file gets uploaded so this should be fine + + // Check if the file is too large (see fileUpload.limits for the limit) + if (file.truncated) + return res.status(413).json({ error: 'File uploaded was too large.' }); + + // Check if the file is a python file + if (file.mimetype !== 'text/x-python') + return res.status(415).json({ error: 'File uploaded was not a Python file.' }); + + res.status(200).json({ file: file.name, path: file.tempFilePath, csrf: req.csrfToken() }); + } catch (err) { + // Generic error handler + res.status(500).json({ error: 'An unknown error occurred while uploading the file.', error_msg: err }); + } + }) + // Fallback + .all(csrf, (req: Request, res: Response) => { + res.set('Allow', 'POST'); + res.status(405).json({ error: 'Method not allowed.' }); + }); + +/* + This route is probably a complete security hole. It allows anyone with a login cookie access to run arbitrary Python code on the server. + + Minimizing PE vectors like running this as a low privilege user is a must. +*/ +api.route('/actuate') + .post(csrf, async (req: Request, res: Response) => { + // Make sure the file being requested to run exists + try { + await access(req.body.path); + } catch (err) { + return res.status(403).json({ error: 'File is not accessible.' }); + } + + + const stats = await stat(req.body.path); + // Make sure the file being requested to run is a regular file + if (!stats.isFile()) + return res.status(403).json({ error: 'File is not a regular file.' }); + // Make sure the file being requested to run is not a directory + if (stats.isDirectory()) + return res.status(403).json({ error: 'File is a directory.' }); + + const escaped = quote([ 'python', req.body.path]); + // Run the code + exec(escaped, (err, stdout, stderr) => { + if (err) + return res.status(500).json({ error: 'An unknown error occurred while executing the file.', error_msg: stderr }); + + // Return the output + res.status(200).json({ output: stdout }); + }); + }) + // Fallback + .all(csrf, (req: Request, res: Response) => { + res.set('Allow', 'POST'); + res.status(405).json({ error: 'Method not allowed.' }); + }); + export default api;
\ No newline at end of file |