From dbe0c1e7a90be3d776093647c0e3f13d45811f2f Mon Sep 17 00:00:00 2001 From: khahani Date: Fri, 7 Aug 2026 11:45:07 +0400 Subject: [PATCH 1/3] fix: harden public calibration API --- .github/workflows/verify.yml | 23 ++++++++++ .python-version | 1 + Procfile | 1 - README.md | 41 +++++++++++++++++ app.py | 54 +++++++++++++++++------ requirements-dev.txt | 3 ++ requirements.txt | 16 +++---- test_app_security.py | 85 ++++++++++++++++++++++++++++++++++++ 8 files changed, 202 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/verify.yml create mode 100644 .python-version create mode 100644 requirements-dev.txt create mode 100644 test_app_security.py diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..faae74b --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,23 @@ +name: Verify + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + cache: pip + python-version-file: .python-version + - run: python -m pip install -r requirements-dev.txt + - run: python -m pytest -q + - run: python -m pip_audit --local diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/Procfile b/Procfile index 1e5b98f..d1c6a16 100644 --- a/Procfile +++ b/Procfile @@ -1,2 +1 @@ web: gunicorn --workers 12 app:app --preload --max-requests 1 --timeout 600 -requirements.txt \ No newline at end of file diff --git a/README.md b/README.md index 31fdf4a..81110ab 100644 --- a/README.md +++ b/README.md @@ -1 +1,42 @@ # python-flask-server + +This Flask service performs server-side signal processing for speaker +calibration. `speaker-calibration/src/server/PythonServerAPI.js` references the +public endpoint `https://easyeyes-python-flask-server.herokuapp.com`, which was +reachable during the 2026-08-07 security review. The individual maintainer and +current deployment pipeline are not yet documented. + +## Runtime + +Use Python 3.11, as recorded in `.python-version`. The scientific dependency +pins do not build under Python 3.13 in the current development environment. + +```sh +python3.11 -m venv .venv +. .venv/bin/activate +python -m pip install -r requirements-dev.txt +python -m pytest -q +python -m pip_audit --local +``` + +## Security configuration + +- `EASYEYES_ALLOWED_ORIGINS`: comma-separated exact browser origins allowed to + call `/task/*` and `/model/*`. There is no wildcard or default browser origin. +- `EASYEYES_MAX_CONTENT_LENGTH`: maximum request body in bytes. The default is + 33,554,432 bytes (32 MiB) to accommodate calibration arrays while bounding + memory use. +- `EASYEYES_DIAGNOSTICS_TOKEN`: bearer token for `/memory` and `/snapshot`. + Those routes return `404` when no token is configured and `401` for an + incorrect token. + +Task routes remain an unauthenticated computation API because the active +client contract does not provide an identity mechanism. Before deploying this +change, operators must configure the exact active origins and verify realistic +calibration payload sizes. A public deployment still needs an upstream rate +limit, request-duration/CPU controls, monitoring, a named owner, and a decision +on whether task authentication is required. + +The older `python-server` repository is a near-duplicate but no active client +reference was found. Confirm and retire it instead of maintaining two public +copies of this service. diff --git a/app.py b/app.py index efa79ca..25cc36e 100644 --- a/app.py +++ b/app.py @@ -1,4 +1,5 @@ import os +import hmac import tracemalloc import gc import psutil @@ -8,8 +9,8 @@ import matplotlib matplotlib.use("Agg") import time -from flask import Flask, request, make_response -from flask_cors import CORS, cross_origin +from flask import Flask, request, make_response, abort +from flask_cors import CORS from impulse_response import run_ir_task, estimate_samples_per_mls_, adjust_mls_length, compute_impulse_resp, impulse_to_frequency_response from inverted_impulse_response import run_component_iir_task, run_system_iir_task, run_convolution_task, run_ir_convolution_task, frequency_response_to_impulse_response from volume import run_volume_task,run_volume_task_nonlinear @@ -21,10 +22,41 @@ import math app = Flask(__name__) -CORS(app, resources = {r"/*": {"origins": "*"}}) +app.config["MAX_CONTENT_LENGTH"] = int( + os.getenv("EASYEYES_MAX_CONTENT_LENGTH", str(32 * 1024 * 1024)) +) + +allowed_origins = [ + origin.strip() + for origin in os.getenv("EASYEYES_ALLOWED_ORIGINS", "").split(",") + if origin.strip() +] +CORS( + app, + resources={ + r"/task/*": {"origins": allowed_origins, "methods": ["POST"]}, + r"/model/*": {"origins": allowed_origins, "methods": ["GET"]}, + }, +) process = psutil.Process(os.getpid()) tracemalloc.start() +s = None + + +def require_diagnostics_token(): + configured_token = os.getenv("EASYEYES_DIAGNOSTICS_TOKEN", "") + if not configured_token: + abort(404) + + authorization = request.headers.get("Authorization", "") + provided_token = ( + authorization.removeprefix("Bearer ") + if authorization.startswith("Bearer ") + else "" + ) + if not hmac.compare_digest(provided_token, configured_token): + abort(401) def handle_autocorrelation_task(request_json, task): @@ -551,33 +583,30 @@ def print_memory_usage(): print("memory used:", round(process.memory_info().rss / 1024 ** 2), "mb") @app.route("/task/", methods=['POST']) -@cross_origin() def task_handler(task): print_memory_usage() gc.collect() if task not in SUPPORTED_TASKS: - return 'ERROR' - content_type = request.headers.get('Content-Type') - if (content_type == 'application/json'): + abort(404) + if request.is_json: headers = {"Content-Type": "application/json"} status, result = SUPPORTED_TASKS[task](request.get_json(cache=False), task) - request.data resp = make_response(result, status) - resp.headers = headers + resp.headers.update(headers) print_memory_usage() return resp else: - return 'Content-Type not supported' + return {"error": "Content-Type must be application/json"}, 415 @app.route('/memory', methods=['POST']) -@cross_origin() def print_memory(): + require_diagnostics_token() return {'memory': process.memory_info().rss / 1024 ** 2} @app.route("/snapshot") -@cross_origin() def snap(): global s + require_diagnostics_token() if not s: s = tracemalloc.take_snapshot() return "taken snapshot\n" @@ -589,7 +618,6 @@ def snap(): return "\n".join(lines) @app.route("/model/faceDetect") -@cross_origin() def face_detect(): # load and return the JSON File: mediapipe_tfjs_model_face_detection_full.json with open('mediapipe_tfjs_model_face_detection_full.json') as f: diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..5ca2de2 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pip-audit==2.10.1 +pytest==9.1.1 diff --git a/requirements.txt b/requirements.txt index c83bf01..0511001 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,12 @@ -click==8.1.7 +click==8.4.2 cycler==0.12.1 -Flask==3.1.0 -Flask-Cors==4.0.2 -fonttools==4.55.3 +Flask==3.1.3 +Flask-Cors==6.0.5 +fonttools==4.63.0 gunicorn==23.0.0 importlib-metadata==8.5.0 itsdangerous==2.2.0 -Jinja2==3.1.4 +Jinja2==3.1.6 json-tricks==3.17.3 kiwisolver==1.4.7 MarkupSafe==3.0.2 @@ -14,12 +14,12 @@ matplotlib==3.9.4 numpy==1.26.4 packaging==24.2 pandas==2.2.3 -Pillow==11.1.0 +Pillow==12.3.0 psutil==6.1.1 pyparsing==3.2.0 python-dateutil==2.9.0 pytz==2024.2 scipy==1.15.3 six==1.17.0 -Werkzeug==3.1.3 -zipp==3.21.0 \ No newline at end of file +Werkzeug==3.1.8 +zipp==3.21.0 diff --git a/test_app_security.py b/test_app_security.py new file mode 100644 index 0000000..c590bcd --- /dev/null +++ b/test_app_security.py @@ -0,0 +1,85 @@ +import importlib +import os + +import pytest + + +@pytest.fixture() +def client(monkeypatch): + monkeypatch.setenv("EASYEYES_ALLOWED_ORIGINS", "https://speaker.easyeyes.app") + monkeypatch.delenv("EASYEYES_DIAGNOSTICS_TOKEN", raising=False) + + import app as app_module + + app_module = importlib.reload(app_module) + app_module.app.config.update(TESTING=True) + return app_module.app.test_client() + + +def test_diagnostics_are_disabled_without_a_server_token(client): + memory_response = client.post("/memory") + snapshot_response = client.get("/snapshot") + + assert memory_response.status_code == 404 + assert snapshot_response.status_code == 404 + + +def test_diagnostics_require_the_configured_bearer_token(client, monkeypatch): + monkeypatch.setenv("EASYEYES_DIAGNOSTICS_TOKEN", "test-diagnostics-token") + + unauthorized_response = client.post("/memory") + authorized_response = client.post( + "/memory", + headers={"Authorization": "Bearer test-diagnostics-token"}, + ) + + assert unauthorized_response.status_code == 401 + assert authorized_response.status_code == 200 + assert "memory" in authorized_response.json + + +def test_cors_allows_configured_origin_but_not_arbitrary_origin(client): + allowed_response = client.options( + "/task/mls", + headers={ + "Origin": "https://speaker.easyeyes.app", + "Access-Control-Request-Method": "POST", + }, + ) + denied_response = client.options( + "/task/mls", + headers={ + "Origin": "https://attacker.example", + "Access-Control-Request-Method": "POST", + }, + ) + + assert ( + allowed_response.headers["Access-Control-Allow-Origin"] + == "https://speaker.easyeyes.app" + ) + assert "Access-Control-Allow-Origin" not in denied_response.headers + + +def test_request_body_limit_is_enforced(client): + client.application.config["MAX_CONTENT_LENGTH"] = 128 + + response = client.post( + "/task/mls", + data=b"{" + b'"payload":"' + (b"x" * 256) + b'"}', + content_type="application/json", + ) + + assert response.status_code == 413 + + +def test_unknown_tasks_and_unsupported_content_types_have_error_statuses(client): + unknown_task_response = client.post( + "/task/not-a-task", json={"payload": []} + ) + unsupported_type_response = client.post( + "/task/mls", data="payload", content_type="text/plain" + ) + + assert unknown_task_response.status_code == 404 + assert unsupported_type_response.status_code == 415 From 3166cf5a6d08442ca5ee4a2dcfc6713777d221ab Mon Sep 17 00:00:00 2001 From: khahani Date: Fri, 7 Aug 2026 11:47:18 +0400 Subject: [PATCH 2/3] fix: validate calibration task payloads --- README.md | 8 ++++++- app.py | 56 ++++++++++++++++++++++++++++++++++++++------ test_app_security.py | 18 ++++++++++++++ 3 files changed, 74 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 81110ab..71c2e13 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,13 @@ python -m pip_audit --local memory use. - `EASYEYES_DIAGNOSTICS_TOKEN`: bearer token for `/memory` and `/snapshot`. Those routes return `404` when no token is configured and `401` for an - incorrect token. +incorrect token. + +Task payloads must be JSON objects containing only finite numbers and ordinary +JSON values. Nesting and total value counts are bounded before scientific +handlers run. Missing or invalid task parameters return `400` instead of an +internal error. Task-specific numeric ranges still need to be documented and +enforced. Task routes remain an unauthenticated computation API because the active client contract does not provide an identity mechanism. Before deploying this diff --git a/app.py b/app.py index 25cc36e..e9c1d42 100644 --- a/app.py +++ b/app.py @@ -21,6 +21,9 @@ from utils import allHzPowerCheck, volumePowerCheck import math +MAX_JSON_DEPTH = 20 +MAX_JSON_VALUES = 2_000_000 + app = Flask(__name__) app.config["MAX_CONTENT_LENGTH"] = int( os.getenv("EASYEYES_MAX_CONTENT_LENGTH", str(32 * 1024 * 1024)) @@ -41,7 +44,7 @@ process = psutil.Process(os.getpid()) tracemalloc.start() -s = None +baseline_snapshot = None def require_diagnostics_token(): @@ -59,6 +62,37 @@ def require_diagnostics_token(): abort(401) +def is_valid_task_payload(payload): + if not isinstance(payload, dict): + return False + + pending = [(payload, 0)] + value_count = 0 + + while pending: + value, depth = pending.pop() + if depth > MAX_JSON_DEPTH: + return False + + if isinstance(value, dict): + if not all(isinstance(key, str) for key in value): + return False + value_count += len(value) + pending.extend((item, depth + 1) for item in value.values()) + elif isinstance(value, list): + value_count += len(value) + pending.extend((item, depth + 1) for item in value) + elif isinstance(value, float) and not math.isfinite(value): + return False + elif not isinstance(value, (str, int, float, bool, type(None))): + return False + + if value_count > MAX_JSON_VALUES: + return False + + return True + + def handle_autocorrelation_task(request_json, task): if "payload" not in request_json: return 400, "Request Body is missing a 'payload' entry" @@ -590,7 +624,13 @@ def task_handler(task): abort(404) if request.is_json: headers = {"Content-Type": "application/json"} - status, result = SUPPORTED_TASKS[task](request.get_json(cache=False), task) + request_json = request.get_json(cache=False) + if not is_valid_task_payload(request_json): + return {"error": "Invalid JSON task payload"}, 400 + try: + status, result = SUPPORTED_TASKS[task](request_json, task) + except (KeyError, TypeError, ValueError, OverflowError): + return {"error": "Invalid task parameters"}, 400 resp = make_response(result, status) resp.headers.update(headers) print_memory_usage() @@ -604,15 +644,17 @@ def print_memory(): return {'memory': process.memory_info().rss / 1024 ** 2} @app.route("/snapshot") -def snap(): - global s +def create_or_compare_snapshot(): + global baseline_snapshot require_diagnostics_token() - if not s: - s = tracemalloc.take_snapshot() + if not baseline_snapshot: + baseline_snapshot = tracemalloc.take_snapshot() return "taken snapshot\n" else: lines = [] - top_stats = tracemalloc.take_snapshot().compare_to(s, 'lineno') + top_stats = tracemalloc.take_snapshot().compare_to( + baseline_snapshot, 'lineno' + ) for stat in top_stats[:5]: lines.append(str(stat)) return "\n".join(lines) diff --git a/test_app_security.py b/test_app_security.py index c590bcd..3b5855c 100644 --- a/test_app_security.py +++ b/test_app_security.py @@ -83,3 +83,21 @@ def test_unknown_tasks_and_unsupported_content_types_have_error_statuses(client) assert unknown_task_response.status_code == 404 assert unsupported_type_response.status_code == 415 + + +@pytest.mark.parametrize( + "body", + [ + "[]", + '{"length": NaN, "amplitude": 1}', + ], +) +def test_task_payloads_require_an_object_with_finite_numbers(client, body): + response = client.post( + "/task/mls", + data=body, + content_type="application/json", + ) + + assert response.status_code == 400 + assert response.json == {"error": "Invalid JSON task payload"} From dd971d521e523594b98e5712be9733f7fe91fb55 Mon Sep 17 00:00:00 2001 From: khahani Date: Fri, 7 Aug 2026 21:14:08 +0400 Subject: [PATCH 3/3] fix: upgrade setuptools for security audit --- requirements-dev.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements-dev.txt b/requirements-dev.txt index 5ca2de2..5b86572 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,4 @@ -r requirements.txt pip-audit==2.10.1 pytest==9.1.1 +setuptools==83.0.0