Fix path traversal, temp file leak, cross-browser speech, and add tests

This commit is contained in:
Gan, Jimmy
2026-02-12 15:54:37 +08:00
parent 86caddbeb4
commit 1b74e0d9a1
3 changed files with 108 additions and 14 deletions
+9 -3
View File
@@ -6,7 +6,8 @@ env.allowLocalModels = false;
let progressCallback = (progress) => { let progressCallback = (progress) => {
const status = document.getElementById('status'); const status = document.getElementById('status');
if (progress.status === 'progress') { 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') { } else if (progress.status === 'done') {
status.textContent = 'Model loaded'; status.textContent = 'Model loaded';
} }
@@ -27,7 +28,7 @@ let recognition;
let transcriber = null; let transcriber = null;
let currentModel = null; let currentModel = null;
const getModelForLanguage = () => 'Xenova/whisper-small'; const getModelForLanguage = (_lang) => 'Xenova/whisper-small';
fileInput.addEventListener('change', () => { fileInput.addEventListener('change', () => {
uploadBtn.disabled = !fileInput.files.length; uploadBtn.disabled = !fileInput.files.length;
@@ -94,7 +95,12 @@ recordBtn.addEventListener('click', async () => {
try { try {
await navigator.mediaDevices.getUserMedia({ audio: true }); 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.continuous = true;
recognition.interimResults = true; recognition.interimResults = true;
recognition.lang = languageSelect.value; recognition.lang = languageSelect.value;
+11 -11
View File
@@ -5,7 +5,7 @@ import whisper
import os import os
import tempfile import tempfile
app = Flask(__name__, static_folder='.', static_url_path='') app = Flask(__name__, static_folder=None)
CORS(app) CORS(app)
app.config['MAX_CONTENT_LENGTH'] = 50 * 1024 * 1024 # 50MB max (audio only) 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") model = whisper.load_model("base")
print("Model loaded!") print("Model loaded!")
ALLOWED_STATIC_FILES = {'index.html', 'app.js'}
@app.route('/') @app.route('/')
def index(): def index():
response = send_from_directory('.', 'index.html') return send_from_directory('.', 'index.html')
response.headers['Cross-Origin-Opener-Policy'] = 'same-origin'
response.headers['Cross-Origin-Embedder-Policy'] = 'require-corp'
return response
@app.route('/<path:filename>') @app.route('/<path:filename>')
def static_files(filename): def static_files(filename):
response = send_from_directory('.', filename) if filename not in ALLOWED_STATIC_FILES:
response.headers['Cross-Origin-Opener-Policy'] = 'same-origin' return jsonify({'error': 'Not found'}), 404
response.headers['Cross-Origin-Embedder-Policy'] = 'require-corp' return send_from_directory('.', filename)
return response
@app.route('/transcribe', methods=['POST']) @app.route('/transcribe', methods=['POST'])
def transcribe(): def transcribe():
@@ -58,8 +56,10 @@ def transcribe():
filepath = tmp.name filepath = tmp.name
file.save(filepath) file.save(filepath)
result = model.transcribe(filepath, language=language) try:
os.remove(filepath) result = model.transcribe(filepath, language=language)
finally:
os.remove(filepath)
return jsonify({'text': result['text']}) return jsonify({'text': result['text']})
except Exception as e: except Exception as e:
+88
View File
@@ -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']