Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 61 additions & 29 deletions api/python_exec_api.py
Original file line number Diff line number Diff line change
@@ -1,41 +1,73 @@
# /api/python_exec_api.py
from flask import Blueprint, request, jsonify
from flask import Blueprint, Flask, request
from flask_restful import Api, Resource
import subprocess, tempfile, os
import subprocess, tempfile, os, requests

python_exec_api = Blueprint('python_exec_api', __name__, url_prefix='/run')

api = Api(python_exec_api)

# todo: don't hardcode
RUNNER_URL = "http://code_runner:8591/python"

class PythonExec(Resource):
def post(self):
"""Executes submitted Python code safely in a short-lived subprocess."""
data = request.get_json()
data = request.get_json(silent=True) or {}
code = data.get("code", "")

if not code.strip():
return {"output": "⚠️ No code provided."}, 400

with tempfile.NamedTemporaryFile(delete=False, suffix=".py") as tmp:
tmp.write(code.encode())
tmp.flush()

try:
result = subprocess.run(
["python3", tmp.name],
capture_output=True,
text=True,
timeout=5,
cwd="/tmp", # Force working directory to /tmp
env={"HOME": "/tmp", "PATH": "/usr/bin:/usr/local/bin"} # Restricted environment
)
output = result.stdout + result.stderr
except subprocess.TimeoutExpired:
output = "⏱️ Execution timed out (5 s limit)."
except Exception as e:
output = f"Error running code: {str(e)}"
finally:
os.unlink(tmp.name)

return {"output": output}

api.add_resource(PythonExec, "/python")
is_production = os.environ.get("IS_PRODUCTION", "false").lower() == "true"

if is_production:
return _execute_remote(data)
# might have to update this in future; could be vuln
# skipping verbose check
else:
return _execute_local(code)


def _execute_local(code):
with tempfile.NamedTemporaryFile(delete=False, suffix=".py") as tmp:
tmp.write(code.encode())
tmp.flush()

try:
result = subprocess.run(
["python3", tmp.name],
capture_output=True,
text=True,
timeout=5,
cwd="/tmp", # Force working directory to /tmp
env={"HOME": "/tmp", "PATH": "/usr/bin:/usr/local/bin"} # Restricted environment
)
output = result.stdout + result.stderr
except subprocess.TimeoutExpired:
output = "Execution timed out (5 s limit)."
except Exception as e:
output = f"Error running code: {str(e)}"
finally:
os.unlink(tmp.name)

return {"output": output}

def _execute_remote(data):
try:
response = requests.post(
RUNNER_URL,
json=data,
timeout=10
)

return response.json(), response.status_code

except requests.Timeout:
return {"output": "Runner timed out."}, 504

except requests.RequestException as e:
return {
"output": f"Could not connect to code runner: {str(e)}"
}, 502


api.add_resource(PythonExec, "/python")
40 changes: 23 additions & 17 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
version: '3'
services:
web:
image: flask_open
build: .
env_file:
- .env # This file is optional; defaults will be used if it does not exist
ports:
- "8587:8587"
volumes:
- ./instance:/app/instance
restart: unless-stopped
web:
image: flask_open
build: .
env_file:
- .env # This file is optional; defaults will be used if it does not exist
ports:
- "8587:8587"
volumes:
- ./instance:/app/instance
restart: unless-stopped

socketio:
image: socket_open
build: ./socket
ports:
- "8500:8500"

restart: unless-stopped
socketio:
image: socket_open
build: ./socket
ports:
- "8500:8500"
restart: unless-stopped

code_runner:
image: flask_runner
build: ./runner
cap_drop:
- ALL
restart: unless-stopped
16 changes: 16 additions & 0 deletions runner/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
FROM python:3.12-slim

WORKDIR /app

# Install Python deps first for better layer caching
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r /app/requirements.txt

# Copy service code
COPY . /app

EXPOSE 8591

# Run Flask with Gunicorn
CMD ["gunicorn", "--bind", "0.0.0.0:8591", "--workers", "2", "runner:runner"]
2 changes: 2 additions & 0 deletions runner/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
flask
gunicorn
41 changes: 41 additions & 0 deletions runner/runner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from flask import Flask, jsonify, request
import subprocess, tempfile, os

runner = Flask(__name__)

@runner.post("/python")
def run_python():
data = request.get_json()
code = data.get("code", "")

if not code.strip():
return {"output": "No code provided."}, 400

with tempfile.NamedTemporaryFile(delete=False, suffix=".py") as tmp:
tmp.write(code.encode())
tmp.flush()

try:
result = subprocess.run(
["python3", tmp.name],
capture_output=True,
text=True,
timeout=5,
cwd="/tmp", # Force working directory to /tmp
env={"HOME": "/tmp", "PATH": "/usr/bin:/usr/local/bin"} # Restricted environment
)
output = result.stdout + result.stderr
except subprocess.TimeoutExpired:
output = "Execution timed out (5 s limit)."
except Exception as e:
output = f"Error running code: {str(e)}"
finally:
os.unlink(tmp.name)

return {"output": output}

if __name__ == "__main__":
host = "0.0.0.0"
port = 8591
print(f"** Server running: http://localhost:{port}") # Pretty link
runner.run(debug=True, host=host, port=port, use_reloader=False)