From 1b74e0d9a101e69ad6c7ccd7366d753b43bc73f3 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Thu, 12 Feb 2026 15:54:37 +0800 Subject: [PATCH] Fix path traversal, temp file leak, cross-browser speech, and add tests --- app.js | 12 +++++-- server.py | 22 ++++++------- test_server.py | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 14 deletions(-) create mode 100644 test_server.py diff --git a/app.js b/app.js index b75d3d0..2dfac06 100644 --- a/app.js +++ b/app.js @@ -6,7 +6,8 @@ env.allowLocalModels = false; let progressCallback = (progress) => { const status = document.getElementById('status'); if (progress.status === 'progress') { - status.textContent = `Downloading model... ${progress.file}`; + const pct = progress.progress != null ? ` ${Math.round(progress.progress)}%` : ''; + status.textContent = `Downloading model... ${progress.file}${pct}`; } else if (progress.status === 'done') { status.textContent = 'Model loaded'; } @@ -27,7 +28,7 @@ let recognition; let transcriber = null; let currentModel = null; -const getModelForLanguage = () => 'Xenova/whisper-small'; +const getModelForLanguage = (_lang) => 'Xenova/whisper-small'; fileInput.addEventListener('change', () => { uploadBtn.disabled = !fileInput.files.length; @@ -94,7 +95,12 @@ recordBtn.addEventListener('click', async () => { try { await navigator.mediaDevices.getUserMedia({ audio: true }); - recognition = new webkitSpeechRecognition(); + const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; + if (!SpeechRecognition) { + status.textContent = 'Speech recognition not supported in this browser'; + return; + } + recognition = new SpeechRecognition(); recognition.continuous = true; recognition.interimResults = true; recognition.lang = languageSelect.value; diff --git a/server.py b/server.py index c875859..263428e 100644 --- a/server.py +++ b/server.py @@ -5,7 +5,7 @@ import whisper import os import tempfile -app = Flask(__name__, static_folder='.', static_url_path='') +app = Flask(__name__, static_folder=None) CORS(app) app.config['MAX_CONTENT_LENGTH'] = 50 * 1024 * 1024 # 50MB max (audio only) @@ -19,19 +19,17 @@ print("Loading Whisper model...") model = whisper.load_model("base") print("Model loaded!") +ALLOWED_STATIC_FILES = {'index.html', 'app.js'} + @app.route('/') def index(): - response = send_from_directory('.', 'index.html') - response.headers['Cross-Origin-Opener-Policy'] = 'same-origin' - response.headers['Cross-Origin-Embedder-Policy'] = 'require-corp' - return response + return send_from_directory('.', 'index.html') @app.route('/') def static_files(filename): - response = send_from_directory('.', filename) - response.headers['Cross-Origin-Opener-Policy'] = 'same-origin' - response.headers['Cross-Origin-Embedder-Policy'] = 'require-corp' - return response + if filename not in ALLOWED_STATIC_FILES: + return jsonify({'error': 'Not found'}), 404 + return send_from_directory('.', filename) @app.route('/transcribe', methods=['POST']) def transcribe(): @@ -58,8 +56,10 @@ def transcribe(): filepath = tmp.name file.save(filepath) - result = model.transcribe(filepath, language=language) - os.remove(filepath) + try: + result = model.transcribe(filepath, language=language) + finally: + os.remove(filepath) return jsonify({'text': result['text']}) except Exception as e: diff --git a/test_server.py b/test_server.py new file mode 100644 index 0000000..bc5408c --- /dev/null +++ b/test_server.py @@ -0,0 +1,88 @@ +import io +import sys +from unittest.mock import MagicMock, patch + +import pytest + +# Mock whisper before importing server +mock_whisper = MagicMock() +mock_whisper.load_model.return_value = MagicMock() +sys.modules['whisper'] = mock_whisper + +from server import app + +@pytest.fixture +def client(): + app.config['TESTING'] = True + with app.test_client() as c: + yield c + +# --- Static file serving --- + +def test_index(client): + resp = client.get('/') + assert resp.status_code == 200 + +def test_allowed_static_file(client): + resp = client.get('/app.js') + assert resp.status_code == 200 + +def test_blocked_static_file(client): + resp = client.get('/server.py') + assert resp.status_code == 404 + +def test_path_traversal_blocked(client): + resp = client.get('/../requirements.txt') + assert resp.status_code == 404 + +# --- COOP/COEP headers --- + +def test_coop_coep_headers(client): + resp = client.get('/') + assert resp.headers['Cross-Origin-Opener-Policy'] == 'same-origin' + assert resp.headers['Cross-Origin-Embedder-Policy'] == 'require-corp' + +# --- /transcribe endpoint --- + +def test_transcribe_no_file(client): + resp = client.post('/transcribe') + assert resp.status_code == 400 + assert 'No file provided' in resp.get_json()['error'] + +def test_transcribe_empty_filename(client): + data = {'file': (io.BytesIO(b'data'), '')} + resp = client.post('/transcribe', data=data, content_type='multipart/form-data') + assert resp.status_code == 400 + assert 'No file selected' in resp.get_json()['error'] + +def test_transcribe_video_rejected(client): + for ext in ['.mp4', '.avi', '.mov', '.mkv', '.flv', '.wmv', '.webm']: + data = {'file': (io.BytesIO(b'data'), f'video{ext}')} + resp = client.post('/transcribe', data=data, content_type='multipart/form-data') + assert resp.status_code == 400 + assert 'Video files not allowed' in resp.get_json()['error'] + +@patch('server.model') +def test_transcribe_success(mock_model, client): + mock_model.transcribe.return_value = {'text': 'hello world'} + data = {'file': (io.BytesIO(b'audio data'), 'test.mp3')} + resp = client.post('/transcribe', data=data, content_type='multipart/form-data') + assert resp.status_code == 200 + assert resp.get_json()['text'] == 'hello world' + +@patch('server.model') +def test_transcribe_with_language(mock_model, client): + mock_model.transcribe.return_value = {'text': 'hola'} + data = {'file': (io.BytesIO(b'audio'), 'test.mp3'), 'language': 'es-ES'} + resp = client.post('/transcribe', data=data, content_type='multipart/form-data') + assert resp.status_code == 200 + mock_model.transcribe.assert_called_once() + assert mock_model.transcribe.call_args[1]['language'] == 'es' + +@patch('server.model') +def test_transcribe_cleans_up_temp_file_on_error(mock_model, client): + mock_model.transcribe.side_effect = RuntimeError('model failed') + data = {'file': (io.BytesIO(b'audio'), 'test.mp3')} + resp = client.post('/transcribe', data=data, content_type='multipart/form-data') + assert resp.status_code == 500 + assert 'model failed' in resp.get_json()['error']