aboutsummaryrefslogtreecommitdiffstats
path: root/src/routes/api.ts
blob: 77b3c795e6e75ac66071262b5dab73a983d69106 (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
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 { Stats } from 'fs';
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.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:
        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",
            }
        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) => {
    // 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 or does not exist.' });
    }


    const stats: Stats = await stat(req.body.path);
    // Make sure the file being requested to run is a regular file
    if (!stats.isFile())
      return res.status(400).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(400).json({ error: 'File is a directory.' });

    const escaped = quote([req.body.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) => {
      if (code !== 0)
        return res.status(500).json({ error: `Program exited with exit code ${code}`, error_msg: output });
      return res.status(200).json({ stdout: output });
    });
  })
  // Fallback
  .all(csrf, (req: Request, res: Response) => {
    res.set('Allow', 'POST');
    res.status(405).json({ error: 'Method not allowed.' });
  });


export default api;