FIX: 11 bugs bảo mật/ổn định (static mount chặn dotfile, delete traversal, cleanup giữ clips serverFileId, upload whitelist, password strength, auth audio endpoints, pedalboard==0.9.19, vendor CDN local) + FEATURE: Carla bridge preview/export MIDI notes âm VSTi (POST /midi-render, /carla-play-notes, nút Preview VSTi/Export MIDI->Audio; pedalboard 0.9.19 raw MIDI bytes; SONICFORGE_STORAGE_DIR cô lập test storage)

This commit is contained in:
2026-08-10 18:46:54 +07:00
parent 67ad2b8e4b
commit 61cb4b846f
33 changed files with 1097 additions and 103 deletions
+6
View File
@@ -12,3 +12,9 @@ os.environ.setdefault(
"SONICFORGE_DB_PATH",
os.path.join(tempfile.gettempdir(), "sonicforge_test.db"),
)
# Cô lập storage khỏi app production (đang chạy root, ghi sf_scan_state.json
# vào storage thật → pytest gặp PermissionError khi file root-owned).
os.environ.setdefault(
"SONICFORGE_STORAGE_DIR",
os.path.join(tempfile.gettempdir(), "sonicforge_test_storage"),
)
+70 -3
View File
@@ -11,8 +11,11 @@ client = TestClient(app)
def get_admin_token():
resp = client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin123"})
if resp.status_code == 200:
# test_auth_and_quota.py có thể đã rotate password; thử cả 2.
for pwd in ("admin123", "admin_new_password_2026"):
resp = client.post("/api/v1/auth/login", json={"username": "admin", "password": pwd})
if resp.status_code != 200:
continue
token = resp.json()["access_token"]
# Admin is seeded with must_change_password=1; the app blocks music
# processing until the first password change. Complete that flow here
@@ -20,7 +23,7 @@ def get_admin_token():
user = resp.json()["user"]
if user.get("must_change_password"):
r = client.post("/api/v1/auth/change-password", headers={"Authorization": f"Bearer {token}"},
json={"old_password": "admin123", "new_password": "admin123"})
json={"old_password": pwd, "new_password": pwd})
if r.status_code == 200:
token = r.json()["access_token"]
return token
@@ -145,3 +148,67 @@ class TestPluginAPI:
client.post("/api/v1/plugins/dirs", headers=h, json={"plugin_dirs": [d1]})
g = client.get("/api/v1/plugins/dirs", headers=h)
assert g.json()["plugin_dirs"] == [d1]
class TestMidiRenderVSTi:
"""Preview/export MIDI notes với âm VSTi — feature Carla bridge → pedalboard."""
def test_midi_render_requires_auth(self):
client.cookies.clear() # TestClient giữ cookie login từ test trước
resp = client.post("/api/v1/plugins/midi-render",
json={"instrument_id": "x", "notes": [{"pitch": 60}]})
assert resp.status_code == 401
def test_midi_render_no_notes(self):
token = get_admin_token()
if not token:
pytest.skip("Cannot get admin token")
resp = client.post("/api/v1/plugins/midi-render", headers={"Authorization": f"Bearer {token}"},
json={"instrument_id": "x", "notes": []})
assert resp.status_code == 400
def test_midi_render_unknown_instrument(self):
token = get_admin_token()
if not token:
pytest.skip("Cannot get admin token")
resp = client.post("/api/v1/plugins/midi-render", headers={"Authorization": f"Bearer {token}"},
json={"instrument_id": "NoSuchPluginXYZ",
"notes": [{"pitch": 60, "start_beat": 0, "duration_beats": 1, "velocity": 0.8}]})
assert resp.status_code == 404
assert "VSTi" in resp.json()["detail"]
def test_midi_render_requires_pedalboard(self):
# Nếu pedalboard thiếu → 501 (không crash)
import app.api.v1.plugins as plugins_mod
token = get_admin_token()
if not token:
pytest.skip("Cannot get admin token")
if not plugins_mod.HAS_PEDALBOARD:
resp = client.post("/api/v1/plugins/midi-render", headers={"Authorization": f"Bearer {token}"},
json={"instrument_id": "x",
"notes": [{"pitch": 60, "start_beat": 0, "duration_beats": 1}]})
assert resp.status_code == 501
else:
pytest.skip("pedalboard present — render path covered by unknown-instrument test")
def test_carla_play_notes_requires_auth(self):
client.cookies.clear() # TestClient giữ cookie login từ test trước
resp = client.post("/api/v1/plugins/carla-play-notes",
json={"notes": [{"pitch": 60}], "bpm": 120})
assert resp.status_code == 401
def test_carla_play_notes_no_notes(self):
token = get_admin_token()
if not token:
pytest.skip("Cannot get admin token")
resp = client.post("/api/v1/plugins/carla-play-notes", headers={"Authorization": f"Bearer {token}"},
json={"notes": [], "bpm": 120})
assert resp.status_code == 400
def test_carla_play_notes_no_carla(self):
# Máy test không có Carla local → 409 hướng dẫn định vị/mở Carla.
token = get_admin_token()
if not token:
pytest.skip("Cannot get admin token")
resp = client.post("/api/v1/plugins/carla-play-notes", headers={"Authorization": f"Bearer {token}"},
json={"notes": [{"pitch": 60, "start_beat": 0, "duration_beats": 1}], "bpm": 120})
assert resp.status_code == 409
+126 -3
View File
@@ -10,7 +10,9 @@ Covers the vulnerabilities found during the 2026-08 audit:
"""
import os
import json
import time
import httpx
import numpy as np
import pytest
import soundfile as sf
@@ -68,9 +70,32 @@ class TestAIProxySSRF:
def test_proxy_allows_configured_localhost_provider(self):
# localhost:11434 is in the default AI provider list; it must pass the
# SSRF check (and then fail to connect in this environment -> 502).
resp = client.post("/api/v1/ai/proxy", headers=auth_headers(),
json={"url": "http://localhost:11434/v1/chat/completions", "body": {}})
# SSRF check (and then fail to connect -> 502). Bug #5: máy có Ollama
# chạy ở localhost:11434 trả 200 → test fail giả. Mock connection fail
# để test deterministic, không phụ thuộc service ngoài.
import app.api.v1.ai_proxy as ai_proxy
class _FakeConnectError(httpx.ConnectError):
def __init__(self, *a, **k):
super().__init__("mock connection refused", request=httpx.Request("POST", "http://localhost:11434/"))
class _FakeAsyncClient(httpx.AsyncClient):
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def post(self, *a, **k):
raise _FakeConnectError()
monkeypatch = pytest.MonkeyPatch()
monkeypatch.setattr(ai_proxy.httpx, "AsyncClient", _FakeAsyncClient)
try:
resp = client.post("/api/v1/ai/proxy", headers=auth_headers(),
json={"url": "http://localhost:11434/v1/chat/completions", "body": {}})
finally:
monkeypatch.undo()
assert resp.status_code == 502
@@ -232,3 +257,101 @@ class TestRenderResample:
os.remove(src_path)
except OSError:
pass
# ── 7. Bug fixes 2026-08-10 audit round 2 ──
class TestBugFixes:
def test_static_mount_blocks_dotfiles(self):
# Bug #1: /static/audio/.secret_key, sonicforge.db, temp/autosave.json
# từng serve 200 không cần auth — giờ phải 404.
assert client.get("/static/audio/.secret_key").status_code == 404
assert client.get("/static/audio/sonicforge.db").status_code == 404
assert client.get("/static/audio/temp/autosave.json").status_code == 404
assert client.get("/static/audio/../.secret_key").status_code in (404, 400)
def test_static_uploads_still_served(self):
# File audio hợp lệ trong uploads vẫn phải serve được (không vỡ UI).
fid = f"user_test_{os.urandom(4).hex()}.wav"
p = os.path.join(settings.UPLOADS_DIR, fid)
with open(p, "wb") as f:
f.write(b"RIFFxxxxWAVE")
try:
assert client.get(f"/static/audio/uploads/{fid}").status_code == 200
finally:
os.remove(p)
def test_delete_user_file_blocks_traversal(self):
# Bug #2: `user_<id>_../../x` không được xóa file ngoài storage.
token = get_admin_token()
if not token:
pytest.skip("Cannot get admin token")
prof = client.get("/api/v1/auth/profile", headers={"Authorization": f"Bearer {token}"})
uid = prof.json()["id"]
marker = os.path.join(settings.STORAGE_DIR, "pwn_marker_%s.txt" % os.urandom(4).hex())
with open(marker, "w") as f:
f.write("x")
try:
resp = client.delete(f"/api/v1/audio/my-files/user_{uid}_../../{os.path.basename(marker)}",
headers={"Authorization": f"Bearer {token}"})
assert resp.status_code in (403, 404)
assert os.path.exists(marker), "traversal delete phải bị chặn"
finally:
if os.path.exists(marker):
os.remove(marker)
def test_upload_rejects_non_audio_extension(self):
# Bug #4: upload .exe bị từ chối.
resp = client.post("/api/v1/audio/upload",
files={"file": ("evil.exe", b"MZ\x90\x00", "application/octet-stream")})
assert resp.status_code == 400
def test_change_password_rejects_weak(self):
# Bug #7: change-password áp cùng policy độ mạnh như register.
token = get_admin_token()
if not token:
pytest.skip("Cannot get admin token")
resp = client.post("/api/v1/auth/change-password",
headers={"Authorization": f"Bearer {token}"},
json={"old_password": "admin123", "new_password": "a"})
assert resp.status_code == 400
def test_cleanup_keeps_referenced_files(self):
# Bug #3: cleanup_expired_files_task không xóa file được project tham chiếu.
from app.tasks.worker import cleanup_expired_files_task
from app.models.user import get_db_connection
token = get_admin_token()
if not token:
pytest.skip("Cannot get admin token")
prof = client.get("/api/v1/auth/profile", headers={"Authorization": f"Bearer {token}"})
uid = prof.json()["id"]
ref_id = f"user_{uid}_ref_{os.urandom(4).hex()}.wav"
stray_id = f"user_{uid}_stray_{os.urandom(4).hex()}.wav"
ref_path = os.path.join(settings.PROCESSED_DIR, ref_id)
stray_path = os.path.join(settings.PROCESSED_DIR, stray_id)
old = time.time() - 999999
for p in (ref_path, stray_path):
with open(p, "wb") as f:
f.write(b"RIFFxxxxWAVE")
os.utime(p, (old, old))
pid = f"cleanup_test_{os.urandom(4).hex()}"
conn = get_db_connection()
cursor = conn.cursor()
proj = {"tracks": [{"serverFileId": ref_id}]}
cursor.execute("INSERT INTO projects (id, user_id, name, data_json, is_temp, size_bytes, updated_at) VALUES (?,?,?,?,0,?,?)",
(pid, uid, "cleanup test", json.dumps(proj), 0, old))
conn.commit()
conn.close()
try:
result = cleanup_expired_files_task(max_age_hours=0)
assert os.path.exists(ref_path), "file được project tham chiếu phải giữ"
assert not os.path.exists(stray_path), "file không tham chiếu phải bị dọn"
assert result["referenced_files_kept"] >= 1
finally:
for p in (ref_path, stray_path):
if os.path.exists(p):
os.remove(p)
conn = get_db_connection()
cur = conn.cursor()
cur.execute("DELETE FROM projects WHERE id = ?", (pid,))
conn.commit()
conn.close()
+3 -8
View File
@@ -21,14 +21,9 @@ class TestPluginManager:
sr = 44100
msgs = PluginManager.midi_events_to_messages(events, bpm, sr)
beat_sec = 60.0 / 120
expected_note_on_offset = 0
expected_note_off_offset = int(beat_sec * sr)
# Check note_on message
assert msgs[0].sample_offset == expected_note_on_offset
assert msgs[0].note == 60
# Check note_off message
assert msgs[1].sample_offset == expected_note_off_offset
assert msgs[1].note == 60
# pedalboard >= 0.9: message = (bytes raw MIDI, timestamp_seconds)
assert msgs[0] == (bytes([0x90, 60, 100]), 0.0)
assert msgs[1] == (bytes([0x80, 60, 0]), beat_sec)
def test_list_available_empty(self):
pm = PluginManager(vst_dir="/tmp/nonexistent_vst_dir_xyz", sf_dir="/tmp/nonexistent_sf_dir_xyz")