[AZ-178] Fix Critical/High security findings: auth, CVEs, non-root containers, per-job SSE

- Pin all deps; h11==0.16.0 (CVE-2025-43859), python-multipart>=1.3.1 (CVE-2026-28356), PyJWT==2.12.1
- Add HMAC JWT verification (require_auth FastAPI dependency, JWT_SECRET-gated)
- Fix TokenManager._refresh() to use ADMIN_API_URL instead of ANNOTATIONS_URL
- Rename POST /detect → POST /detect/image (image-only, rejects video files)
- Replace global SSE stream with per-job SSE: GET /detect/{media_id} with event replay buffer
- Apply require_auth to all 4 protected endpoints
- Fix on_annotation/on_status closure to use mutable current_id for correct post-upload event routing
- Add non-root appuser to Dockerfile and Dockerfile.gpu
- Add JWT_SECRET to e2e/docker-compose.test.yml and run-tests.sh
- Update all e2e tests and unit tests for new endpoints and HMAC token signing
- 64/64 tests pass

Made-with: Cursor
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-04-02 06:32:12 +03:00
parent dac350cbc5
commit 097811a67b
25 changed files with 369 additions and 429 deletions
+10 -5
View File
@@ -1,5 +1,6 @@
import base64
import json
import os
import time
from unittest.mock import MagicMock, patch
@@ -8,6 +9,14 @@ from fastapi import HTTPException
def _access_jwt(sub: str = "u1") -> str:
secret = os.environ.get("JWT_SECRET", "")
if secret:
import jwt as pyjwt
return pyjwt.encode(
{"exp": int(time.time()) + 3600, "sub": sub},
secret,
algorithm="HS256",
)
raw = json.dumps(
{"exp": int(time.time()) + 3600, "sub": sub}, separators=(",", ":")
).encode()
@@ -19,11 +28,7 @@ def test_token_manager_decode_user_id_sub():
# Arrange
from main import TokenManager
raw = json.dumps(
{"sub": "user-abc", "exp": int(time.time()) + 3600}, separators=(",", ":")
).encode()
payload = base64.urlsafe_b64encode(raw).decode().rstrip("=")
token = f"hdr.{payload}.sig"
token = _access_jwt("user-abc")
# Act
uid = TokenManager.decode_user_id(token)
# Assert
+13 -166
View File
@@ -1,12 +1,11 @@
import base64
import json
import builtins
import os
import tempfile
import threading
import time
from unittest.mock import MagicMock, patch
import cv2
import jwt as pyjwt
import numpy as np
import pytest
from fastapi.testclient import TestClient
@@ -17,9 +16,15 @@ import inference as inference_mod
def _access_jwt(sub: str = "u1") -> str:
raw = json.dumps(
{"exp": int(time.time()) + 3600, "sub": sub}, separators=(",", ":")
).encode()
secret = os.environ.get("JWT_SECRET", "")
if secret:
return pyjwt.encode(
{"exp": int(time.time()) + 3600, "sub": sub},
secret,
algorithm="HS256",
)
import base64, json
raw = json.dumps({"exp": int(time.time()) + 3600, "sub": sub}, separators=(",", ":")).encode()
payload = base64.urlsafe_b64encode(raw).decode().rstrip("=")
return f"h.{payload}.s"
@@ -30,53 +35,7 @@ def _jpeg_bytes() -> bytes:
class _FakeInfVideo:
def run_detect_video(
self,
video_bytes,
ai_cfg,
media_name,
save_path,
on_annotation,
status_callback=None,
):
writer = inference_mod._write_video_bytes_to_path
ev = threading.Event()
t = threading.Thread(
target=writer,
args=(save_path, video_bytes, ev),
)
t.start()
ev.wait(timeout=60)
t.join(timeout=60)
def run_detect_image(self, *args, **kwargs):
pass
class _FakeInfVideoConcurrent:
def run_detect_video(
self,
video_bytes,
ai_cfg,
media_name,
save_path,
on_annotation,
status_callback=None,
):
holder = {}
writer = inference_mod._write_video_bytes_to_path
def write():
holder["tid"] = threading.get_ident()
writer(save_path, video_bytes, threading.Event())
t = threading.Thread(target=write)
t.start()
holder["caller_tid"] = threading.get_ident()
t.join(timeout=60)
assert holder["tid"] != holder["caller_tid"]
def run_detect_image(self, *args, **kwargs):
def run_detect_image(self, image_bytes, ai_cfg, media_name, on_annotation, *args, **kwargs):
pass
@@ -89,92 +48,8 @@ def reset_main_inference():
main.inference = None
def test_auth_video_storage_path_opened_wb_once(reset_main_inference):
# Arrange
import main
from media_hash import compute_media_content_hash
write_paths = []
real_write = inference_mod._write_video_bytes_to_path
def tracking_write(path, data, ev):
write_paths.append(os.path.abspath(str(path)))
return real_write(path, data, ev)
video_body = b"vid-bytes-" * 20
token = _access_jwt()
mock_post = MagicMock()
mock_post.return_value.status_code = 201
mock_put = MagicMock()
mock_put.return_value.status_code = 204
with tempfile.TemporaryDirectory() as vd:
os.environ["VIDEOS_DIR"] = vd
content_hash = compute_media_content_hash(video_body)
expected_path = os.path.abspath(os.path.join(vd, f"{content_hash}.mp4"))
client = TestClient(main.app)
with (
patch.object(inference_mod, "_write_video_bytes_to_path", tracking_write),
patch.object(main.http_requests, "post", mock_post),
patch.object(main.http_requests, "put", mock_put),
patch.object(main, "get_inference", return_value=_FakeInfVideo()),
):
# Act
r = client.post(
"/detect",
files={"file": ("clip.mp4", video_body, "video/mp4")},
headers={"Authorization": f"Bearer {token}"},
)
# Assert
assert r.status_code == 200
assert write_paths.count(expected_path) == 1
with open(expected_path, "rb") as f:
assert f.read() == video_body
assert mock_post.called
assert mock_put.call_count >= 2
def test_non_auth_temp_video_opened_wb_once_and_removed(reset_main_inference):
# Arrange
import main
write_paths = []
real_write = inference_mod._write_video_bytes_to_path
def tracking_write(path, data, ev):
write_paths.append(os.path.abspath(str(path)))
return real_write(path, data, ev)
video_body = b"tmp-vid-" * 30
client = TestClient(main.app)
tmp_path_holder = []
class _CaptureTmp(_FakeInfVideo):
def run_detect_video(self, video_bytes, ai_cfg, media_name, save_path, on_annotation, status_callback=None):
tmp_path_holder.append(os.path.abspath(str(save_path)))
super().run_detect_video(
video_bytes, ai_cfg, media_name, save_path, on_annotation, status_callback
)
with (
patch.object(inference_mod, "_write_video_bytes_to_path", tracking_write),
patch.object(main, "get_inference", return_value=_CaptureTmp()),
):
# Act
r = client.post(
"/detect",
files={"file": ("n.mp4", video_body, "video/mp4")},
)
# Assert
assert r.status_code == 200
assert len(tmp_path_holder) == 1
tmp_path = tmp_path_holder[0]
assert write_paths.count(tmp_path) == 1
assert not os.path.isfile(tmp_path)
def test_auth_image_still_writes_once_before_detect(reset_main_inference):
# Arrange
import builtins
import main
from media_hash import compute_media_content_hash
@@ -205,7 +80,7 @@ def test_auth_image_still_writes_once_before_detect(reset_main_inference):
):
# Act
r = client.post(
"/detect",
"/detect/image",
files={"file": ("p.jpg", img, "image/jpeg")},
headers={"Authorization": f"Bearer {token}"},
)
@@ -214,31 +89,3 @@ def test_auth_image_still_writes_once_before_detect(reset_main_inference):
assert wb_hits.count(expected_path) == 1
with real_open(expected_path, "rb") as f:
assert f.read() == img
def test_video_writer_runs_in_separate_thread_from_executor(reset_main_inference):
# Arrange
import main
token = _access_jwt()
mock_post = MagicMock()
mock_post.return_value.status_code = 201
mock_put = MagicMock()
mock_put.return_value.status_code = 204
video_body = b"thr-test-" * 15
with tempfile.TemporaryDirectory() as vd:
os.environ["VIDEOS_DIR"] = vd
client = TestClient(main.app)
with (
patch.object(main.http_requests, "post", mock_post),
patch.object(main.http_requests, "put", mock_put),
patch.object(main, "get_inference", return_value=_FakeInfVideoConcurrent()),
):
# Act
r = client.post(
"/detect",
files={"file": ("c.mp4", video_body, "video/mp4")},
headers={"Authorization": f"Bearer {token}"},
)
# Assert
assert r.status_code == 200
+24 -8
View File
@@ -276,6 +276,14 @@ class TestMediaContentHashFromFile:
def _access_jwt(sub: str = "u1") -> str:
import jwt as pyjwt
secret = os.environ.get("JWT_SECRET", "")
if secret:
return pyjwt.encode(
{"exp": int(time.time()) + 3600, "sub": sub},
secret,
algorithm="HS256",
)
raw = json.dumps(
{"exp": int(time.time()) + 3600, "sub": sub}, separators=(",", ":")
).encode()
@@ -361,7 +369,10 @@ class TestDetectVideoEndpoint:
os.environ["VIDEOS_DIR"] = vd
from fastapi.testclient import TestClient
client = TestClient(main.app)
with patch.object(main, "get_inference", return_value=_FakeInfStream()):
with (
patch.object(main, "JWT_SECRET", ""),
patch.object(main, "get_inference", return_value=_FakeInfStream()),
):
# Act
r = client.post(
"/detect/video",
@@ -379,12 +390,13 @@ class TestDetectVideoEndpoint:
from fastapi.testclient import TestClient
client = TestClient(main.app)
# Act
r = client.post(
"/detect/video",
content=b"data",
headers={"X-Filename": "photo.jpg"},
)
# Act — patch JWT_SECRET to "" so auth does not block the extension check
with patch.object(main, "JWT_SECRET", ""):
r = client.post(
"/detect/video",
content=b"data",
headers={"X-Filename": "photo.jpg"},
)
# Assert
assert r.status_code == 400
@@ -411,12 +423,16 @@ class TestDetectVideoEndpoint:
os.environ["VIDEOS_DIR"] = vd
from fastapi.testclient import TestClient
client = TestClient(main.app)
token = _access_jwt()
with patch.object(main, "get_inference", return_value=_CaptureInf()):
# Act
r = client.post(
"/detect/video",
content=video_body,
headers={"X-Filename": "v.mp4"},
headers={
"X-Filename": "v.mp4",
"Authorization": f"Bearer {token}",
},
)
# Assert