diff options
Diffstat (limited to '')
-rw-r--r-- | src/index.ts | 17 | ||||
-rw-r--r-- | src/public/css/style.css | 127 | ||||
-rw-r--r-- | src/public/js/form.js | 128 | ||||
-rw-r--r-- | src/routes/api.ts | 287 |
4 files changed, 284 insertions, 275 deletions
diff --git a/src/index.ts b/src/index.ts index 63c803c..def7aff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,14 +20,13 @@ const csrf = csurf({ cookie: true }); // Rate limiting const rateLimiter = rateLimit({ - windowMs: 1 * 60 * 1000, // 1 minute - max: 40, // Limit each IP to 40 requests per `window` (here, per 1 minutes) - standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers - legacyHeaders: false, // Disable the `X-RateLimit-*` headers + windowMs: 1 * 60 * 1000, // 1 minute + max: 40, // Limit each IP to 40 requests per `window` (here, per 1 minutes) + standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers + legacyHeaders: false, // Disable the `X-RateLimit-*` headers }); app.use(rateLimiter); - // The API app.use('/api/v1/', api); @@ -40,14 +39,14 @@ app.use('/public', express.static(path.join(__dirname, 'public'))); // Set stati /* ROUTING */ app.get('/', csrf, (req: Request, res: Response) => { - res.render('index', { csrfToken: req.csrfToken() }); + res.render('index', { csrfToken: req.csrfToken() }); }); app.get('/about', csrf, (req: Request, res: Response) => { - res.render('about'); + res.render('about'); }); // Start the server const port = env.PORT || 2000; app.listen(port, () => { - console.log(`Server is listening on port ${port}`); -});
\ No newline at end of file + console.log(`Server is listening on port ${port}`); +}); diff --git a/src/public/css/style.css b/src/public/css/style.css index a106926..3165d06 100644 --- a/src/public/css/style.css +++ b/src/public/css/style.css @@ -1,126 +1,117 @@ body { - background-color: black; - color: white; - text-align: center; - vertical-align: middle; - position: relative; + background-color: black; + color: white; + text-align: center; + vertical-align: middle; + position: relative; } .header { - margin: 5px; - font-family: "Times New Roman", Times, serif; + margin: 5px; + font-family: 'Times New Roman', Times, serif; } - /* Customize HTML paragraph element margins */ p { - display: block; - margin-top: 1em; - margin-bottom: 1em; - margin-left: 15%; - margin-right: 15%; + display: block; + margin-top: 1em; + margin-bottom: 1em; + margin-left: 15%; + margin-right: 15%; } - /*-------------------------Buttons-----------------------------------------------------*/ - /* Used for id=button1 */ #actuate-but { - background-color: #0a3f73; - border: solid; - border-color: #d5d6d2; - color: white; - padding: 10px 20px; - text-align: center; - text-decoration: none; - font-family: "Constantia", sans-serif; - border-radius: 10px; - display: inline-block; - font-size: 20px; + background-color: #0a3f73; + border: solid; + border-color: #d5d6d2; + color: white; + padding: 10px 20px; + text-align: center; + text-decoration: none; + font-family: 'Constantia', sans-serif; + border-radius: 10px; + display: inline-block; + font-size: 20px; } - /* Used for id=button1 - What the button looks like when a user puts one's cursor on it */ #actuate-but:hover { - background-color: white; - color: maroon; - cursor: pointer; + background-color: white; + color: maroon; + cursor: pointer; } - a { - color: blue; - text-decoration: none; + color: blue; + text-decoration: none; } - /* Used for the about page*/ .about { - color: white; - text-align: center; - margin: 5px; - font-size: large; + color: white; + text-align: center; + margin: 5px; + font-size: large; } - #Background { - color: #ffbb00; + color: #ffbb00; } #Pendulum_info { - /* #4294cf is similar to a light blue color */ - color: #4294cf; + /* #4294cf is similar to a light blue color */ + color: #4294cf; } - /* Used for id=Start */ #Start { - font-size: 25px; - font-weight: bold; - color: #ffbb00; + font-size: 25px; + font-weight: bold; + color: #ffbb00; } - .navbar { - top: 0; - width: 100%; - list-style-type: none; - margin: 0; - padding: 0; - overflow: hidden; - background-color: #7a0019; + top: 0; + width: 100%; + list-style-type: none; + margin: 0; + padding: 0; + overflow: hidden; + background-color: #7a0019; } .navbar li { - float: left; + float: left; } .navbar li a { - display: block; - color: white; - text-align: center; - padding: 14px 16px; - font-family: "Raleway", sans-serif; - text-decoration: none; + display: block; + color: white; + text-align: center; + padding: 14px 16px; + font-family: 'Raleway', sans-serif; + text-decoration: none; } .navbar li a:hover { - background-color: #ffcc33; - color: #000000; - /* Remove underlines from links */ - text-decoration: none; + background-color: #ffcc33; + color: #000000; + /* Remove underlines from links */ + text-decoration: none; } h2 { - font-size: larger; + font-size: larger; } .error { - color: red; -}
\ No newline at end of file + color: red; +} diff --git a/src/public/js/form.js b/src/public/js/form.js index 9e926fa..1e9eaca 100644 --- a/src/public/js/form.js +++ b/src/public/js/form.js @@ -1,84 +1,80 @@ // This is split into comments so it doesn't look as gross with the closure tabs window.onload = function () { - document.getElementById('nojs').hidden = true; - document.getElementById('block').hidden = false; + document.getElementById('nojs').hidden = true; + document.getElementById('block').hidden = false; }; // File submit AJAX request // After successful upload, actuate the file by calling actuate() on the successfully uploaded file document.getElementById('upload').onsubmit = function () { - // Reset error message and success message - document.getElementById('upload-err').innerText = ''; - document.getElementById('actuate-err').innerText = ''; - document.getElementById('upload-response').innerText = ''; - document.getElementById('download-link').innerText = ''; - // Make AJAX request - let xhr = new XMLHttpRequest(); - xhr.open('POST', '/api/v1/upload'); - let formData = new FormData(this); - xhr.send(formData); - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - let response = JSON.parse(xhr.responseText); - if (xhr.status === 201) { - // Display upload success message to the user - document.getElementById('upload-response').innerText = response.msg; - actuate(response); - } else { - // Display upload error message to the user - document.getElementById('upload-err').innerText = response.error; - // DEBUG: Print full error if unknown error occurs - if (xhr.status === 500) - console.error(response.error_msg); - } - } - }; - document.getElementById('upload').reset(); - return false; + // Reset error message and success message + document.getElementById('upload-err').innerText = ''; + document.getElementById('actuate-err').innerText = ''; + document.getElementById('upload-response').innerText = ''; + document.getElementById('download-link').innerText = ''; + // Make AJAX request + let xhr = new XMLHttpRequest(); + xhr.open('POST', '/api/v1/upload'); + let formData = new FormData(this); + xhr.send(formData); + xhr.onreadystatechange = function () { + if (xhr.readyState === 4) { + let response = JSON.parse(xhr.responseText); + if (xhr.status === 201) { + // Display upload success message to the user + document.getElementById('upload-response').innerText = response.msg; + actuate(response); + } else { + // Display upload error message to the user + document.getElementById('upload-err').innerText = response.error; + // DEBUG: Print full error if unknown error occurs + if (xhr.status === 500) console.error(response.error_msg); + } + } + }; + document.getElementById('upload').reset(); + return false; }; // Actuate button AJAX request // Should always be called after upload // Implies that upload has been successful since it relies on the upload response -// +// function actuate(file) { - let xhr = new XMLHttpRequest(); - xhr.open('POST', '/api/v1/actuate'); - let data = { - file: file.file - }; - xhr.setRequestHeader('Content-Type', 'application/json; charset=utf-8'); - xhr.setRequestHeader('X-CSRF-TOKEN', file.csrf); - xhr.send(JSON.stringify(data)); - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - let response = JSON.parse(xhr.responseText); - if (xhr.status === 200 || xhr.status === 500) { - createDownload(response.file); - } - if (xhr.status === 500) { - document.getElementById('actuate-err').innerText = response.error; - // DEBUG: Print full error if unknown error occurs - console.error(response.error_msg); - } - - } - return; - }; + let xhr = new XMLHttpRequest(); + xhr.open('POST', '/api/v1/actuate'); + let data = { + file: file.file, + }; + xhr.setRequestHeader('Content-Type', 'application/json; charset=utf-8'); + xhr.setRequestHeader('X-CSRF-TOKEN', file.csrf); + xhr.send(JSON.stringify(data)); + xhr.onreadystatechange = function () { + if (xhr.readyState === 4) { + let response = JSON.parse(xhr.responseText); + if (xhr.status === 200 || xhr.status === 500) { + createDownload(response.file); + } + if (xhr.status === 500) { + document.getElementById('actuate-err').innerText = response.error; + // DEBUG: Print full error if unknown error occurs + console.error(response.error_msg); + } + } + return; + }; } - // Creates the download element function createDownload(response) { - if (!response) - return; - const tempName = response.filename; - const downloadName = response.name.split('.')[ 0 ]; - const downloadLink = document.createElement('a'); - downloadLink.setAttribute('href', `/api/v1/download/?filename=${tempName}`); - downloadLink.setAttribute('download', `${downloadName}.csv`); - downloadLink.innerText = 'Download CSV of results here.'; - document.getElementById('download-link').appendChild(downloadLink); - return; -}
\ No newline at end of file + if (!response) return; + const tempName = response.filename; + const downloadName = response.name.split('.')[0]; + const downloadLink = document.createElement('a'); + downloadLink.setAttribute('href', `/api/v1/download/?filename=${tempName}`); + downloadLink.setAttribute('download', `${downloadName}.csv`); + downloadLink.innerText = 'Download CSV of results here.'; + document.getElementById('download-link').appendChild(downloadLink); + return; +} diff --git a/src/routes/api.ts b/src/routes/api.ts index ee80ea5..22edd79 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -15,15 +15,16 @@ api.use(cookieParser()); const csrf = csurf({ cookie: true }); // For file uploads -api.use(fileUpload({ +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 -})); - + }) +); /* Upload a file to the server @@ -44,34 +45,44 @@ api.use(fileUpload({ 415 when the file's MIME type is not text/x-python or text/plain (WINDOWS WHY) 500 for any other errors */ -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' && file.mimetype !== 'text/plain') - return res.status(415).json({ error: 'File uploaded was not a Python file.' }); - - res.status(201).json({ file: { file: file.name, path: file.tempFilePath }, msg: 'File uploaded successfully.', 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.' }); - }); +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' && file.mimetype !== 'text/plain') + return res + .status(415) + .json({ error: 'File uploaded was not a Python file.' }); + + res.status(201).json({ + file: { file: file.name, path: file.tempFilePath }, + msg: 'File uploaded successfully.', + 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.' }); + }); /* Actuate the pendulum @@ -102,20 +113,20 @@ api.route('/upload') Minimizing PE vectors like running this as an extremely low privilege user is a must. */ -api.route('/actuate') - // Snyk error mitigation, should be fine since the rate limiting is already in place - // file deepcode ignore NoRateLimitingForExpensiveWebOperation: This is already rate limited by the website, so we don't need to do it again - .post(csrf, async (req: Request, res: Response) => { - try { - const path: string = req.body.file.path; - // Verify that the file exists and is a regular file - // Return if not since the res will be sent by the verifyFile function - if (await verifyFile(path, res) !== true) - return; - - const escaped = quote([ path ]); - // Run the code - /* +api + .route('/actuate') + // Snyk error mitigation, should be fine since the rate limiting is already in place + // file deepcode ignore NoRateLimitingForExpensiveWebOperation: This is already rate limited by the website, so we don't need to do it again + .post(csrf, async (req: Request, res: Response) => { + try { + const path: string = req.body.file.path; + // Verify that the file exists and is a regular file + // Return if not since the res will be sent by the verifyFile function + if ((await verifyFile(path, res)) !== true) return; + + const escaped = quote([path]); + // Run the code + /* TODO: - Potentially add the limiter to one-per-person here - Add a timeout @@ -123,37 +134,48 @@ api.route('/actuate') - Make this more secure - HOW? */ - // let output = ''; - let stderr = ''; - const actuation = spawn('python', escaped.split(' ')); - // actuation.stdout.on('data', (data: Buffer) => { - // output += data.toString(); - // }); - actuation.stderr.on('data', (data: Buffer) => { - stderr += `STDERR: ${data.toString()}`; - }); - actuation.on('close', (code: number) => { - const filename: string = (req.body.file.path as string).split('/').pop() as string; - // Make sure the program exited with a code of 0 (success) - if (code !== 0) - return res.status(500).json({ error: `Program exited with exit code ${code}`, error_msg: stderr, file: { name: req.body.file.file, filename: filename } }); - return res.status(200).json({ file: { name: req.body.file.file, filename: filename } }); - }); - // Kill the process if it takes too long - // Default timeout is 120 seconds (2 minutes) - setTimeout(() => { - actuation.kill(); - }, 120000); - } catch (err) { - // Generic error handler - return res.status(500).json({ error: 'An unknown error occurred while running the file.', error_msg: err }); - } - }) - // Fallback - .all(csrf, (req: Request, res: Response) => { - res.set('Allow', 'POST'); - return res.status(405).json({ error: 'Method not allowed.' }); - }); + // let output = ''; + let stderr = ''; + const actuation = spawn('python', escaped.split(' ')); + // actuation.stdout.on('data', (data: Buffer) => { + // output += data.toString(); + // }); + actuation.stderr.on('data', (data: Buffer) => { + stderr += `STDERR: ${data.toString()}`; + }); + actuation.on('close', (code: number) => { + const filename: string = (req.body.file.path as string) + .split('/') + .pop() as string; + // Make sure the program exited with a code of 0 (success) + if (code !== 0) + return res.status(500).json({ + error: `Program exited with exit code ${code}`, + error_msg: stderr, + file: { name: req.body.file.file, filename: filename }, + }); + return res + .status(200) + .json({ file: { name: req.body.file.file, filename: filename } }); + }); + // Kill the process if it takes too long + // Default timeout is 120 seconds (2 minutes) + setTimeout(() => { + actuation.kill(); + }, 120000); + } catch (err) { + // Generic error handler + return res.status(500).json({ + error: 'An unknown error occurred while running the file.', + error_msg: err, + }); + } + }) + // Fallback + .all(csrf, (req: Request, res: Response) => { + res.set('Allow', 'POST'); + return res.status(405).json({ error: 'Method not allowed.' }); + }); /* Download the CSV file after running the pendulum @@ -166,35 +188,34 @@ api.route('/actuate') 404 when the file is not accessible or does not exist 500 for any other errors */ -api.route('/download') - .get(csrf, async (req: Request, res: Response) => { - const filename: string = req.query.filename as string; - if (!filename) - return res.status(400).json({ error: 'No filename specified.' }); - // Make sure no path traversal is attempted - // This regex matches all alphanumeric characters, underscores, and dashes. - // MAKE SURE THIS DOES NOT ALLOW PATH TRAVERSAL - if (!/^[\w-]+$/.test(filename)) - return res.status(403).json({ error: 'No.' }); - - const path = `/tmp/${filename}.csv`; - - // Verify that the file exists and is a regular file - // Return if not since the res will be sent by the verifyFile function - if (await verifyFile(path, res) !== true) - return; - // Read the file and send it to the client - res.type('text/csv'); - // Snyk error mitigation, should be fine since tmp is private and the simple regex above should prevent path traversal - // deepcode ignore PT: This is probably mitigated by the regex - return res.sendFile(path); - }) - // Fallback - .all(csrf, (req: Request, res: Response) => { - res.set('Allow', 'GET'); - return res.status(405).json({ error: 'Method not allowed.' }); - }); +api + .route('/download') + .get(csrf, async (req: Request, res: Response) => { + const filename: string = req.query.filename as string; + if (!filename) + return res.status(400).json({ error: 'No filename specified.' }); + // Make sure no path traversal is attempted + // This regex matches all alphanumeric characters, underscores, and dashes. + // MAKE SURE THIS DOES NOT ALLOW PATH TRAVERSAL + if (!/^[\w-]+$/.test(filename)) + return res.status(403).json({ error: 'No.' }); + + const path = `/tmp/${filename}.csv`; + // Verify that the file exists and is a regular file + // Return if not since the res will be sent by the verifyFile function + if ((await verifyFile(path, res)) !== true) return; + // Read the file and send it to the client + res.type('text/csv'); + // Snyk error mitigation, should be fine since tmp is private and the simple regex above should prevent path traversal + // deepcode ignore PT: This is probably mitigated by the regex + return res.sendFile(path); + }) + // Fallback + .all(csrf, (req: Request, res: Response) => { + res.set('Allow', 'GET'); + return res.status(405).json({ error: 'Method not allowed.' }); + }); /* Verify that the file exists and is a regular file @@ -207,36 +228,38 @@ api.route('/download') ** AFTER THIS POINT, THE API HAS ALREADY SENT A RESPONSE, SO THE FUNCTION THAT CALLED IT SHOULD NOT RETURN ANOTHER RESPONSE ** */ async function verifyFile(file: string, res: Response) { - // Make sure the file being requested to run exists - try { - await access(file); - } catch (err) { - res.status(404).json({ error: 'File is not accessible or does not exist.' }); - return false; - } - // This is a try catch because otherwise type checking will fail and get all messed up - // Handle your promise rejections, kids - try { - const stats = await stat(file); - // Make sure the file being requested to run is a regular file - if (!stats.isFile()) { - res.status(400).json({ error: 'File is not a regular file.' }); - return false; - } - // Make sure the file being requested to run is not a directory - else if (stats.isDirectory()) { - res.status(400).json({ error: 'File is a directory.' }); - return false; - } - - - // File does exist and is a regular file, so it is good to go - return true; + // Make sure the file being requested to run exists + try { + await access(file); + } catch (err) { + res + .status(404) + .json({ error: 'File is not accessible or does not exist.' }); + return false; + } + // This is a try catch because otherwise type checking will fail and get all messed up + // Handle your promise rejections, kids + try { + const stats = await stat(file); + // Make sure the file being requested to run is a regular file + if (!stats.isFile()) { + res.status(400).json({ error: 'File is not a regular file.' }); + return false; } - catch (err) { - res.status(404).json({ error: 'File is not accessible or does not exist.' }); - return false; + // Make sure the file being requested to run is not a directory + else if (stats.isDirectory()) { + res.status(400).json({ error: 'File is a directory.' }); + return false; } + + // File does exist and is a regular file, so it is good to go + return true; + } catch (err) { + res + .status(404) + .json({ error: 'File is not accessible or does not exist.' }); + return false; + } } export default api; |