aboutsummaryrefslogtreecommitdiffstats
path: root/src/routes/api.ts
blob: 798fb2744adbb9153d5e9f647189abd0bec619e9 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
import { spawn } from 'child_process';
import cookieParser from 'cookie-parser';
import csurf from 'csurf';
import express, { Request, Response } from 'express';
import fileUpload, { UploadedFile } from 'express-fileupload';
import { access, stat } from 'fs/promises';
import { quote } from 'shell-quote';

const api = express.Router();

// Use JSON parser for API requests and responses
api.use(express.json());
// CSRF protection
api.use(cookieParser());
const csrf = csurf({ cookie: true });

// For file uploads
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
    POST /api/v1/upload
    Parameters:
        files: The file to upload
    Returns:
        201:
            {
                "message": "File uploaded successfully",
                "file": {
                    "name": "file.py",
                    "path": "/tmp/file-538126",
                }
            }
        400 when there is no file
        413 when the file is too large
        415 when the file's MIME type is not text/x-python
        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')
                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
    POST /api/v1/actuate
    Parameters:
        file: {
            name: The name of the file to run, currently unused
            path: The path to the uploaded file on the server, passed in from /api/v1/upload on the website
        }
    Returns:
        200:
            {
                "stdout": "Hello from Python!\n",
                "file": {
                    "name": "file.py",
                    "filename": "file-538126",
                }
            }
        400 for when the file passed in is not a regular file
        403 when the file is not accessible
        500:
            {
                "error": "Program exited with error code 1.",
                "error_msg": "NameError: name 'sleep' is not defined",
            }
    
    This route is probably a complete security nightmare. It allows anyone to run arbitrary Python code on the server.

    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
            /*
            TODO:
            - Potentially add the limiter to one-per-person here
            - Add a timeout
                - Communicate to the machine to give up (the user as well), maybe just kill the process?
            - Make this more secure
                - HOW?
        */
            let output = '';
            const actuation = spawn('python', escaped.split(' '));
            actuation.stdout.on('data', (data: Buffer) => {
                output += data.toString();
            });
            actuation.stderr.on('data', (data: Buffer) => {
                output += `STDERR: ${data.toString()}`;
            });
            actuation.on('close', (code: number) => {
                // 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: output });
                const filename: string = (req.body.file.path as string).split('/').pop() as string;
                return res.status(200).json({ stdout: output, 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.' });
    });


api.route('/download')
    .get(csrf, async (req: Request, res: Response) => {
        const path: string = `/tmp/${req.query.filename}.csv` as string;

        // 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(path))
            return res.status(403).json({ error: 'Get lost' });

        // 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
    Parameters:
        path: The path to the file on the server
        res: The response object to send the unsuccessful response to
    Returns:
        true: The file exists and is a regular file
        false: The file does not exist or is not a regular file
        ** 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(403).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;
    }
    catch (err) {
        res.status(404).json({ error: 'File is not accessible or does not exist.' });
        return false;
    }
}

export default api;