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
39 changes: 24 additions & 15 deletions node/server_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

from flask import Flask, request, jsonify
import requests
import json
from urllib.parse import quote

app = Flask(__name__)
Expand All @@ -22,6 +21,23 @@ def _build_local_api_url(path):
safe_path = "/".join(quote(part, safe="") for part in parts)
return f"{LOCAL_SERVER}/api/{safe_path}"


def _relay_upstream_response(response):
"""Relay upstream responses without exposing internal 5xx details."""
if response.status_code >= 500:
app.logger.warning("Upstream server returned %s for proxied request", response.status_code)
return jsonify({'error': 'Upstream server error'}), 502

content_type = response.headers.get('Content-Type', '')
if 'application/json' in content_type:
try:
return jsonify(response.json()), response.status_code
except ValueError:
app.logger.warning("Upstream server returned invalid JSON with JSON content type")
return jsonify({'error': 'Invalid upstream JSON response'}), 502

return response.text, response.status_code

@app.route('/api/<path:path>', methods=['GET', 'POST'])
def proxy(path):
"""Forward all API requests to local server"""
Expand All @@ -43,23 +59,16 @@ def proxy(path):
# Forward GET requests
response = requests.get(url, timeout=10)

# Return the response from local server
# Safely handle non-JSON responses from upstream
content_type = response.headers.get('Content-Type', '')
if 'application/json' in content_type:
try:
return response.json(), response.status_code
except (ValueError, Exception):
# JSON parse failed, fall back to text
return response.text, response.status_code
else:
# Non-JSON response (e.g., HTML error page), return as-is with text
return response.text, response.status_code
return _relay_upstream_response(response)

except requests.exceptions.Timeout:
return jsonify({'error': 'Local server timeout'}), 504
except Exception as e:
return jsonify({'error': str(e)}), 500
except requests.exceptions.RequestException:
app.logger.warning("Local server request failed", exc_info=True)
return jsonify({'error': 'Local server unavailable'}), 502
except Exception:
app.logger.exception("Unexpected proxy error")
return jsonify({'error': 'Proxy error'}), 500

@app.route('/status')
def status():
Expand Down
44 changes: 44 additions & 0 deletions node/tests/test_server_proxy_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import sys
import unittest
from pathlib import Path
from unittest.mock import Mock, patch

NODE_DIR = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(NODE_DIR))

import server_proxy


class ServerProxyErrorTests(unittest.TestCase):
def setUp(self):
server_proxy.app.config["TESTING"] = True
self.client = server_proxy.app.test_client()

@patch("server_proxy.requests.get")
def test_proxy_exception_does_not_leak_message(self, mock_get):
mock_get.side_effect = RuntimeError("secret filesystem path")

response = self.client.get("/api/stats")

self.assertEqual(response.status_code, 500)
body = response.get_json()
self.assertEqual(body, {"error": "Proxy error"})
self.assertNotIn("secret filesystem path", response.get_data(as_text=True))

@patch("server_proxy.requests.get")
def test_upstream_500_body_is_redacted(self, mock_get):
upstream = Mock()
upstream.status_code = 500
upstream.headers = {"Content-Type": "text/plain"}
upstream.text = "Traceback: database password is hunter2"
mock_get.return_value = upstream

response = self.client.get("/api/stats")

self.assertEqual(response.status_code, 502)
self.assertEqual(response.get_json(), {"error": "Upstream server error"})
self.assertNotIn("hunter2", response.get_data(as_text=True))


if __name__ == "__main__":
unittest.main()