Skip to content
Draft
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
23 changes: 23 additions & 0 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.11
1 change: 0 additions & 1 deletion Procfile
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
web: gunicorn --workers 12 app:app --preload --max-requests 1 --timeout 600
requirements.txt
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,48 @@
# 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 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
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.
108 changes: 89 additions & 19 deletions app.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import hmac
import tracemalloc
import gc
import psutil
Expand All @@ -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
Expand All @@ -20,11 +21,76 @@
from utils import allHzPowerCheck, volumePowerCheck
import math

MAX_JSON_DEPTH = 20
MAX_JSON_VALUES = 2_000_000

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()
baseline_snapshot = 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 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):
Expand Down Expand Up @@ -551,45 +617,49 @@ def print_memory_usage():
print("memory used:", round(process.memory_info().rss / 1024 ** 2), "mb")

@app.route("/task/<string: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
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 = 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
if not s:
s = tracemalloc.take_snapshot()
def create_or_compare_snapshot():
global baseline_snapshot
require_diagnostics_token()
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)

@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:
Expand Down
4 changes: 4 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-r requirements.txt
pip-audit==2.10.1
pytest==9.1.1
setuptools==83.0.0
16 changes: 8 additions & 8 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,25 +1,25 @@
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
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
Werkzeug==3.1.8
zipp==3.21.0
103 changes: 103 additions & 0 deletions test_app_security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
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


@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"}
Loading