Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
__pycache__
*.pyc
*.pyo
.env
.git
.github
tests/
venv/
27 changes: 27 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: Python CI

on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]

jobs:
build:

runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3
- name: Set up Python 3.12
uses: actions/setup-python@v3
Comment on lines +15 to +17
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Update GitHub Actions to v4.

The actions/checkout@v3 and actions/setup-python@v3 actions are outdated. Version 3 uses Node.js 16 which is deprecated on GitHub Actions runners.

🔧 Proposed fix
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
     - name: Set up Python 3.12
-      uses: actions/setup-python@v3
+      uses: actions/setup-python@v4
       with:
         python-version: "3.12"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- uses: actions/checkout@v3
- name: Set up Python 3.12
uses: actions/setup-python@v3
- uses: actions/checkout@v4
- name: Set up Python 3.12
uses: actions/setup-python@v4
with:
python-version: "3.12"
🧰 Tools
🪛 actionlint (1.7.10)

15-15: the runner of "actions/checkout@v3" action is too old to run on GitHub Actions. update the action's version to fix this issue

(action)


17-17: the runner of "actions/setup-python@v3" action is too old to run on GitHub Actions. update the action's version to fix this issue

(action)

🤖 Prompt for AI Agents
In @.github/workflows/main.yml around lines 15 - 17, Update the GitHub Actions
steps that reference deprecated major versions: replace uses:
actions/checkout@v3 with uses: actions/checkout@v4 and uses:
actions/setup-python@v3 with uses: actions/setup-python@v4 in the workflow file;
verify any step names or inputs that changed between v3→v4 and adjust their
arguments if needed, rerun CI to confirm runner/node compatibility, and update
any other workflow occurrences of these action identifiers.

with:
python-version: "3.12"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Test with pytest
run: |
export PYTHONPATH=$PYTHONPATH:.
pytest
Comment on lines +24 to +27
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Set API_KEY environment variable for tests.

The test suite requires the API_KEY environment variable (as seen in src/config.py which raises ValueError if not set). Without it, the CI job will fail when importing the app module.

🔧 Proposed fix
     - name: Test with pytest
+      env:
+        API_KEY: test-key
       run: |
         export PYTHONPATH=$PYTHONPATH:.
         pytest
🤖 Prompt for AI Agents
In @.github/workflows/main.yml around lines 24 - 27, The CI step named "Test
with pytest" needs to set the API_KEY env var because src/config.py raises a
ValueError if API_KEY is missing; update that step to export or provide API_KEY
(e.g., export API_KEY=${{ secrets.API_KEY }} or add an env: API_KEY: ${{
secrets.API_KEY }}) before running pytest so imports and tests succeed.

8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
__pycache__/
*.py[cod]
*.so
.env
venv/
.pytest_cache/
.coverage
htmlcov/
16 changes: 16 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .

RUN pip install --no-cache-dir -r requirements.txt

COPY . .

# Expose the port
EXPOSE 5150

# Run the application with Gunicorn
# Bind to 0.0.0.0:5150
CMD ["gunicorn", "--bind", "0.0.0.0:5150", "src.app:app"]
13 changes: 13 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
install:
pip install -r requirements.txt

test:
export PYTHONPATH=$PYTHONPATH:. && pytest

run:
export API_KEY=dev-key && python -m src.app

clean:
rm -rf __pycache__
rm -rf src/__pycache__
rm -rf tests/__pycache__
139 changes: 119 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,32 +1,131 @@
# Matplotlib Graph Render API

This is a minimal Flask API to execute Python code with matplotlib and numpy and return a generated graph as a PNG image.
![Python](https://img.shields.io/badge/python-3.12-blue.svg)
![Flask](https://img.shields.io/badge/flask-3.0.0-green.svg)
![License](https://img.shields.io/badge/license-MIT-blue.svg)

## Usage
A robust, containerized Flask API that allows you to execute Python code remotely to generate Matplotlib graphs. This service is designed for headless environments and includes authentication and basic logging.

- POST to `/render-matplotlib` with JSON body:
`{ "code": "YOUR PYTHON CODE HERE" }`
## Features

- Returns: PNG image (mimetype `image/png`)
- **Headless Rendering:** Uses Matplotlib's Agg backend to render graphs without a display.
- **Secure-ish Execution:** Executes user-provided Python code in a restricted scope (see Security Note).
- **Authentication:** Protects endpoints with API Key authentication.
- **Logging:** Structured logging for monitoring and debugging.
- **Dockerized:** Ready for deployment using Docker.
- **Tested:** Comprehensive test suite using `pytest`.

## Example Python code to send
## Project Structure

```
import numpy as np
import matplotlib.pyplot as plt
x = np.linspace(0, 10, 1000)
y = np.sin(x)
plt.plot(x, y)
plt.title('Sine Wave')
.
├── src/
│ ├── app.py # Main Flask application
│ ├── config.py # Configuration management
│ └── executor.py # Code execution logic
├── tests/ # Unit tests
├── Dockerfile # Container definition
├── Makefile # Convenience commands
└── requirements.txt # Python dependencies
```
Comment on lines 20 to 30
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix markdownlint issues (code fence language + list indentation).

These are flagged by MD040 and MD007 and can break doc linting in CI.

✍️ Proposed README formatting fixes
-```
+```text
 .
 ├── src/
 │   ├── app.py          # Main Flask application
 │   ├── config.py       # Configuration management
 │   └── executor.py     # Code execution logic
 ├── tests/              # Unit tests
 ├── Dockerfile          # Container definition
 ├── Makefile            # Convenience commands
 └── requirements.txt    # Python dependencies

@@
-- Headers:

    • Content-Type: application/json
    • X-API-Key: <your-api-key>
      +- Headers:
    • Content-Type: application/json
    • X-API-Key: <your-api-key>
      @@
      -- Response:
    • 200 OK: Returns the PNG image (MIME type image/png).
    • 400 Bad Request: Missing code or invalid JSON.
    • 401 Unauthorized: Invalid or missing API Key.
    • 500 Internal Server Error: Error during code execution (returns error trace).
      +- Response:
    • 200 OK: Returns the PNG image (MIME type image/png).
    • 400 Bad Request: Missing code or invalid JSON.
    • 401 Unauthorized: Invalid or missing API Key.
    • 500 Internal Server Error: Error during code execution (returns error trace).
</details>


Also applies to: 89-104

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.20.0)</summary>

20-20: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

</details>

</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

In @README.md around lines 20 - 30, Update the README's code fence and list
indentation to satisfy markdownlint: add a language tag to the top tree code
fence (use ```text) and normalize the unordered list indentation under the
"Headers" and "Response" sections to use consistent two-space nesting (e.g., "

  • ...") so MD040 and MD007 pass; locate and update the code fence block at the
    top of the file and the "Headers" / "Response" bullet lists accordingly.

</details>

<!-- fingerprinting:phantom:medusa:eagle -->

<!-- This is an auto-generated comment by CodeRabbit -->


## Deploy on Render
## Getting Started

1. Upload these files to GitHub
2. Connect GitHub repo to Render.com, create a new Web Service
3. Use `pip install -r requirements.txt` as the build command
4. Use `python app.py` as the start command
5. Set PORT environment variable to `$PORT` if required by Render
### Prerequisites

---
**Security Note:** This runs arbitrary code, so use with care.
- Python 3.10+
- Pip

### Installation

1. Clone the repository:
```bash
git clone https://github.com/your-username/matplotlib-render-api.git
cd matplotlib-render-api
```

2. Install dependencies:
```bash
make install
# or
pip install -r requirements.txt
```

3. Set up environment variables:
Create a `.env` file in the root directory:
```env
PORT=5150
API_KEY=your-secret-api-key
DEBUG=True
```

### Running Locally

```bash
make run
# or
export API_KEY=my-secret-key
python -m src.app
```

The server will start at `http://0.0.0.0:5150`.

## API Documentation

### 1. Health Check

**GET** `/`

Returns a simple status message to verify the service is running.

- **Response:** `200 OK`

### 2. Render Graph

**POST** `/render-matplotlib`

Executes the provided Python code and returns the generated graph as a PNG image.

- **Headers:**
- `Content-Type: application/json`
- `X-API-Key: <your-api-key>`

- **Body:**
```json
{
"code": "import numpy as np\nimport matplotlib.pyplot as plt\nx = np.linspace(0, 10, 100)\nplt.plot(x, np.sin(x))\n"
}
```

- **Response:**
- `200 OK`: Returns the PNG image (MIME type `image/png`).
- `400 Bad Request`: Missing code or invalid JSON.
- `401 Unauthorized`: Invalid or missing API Key.
- `500 Internal Server Error`: Error during code execution (returns error trace).

## Docker

Build and run the container:

```bash
docker build -t matplotlib-api .
docker run -p 5150:5150 -e API_KEY=secret matplotlib-api
```

## Testing

Run the test suite using `pytest`:

```bash
make test
# or
export PYTHONPATH=$PYTHONPATH:.
pytest
```

## Security Note

**⚠️ Warning:** This application uses Python's `exec()` function to execute arbitrary code sent by the client. While basic sandboxing is attempted, **it is not secure against malicious attacks**.
- **Do not** expose this API publicly without strict firewalls and trusted clients.
- The current implementation allows imports to facilitate library usage, which increases risk.
- For a truly secure environment, consider running the execution logic inside an isolated ephemeral container (e.g., AWS Lambda, Firecracker microVMs).
42 changes: 0 additions & 42 deletions app.py

This file was deleted.

9 changes: 6 additions & 3 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
flask
matplotlib
numpy
flask==3.0.0
matplotlib==3.8.2
numpy==1.26.2
python-dotenv==1.0.0
pytest==7.4.3
gunicorn==21.2.0
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

GHSA-hc5x-x2vx-497g gunicorn HTTP request response smuggling

💡 Result:

GHSA-hc5x-x2vx-497g (alias CVE-2024-6827) is a high-severity HTTP request/response smuggling issue in Gunicorn caused by improper validation of the Transfer-Encoding header; Gunicorn can fall back to Content-Length, enabling TE.CL request smuggling. Potential impacts listed include cache poisoning, data exposure, session manipulation, SSRF, XSS, and more. [1][2]

  • Affected: gunicorn < 22.0.0 (notably including 21.2.0). [1][3]
  • Fixed: 22.0.0 (Gunicorn 22.0.0 includes HTTP parser hardening and closes request smuggling vectors). [1][4]
  • Severity: CVSS 3.0 7.5 (HIGH). [1][2]
  • Remediation: Upgrade to Gunicorn 22.0.0 or later. [1][3]

Sources: GitHub Advisory Database [1], NVD entry [2], GitLab advisory [3], Gunicorn 22.0.0 release notes [4].


🌐 Web query:

GHSA-w3h3-4rj7-4ph4 gunicorn request smuggling

💡 Result:

GHSA-w3h3-4rj7-4ph4 (CVE-2024-1135) — Gunicorn HTTP request smuggling

  • Issue: Gunicorn improperly validates Transfer-Encoding headers. Requests with multiple/conflicting Transfer-Encoding headers can be parsed inconsistently, enabling HTTP Request Smuggling and potentially bypassing Gunicorn endpoint restrictions (accessing “restricted” endpoints). [1][2]
  • Affected: gunicorn < 22.0.0. [1][2]
  • Fixed in: 22.0.0. [1][2]
  • Attack precondition: Typically requires a network path (e.g., proxies/load balancers) that does not reject/filter malformed/invalid requests. [1][2]
  • Mitigation: Upgrade to Gunicorn 22.0.0+; if you can’t, block restricted endpoints upstream (firewall/proxy rules) and ensure front proxies reject ambiguous Transfer-Encoding/Content-Length combinations. [1][2]

Sources: GitHub Advisory Database (GHSA) [1], GitLab Advisory Database [2].


🌐 Web query:

gunicorn latest version CVE security vulnerabilities 21.2.0

💡 Result:

  • Latest Gunicorn version (as of Jan 29, 2026): 24.1.1 (released Jan 24, 2026) on PyPI. [1]
  • Gunicorn 21.2.0 security status: 21.2.0 is affected by multiple HTTP request smuggling vulnerabilities, including:

Action: If you are on 21.2.0, upgrade to >= 22.0.0 (or ideally the current 24.1.1) to remediate these CVEs. [1][2][4]

Sources
[1] PyPI – gunicorn 24.1.1 (release date, latest version): (pypi.org)
[2] GitHub Advisory (GHSA-w3h3-4rj7-4ph4 / CVE-2024-1135): (github.com)
[3] NVD (CVE-2024-1135): (nvd.nist.gov)
[4] GitHub Advisory (GHSA-hc5x-x2vx-497g / CVE-2024-6827): (github.com)
[5] NVD (CVE-2024-6827): (nvd.nist.gov)

Citations:


Upgrade gunicorn to address known HTTP request smuggling vulnerabilities.

The pinned version gunicorn==21.2.0 has two HIGH severity vulnerabilities (CVSS 7.5 each):

Both are fixed in 22.0.0 or later. Upgrade to at least 22.0.0 (current latest is 24.1.1).

🧰 Tools
🪛 OSV Scanner (2.3.2)

[HIGH] 6-6: gunicorn 21.2.0: Gunicorn HTTP Request/Response Smuggling vulnerability

(GHSA-hc5x-x2vx-497g)


[HIGH] 6-6: gunicorn 21.2.0: Request smuggling leading to endpoint restriction bypass in Gunicorn

(GHSA-w3h3-4rj7-4ph4)

🤖 Prompt for AI Agents
In `@requirements.txt` at line 6, The requirements.txt pins gunicorn to a
vulnerable version (gunicorn==21.2.0); update that entry to a non-vulnerable
release (at minimum 22.0.0) — e.g., replace the line "gunicorn==21.2.0" with a
safe version such as "gunicorn==24.1.1" or a constrained range like
"gunicorn>=22.0.0,<25.0.0", then regenerate any lockfiles or dependency
manifests and run your test suite/CI to ensure compatibility across the codebase
that depends on the gunicorn package.

Empty file added src/__init__.py
Empty file.
62 changes: 62 additions & 0 deletions src/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from flask import Flask, request, send_file, jsonify
import traceback
import logging
from functools import wraps
from .config import Config
from .executor import execute_plot_code

# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)

app = Flask(__name__)
app.config.from_object(Config)

def require_api_key(f):
@wraps(f)
def decorated(*args, **kwargs):
api_key = request.headers.get('X-API-Key')
if not api_key or api_key != app.config['API_KEY']:
logger.warning("Unauthorized access attempt")
return jsonify({"error": "Unauthorized"}), 401
return f(*args, **kwargs)
return decorated

@app.route('/')
def index():
logger.info("Health check endpoint called")
return 'Matplotlib Graph Render API is live!', 200

@app.route('/render-matplotlib', methods=['POST'])
@require_api_key
def render_matplotlib():
logger.info("Render endpoint called")

if not request.is_json:
logger.warning("Request content-type is not JSON")
return jsonify({"error": "Content-Type must be application/json"}), 400

data = request.get_json()
code = data.get("code")
Comment on lines +38 to +43
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Flask 3.0 request.get_json() malformed JSON exception handling

💡 Result:

In Flask 3.x, request.get_json() is implemented by Werkzeug’s Request.get_json(). For malformed JSON (e.g., invalid syntax), the default behavior is to call on_json_loading_failed(), which raises a BadRequest (HTTP 400) unless you silence it. For non‑JSON / wrong Content-Type, Werkzeug may raise UnsupportedMediaType (HTTP 415) (this changed in Werkzeug 2.3+). [1]

Pattern: catch malformed JSON (400) and wrong content type (415)

from flask import request, jsonify
from werkzeug.exceptions import BadRequest, UnsupportedMediaType

`@app.post`("/items")
def items():
    try:
        data = request.get_json()  # same as request.json (default args)
    except BadRequest:
        return jsonify(error="Malformed JSON"), 400
    except UnsupportedMediaType:
        return jsonify(error="Content-Type must be application/json"), 415

    return jsonify(ok=True, data=data), 200

(Types and behaviors: BadRequest / UnsupportedMediaType are Werkzeug HTTP exceptions.) [1]

Pattern: “don’t throw, just return None”

Use silent=True to suppress both mimetype and parsing errors and get None instead. [1]

data = request.get_json(silent=True)
if data is None:
    return {"error": "Missing/invalid JSON"}, 400

Gotcha (repeated calls)

Older Flask/Werkzeug versions cached None when silent=True was used, which could mask a later non-silent call; Flask’s changelog notes this was fixed (“doesn’t cache the result if parsing fails when silent is true”). [2]

Sources: Werkzeug Request.get_json / on_json_loading_failed docs [1]; Flask changelog note about silent caching fix [2].

References:
[1] Werkzeug Request wrapper docs (get_json, on_json_loading_failed, 400/415 behavior): (werkzeug.palletsprojects.com)
[2] Flask changelog entry about Request.get_json not caching failures with silent=True: (flask.palletsprojects.com)

Citations:


🌐 Web query:

Flask request.get_json(silent=True) behavior invalid JSON

💡 Result:

request.get_json(silent=True) is meant to suppress JSON parsing errors. If the request body is invalid JSON, it will return None instead of raising (even if Content-Type: application/json is set). This is documented behavior of silent=True in Flask/Werkzeug. [1][2]

What happens without silent=True:

  • With silent=False (default), if parsing fails, on_json_loading_failed() is called; the default behavior is to raise an HTTP error (commonly 400 Bad Request for malformed JSON). [1][3]

Important edge case (older Flask versions):

  • Historically, calling get_json(silent=True) could cache None (because cache=True by default), so a later get_json() call in the same request could incorrectly keep returning None instead of raising. This was reported in Flask issue #2651 and later fixed: “Request.get_json doesn’t cache the result if parsing fails when silent is true.” [4][5]

Practical takeaways:

  • If you want invalid JSON to error, don’t use silent=True; catch the exception / let Flask return the error.
  • If you want to “try parse” but still want a later strict parse, either:
    • avoid calling it twice, or
    • in older Flask versions, call request.get_json(silent=True, cache=False) to avoid the caching pitfall. [4][5]

Sources:
[1] Flask Request.get_json docs (silent/caching behavior) https://tedboy.github.io/flask/generated/generated/flask.Request.get_json.html
[2] Werkzeug request/response wrappers docs (silent returns None) https://dokk.org/documentation/werkzeug/3.0.0/wrappers/
[3] Werkzeug JSONMixin docs (parsing failure calls on_json_loading_failed) https://www.typeerror.org/docs/werkzeug~0.15/wrappers/index
[4] Flask issue #2651 (silent cached None causing later calls not to raise) pallets/flask#2651
[5] Flask changelog noting the fix for #2651 https://flask.palletsprojects.com/en/stable/changes/


Handle invalid JSON bodies explicitly.

request.get_json() raises BadRequest (HTTP 400) on malformed JSON even when Content-Type: application/json is set, which can bypass your JSON error response. Use silent=True to suppress the exception and handle it explicitly.

Suggested fix
-    data = request.get_json()
+    data = request.get_json(silent=True)
+    if data is None:
+        logger.warning("Invalid JSON payload")
+        return jsonify({"error": "Invalid JSON"}), 400
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if not request.is_json:
logger.warning("Request content-type is not JSON")
return jsonify({"error": "Content-Type must be application/json"}), 400
data = request.get_json()
code = data.get("code")
if not request.is_json:
logger.warning("Request content-type is not JSON")
return jsonify({"error": "Content-Type must be application/json"}), 400
data = request.get_json(silent=True)
if data is None:
logger.warning("Invalid JSON payload")
return jsonify({"error": "Invalid JSON"}), 400
code = data.get("code")
🤖 Prompt for AI Agents
In `@src/app.py` around lines 38 - 43, Replace the direct call to
request.get_json() with a silent parse and explicit error handling: call
request.get_json(silent=True) where you currently call request.get_json(), then
check if the result is None and, if so, log a warning via logger.warning and
return the same JSON 400 response (e.g., jsonify({"error": "Invalid JSON
body"}), 400) before proceeding to access data.get("code"); keep the existing
Content-Type check and ensure the variables request, logger, and code are used
unchanged.


if not code or not isinstance(code, str):
logger.warning("Render request missing code or code is not a string")
return jsonify({"error": "Missing or invalid 'code' field"}), 400

try:
logger.info("Executing plot code")
buf = execute_plot_code(code)
logger.info("Plot generated successfully")
return send_file(buf, mimetype='image/png')
except Exception as e:
logger.error(f"Error executing code: {e}")
logger.error(traceback.format_exc())
# In production, we might want to log the full trace but hide it from the user
# keeping original behavior for now
return jsonify({"error": traceback.format_exc()}), 500
Comment on lines +54 to +59
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's verify the code exists and examine the context
if [ -f "src/app.py" ]; then
  echo "=== File found, reading lines 50-65 for context ==="
  sed -n '50,65p' src/app.py
else
  echo "src/app.py not found"
fi

Repository: 09Catho/matplotownapi

Length of output: 698


🏁 Script executed:

# Check if there are tests that might reference this error handling
fd -t f -e py | xargs rg -l "error executing code|traceback.format_exc" 2>/dev/null | head -20

Repository: 09Catho/matplotownapi

Length of output: 75


🌐 Web query:

Flask best practices error handling DEBUG mode return tracebacks

💡 Result:

What Flask does in DEBUG mode (tracebacks)

  • When you run Flask with debug enabled (e.g., flask run --debug or app.run(debug=True)), Werkzeug’s built-in debugger shows an interactive traceback page in the browser for unhandled exceptions. (flask.palletsprojects.com)
  • In debug mode, a registered 500 error handler is bypassed—Flask shows the interactive debugger instead. (flask.palletsprojects.com)
  • The debugger UI can also show a plain text traceback (useful for copy/paste). (werkzeug.palletsprojects.com)

Production best practice: do not return tracebacks

  • Never enable the debugger / dev server in production. The debugger can allow arbitrary code execution from the browser (PIN is not a security boundary). (flask.palletsprojects.com)
  • Instead: return a generic error response to clients, and capture details via logging / error monitoring (Flask docs explicitly suggest tools like Sentry). (flask.palletsprojects.com)

Recommended error-handling pattern (hide details, log internally)

Use Flask error handlers to return safe responses, while logging the real exception server-side:

from flask import Flask, jsonify
from werkzeug.exceptions import HTTPException

app = Flask(__name__)

`@app.errorhandler`(HTTPException)
def handle_http(e):
    # client errors like 404/405/etc.
    return jsonify(error=e.name), e.code

`@app.errorhandler`(Exception)
def handle_unexpected(e):
    # server errors
    app.logger.exception("Unhandled exception")  # logs stack trace
    return jsonify(error="Internal Server Error"), 500

Key points from Flask’s error-handling rules:

  • Error handlers can be registered by status code or exception class, and Flask resolves the “most specific” handler. (flask.palletsprojects.com)
  • When no handler exists, Flask converts non-HTTP exceptions into a generic 500. (flask.palletsprojects.com)

If you really need tracebacks outside debug (generally: don’t)

  • Werkzeug provides DebuggedApplication middleware to render tracebacks, but it is explicitly a major security risk and must not be enabled on anything publicly accessible. (werkzeug.palletsprojects.com)
  • If you wrap your app with that middleware, Flask may still swallow exceptions unless you configure Flask to re-raise them (e.g., PROPAGATE_EXCEPTIONS=True). (stackoverflow.com)

Citations:


🏁 Script executed:

# Check if DEBUG config is set up in the Flask app
rg -A 5 -B 5 "app.config|DEBUG" src/app.py | head -40

Repository: 09Catho/matplotownapi

Length of output: 860


🏁 Script executed:

# Search for tests that might depend on the error response format
fd -t f -e py test | xargs rg -l "error|traceback|500" 2>/dev/null

Repository: 09Catho/matplotownapi

Length of output: 82


🏁 Script executed:

# Check the full app configuration to understand DEBUG setup
cat -n src/app.py | head -30

Repository: 09Catho/matplotownapi

Length of output: 1115


🏁 Script executed:

# Find and read the Config class
fd -t f -name "config.py" | head -5 | xargs cat -n

Repository: 09Catho/matplotownapi

Length of output: 295


🏁 Script executed:

# Read the test file to see what error response format is expected
cat -n tests/test_app.py

Repository: 09Catho/matplotownapi

Length of output: 2251


🏁 Script executed:

# Find config.py and read it
find . -name "config.py" -type f | xargs cat -n

Repository: 09Catho/matplotownapi

Length of output: 546


🏁 Script executed:

# Also check if there's a conftest.py that sets up the test client and app
find . -name "conftest.py" -type f | xargs cat -n

Repository: 09Catho/matplotownapi

Length of output: 681


Return generic error responses in production; log tracebacks server-side only.

Returning traceback.format_exc() in the JSON response leaks internals and exposes sensitive details. Flask best practices require logging the full traceback server-side only (via logger.exception()) and returning a generic error message to clients unless DEBUG is enabled.

The DEBUG configuration is already available in your Config class, so the proposed fix using app.config.get("DEBUG") is immediately applicable. However, you must update test_render_execution_error in tests/test_app.py to either:

  • Set DEBUG=true in the test environment, or
  • Adjust the assertion to expect a generic error message instead of the specific exception text.
🔒 Safer error response
    except Exception as e:
        logger.error(f"Error executing code: {e}")
        logger.error(traceback.format_exc())
        # In production, we might want to log the full trace but hide it from the user
        # keeping original behavior for now
        return jsonify({"error": traceback.format_exc()}), 500
+    except Exception:
+        logger.exception("Error executing code")
+        if app.config.get("DEBUG"):
+            return jsonify({"error": traceback.format_exc()}), 500
+        return jsonify({"error": "Execution failed"}), 500
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
except Exception as e:
logger.error(f"Error executing code: {e}")
logger.error(traceback.format_exc())
# In production, we might want to log the full trace but hide it from the user
# keeping original behavior for now
return jsonify({"error": traceback.format_exc()}), 500
except Exception:
logger.exception("Error executing code")
if app.config.get("DEBUG"):
return jsonify({"error": traceback.format_exc()}), 500
return jsonify({"error": "Execution failed"}), 500
🧰 Tools
🪛 Ruff (0.14.14)

54-54: Do not catch blind exception: Exception

(BLE001)


55-55: Use logging.exception instead of logging.error

Replace with exception

(TRY400)


56-56: Use logging.exception instead of logging.error

Replace with exception

(TRY400)

🤖 Prompt for AI Agents
In `@src/app.py` around lines 54 - 59, The current exception handler in src/app.py
logs and returns the full traceback; change it to call logger.exception(...) to
log the full traceback server-side and, using app.config.get("DEBUG"), return
the full traceback only when DEBUG is true otherwise return a generic JSON error
like {"error":"Internal server error"} with status 500; update the handler
around the except block (where logger.error and traceback.format_exc() are used)
to implement this behavior and then update
tests/test_app.py::test_render_execution_error to either set
app.config["DEBUG"]=True for the test or change its assertion to expect the
generic error message when DEBUG is False.


if __name__ == '__main__':
app.run(host='0.0.0.0', port=app.config['PORT'])
13 changes: 13 additions & 0 deletions src/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import os
from dotenv import load_dotenv

# Load environment variables from .env file if it exists
load_dotenv()

class Config:
"""Base configuration."""
PORT = int(os.environ.get("PORT", 5150))
API_KEY = os.environ.get("API_KEY")
if not API_KEY:
raise ValueError("No API_KEY set for Flask application")
DEBUG = os.environ.get("DEBUG", "False").lower() == "true"
Comment on lines +7 to +13
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Class-level validation runs at import time, breaking tests and flexibility.

The API_KEY validation at lines 11-12 executes when the module is imported (during class body evaluation), not when the config is used. This causes issues:

  1. Tests must set API_KEY before any import of this module
  2. Cannot import the module to inspect it without a valid key
  3. Tools like linters or documentation generators that import the module will fail

Consider deferring validation to a method or property, or using a factory pattern.

🔧 Proposed fix using a classmethod
 class Config:
     """Base configuration."""
     PORT = int(os.environ.get("PORT", 5150))
-    API_KEY = os.environ.get("API_KEY")
-    if not API_KEY:
-        raise ValueError("No API_KEY set for Flask application")
     DEBUG = os.environ.get("DEBUG", "False").lower() == "true"
+
+    `@classmethod`
+    def get_api_key(cls):
+        api_key = os.environ.get("API_KEY")
+        if not api_key:
+            raise ValueError("No API_KEY set for Flask application")
+        return api_key

Then update consumers to call Config.get_api_key() instead of accessing Config.API_KEY directly.

🧰 Tools
🪛 Ruff (0.14.14)

12-12: Avoid specifying long messages outside the exception class

(TRY003)

🤖 Prompt for AI Agents
In `@src/config.py` around lines 7 - 13, The class-level API_KEY validation runs
at import time; change it to lazy validation by removing the raise from the
class body and adding a classmethod (e.g., Config.get_api_key()) or a property
(e.g., Config.api_key()) that reads os.environ.get("API_KEY"), checks for
presence, and raises ValueError only when called; update all callers to use this
new method/property instead of accessing Config.API_KEY directly and keep PORT
and DEBUG as-is.

32 changes: 32 additions & 0 deletions src/executor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import matplotlib
matplotlib.use('Agg') # For headless servers
import matplotlib.pyplot as plt
import numpy as np
import io
import traceback

def execute_plot_code(code: str) -> io.BytesIO:
"""
Executes the provided Python code in a restricted environment
and returns a BytesIO buffer containing the PNG image.

Raises Exception if code execution fails.
"""
# Setup a fresh plot context
plt.close('all')
buf = io.BytesIO()

# Allow only safe builtins (very basic sandboxing)
# Note: exec is dangerous. In a real production env, use better isolation.
# We allow builtins so imports work, but this is not secure.
safe_globals = {"plt": plt, "np": np}

try:
exec(code, safe_globals)
plt.savefig(buf, format='png', bbox_inches='tight', dpi=200)
plt.close()
buf.seek(0)
return buf
except Exception:
plt.close()
Comment on lines +19 to +31
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/executor.py

Repository: 09Catho/matplotownapi

Length of output: 1213


🏁 Script executed:

rg -i "timeout|signal|alarm|resource|rlimit" --type py

Repository: 09Catho/matplotownapi

Length of output: 47


🏁 Script executed:

rg "execute_plot_code" --type py -B 2 -A 2

Repository: 09Catho/matplotownapi

Length of output: 709


🏁 Script executed:

rg -i "sandbox|isolation|process|subprocess|container" --type py

Repository: 09Catho/matplotownapi

Length of output: 230


🏁 Script executed:

cat -n src/app.py

Repository: 09Catho/matplotownapi

Length of output: 2548


🏁 Script executed:

rg "auth|validate|limit|rate" --type py -i

Repository: 09Catho/matplotownapi

Length of output: 451


🏁 Script executed:

find . -name "README*" -o -name "*.md" | head -5

Repository: 09Catho/matplotownapi

Length of output: 76


🏁 Script executed:

cat README.md

Repository: 09Catho/matplotownapi

Length of output: 3620


🏁 Script executed:

cat -n tests/test_app.py | head -100

Repository: 09Catho/matplotownapi

Length of output: 2251


Code execution lacks timeout and resource limits, enabling DoS attacks.

The safe_globals restricts the execution environment to plt and np only—preventing imports and filesystem/network access—but provides no defense against infinite loops or memory exhaustion. An authenticated client can hang or crash the worker process. While the code already acknowledges this limitation ("this is not secure"), consider adding execution timeouts (e.g., signal.alarm() for CPU limits or third-party solutions like timeout package) and memory constraints to prevent resource exhaustion attacks.

🧰 Tools
🪛 Ruff (0.14.14)

25-25: Use of exec detected

(S102)


29-29: Consider moving this statement to an else block

(TRY300)

🤖 Prompt for AI Agents
In `@src/executor.py` around lines 19 - 31, The exec-based execution in this
module (exec(code, safe_globals)) has no timeout or memory limits and can be
DoS'ed; move execution into a separate sandboxed child process (e.g.,
multiprocessing or a subprocess) that runs the exec with the same safe_globals
and performs plt.savefig to a temp file/pipe, and enforce limits in the child
using resource.setrlimit(RLIMIT_AS, RLIMIT_CPU) or signal.alarm (or a
third-party timeout wrapper) so infinite loops and memory exhaustion are
bounded; ensure the parent waits with a timeout, kills the child on expiry,
retrieves the image buffer (instead of relying on in-process buf), and always
call plt.close in the child cleanup to avoid leaking state.

raise
Loading