Cap buffered audio and keep transcripts out of INFO logs
Wyoming has no authentication, so buffered audio is attacker-controlled. self.audio grew until AudioStop with no bound: measured at ~11 MB/s over loopback, one connection exhausts 32 GB in under an hour, and a stuck satellite that never sends AudioStop does the same by accident. Cap it at --max-audio-seconds (default 120), dropping the excess with a single warning while still transcribing what was captured. Verified: a client streaming 10.8 GB now moves server RSS by 213 MB rather than 10.8 GB. Transcripts were logged at INFO. Log files are long-lived and world-readable under /tmp on macOS, so every voice command sat in plaintext readable by any local account. INFO now records duration, latency and character count; the text moved behind --debug. Both are covered by mutation-checked tests, and the README gains a Security section covering the unauthenticated trust boundary, the 0.0.0.0 bind that also exposes VPN interfaces, and running the daemon as a non-admin user. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+88
-4
@@ -1,4 +1,5 @@
|
||||
"""Tests for the Wyoming event handling."""
|
||||
from argparse import Namespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
@@ -7,7 +8,12 @@ from wyoming.audio import AudioChunk, AudioStop
|
||||
from wyoming.info import AsrModel, AsrProgram, Attribution, Describe, Info
|
||||
|
||||
from wyoming_parakeet.engine import SAMPLE_RATE
|
||||
from wyoming_parakeet.handler import ParakeetEventHandler
|
||||
from wyoming_parakeet.handler import (
|
||||
DEFAULT_MAX_AUDIO_SECONDS,
|
||||
ParakeetEventHandler,
|
||||
)
|
||||
|
||||
ARGS = Namespace(max_audio_seconds=DEFAULT_MAX_AUDIO_SECONDS)
|
||||
|
||||
INFO = Info(
|
||||
asr=[
|
||||
@@ -36,7 +42,7 @@ INFO = Info(
|
||||
def handler():
|
||||
engine = MagicMock()
|
||||
engine.transcribe = AsyncMock(return_value="turn off the kitchen lights")
|
||||
h = ParakeetEventHandler(INFO, MagicMock(), engine, MagicMock(), MagicMock())
|
||||
h = ParakeetEventHandler(INFO, ARGS, engine, MagicMock(), MagicMock())
|
||||
h.write_event = AsyncMock()
|
||||
return h
|
||||
|
||||
@@ -172,8 +178,8 @@ async def test_concurrent_handlers_do_not_share_audio():
|
||||
seen = []
|
||||
engine.transcribe.side_effect = lambda pcm: seen.append(pcm) or ""
|
||||
|
||||
a = ParakeetEventHandler(INFO, MagicMock(), engine, MagicMock(), MagicMock())
|
||||
b = ParakeetEventHandler(INFO, MagicMock(), engine, MagicMock(), MagicMock())
|
||||
a = ParakeetEventHandler(INFO, ARGS, engine, MagicMock(), MagicMock())
|
||||
b = ParakeetEventHandler(INFO, ARGS, engine, MagicMock(), MagicMock())
|
||||
a.write_event = AsyncMock()
|
||||
b.write_event = AsyncMock()
|
||||
|
||||
@@ -224,3 +230,81 @@ async def test_8bit_input_is_widened(handler):
|
||||
await handler.handle_event(AudioStop().event())
|
||||
|
||||
assert len(handler.engine.transcribe.await_args[0][0]) == SAMPLE_RATE * 2
|
||||
|
||||
|
||||
# --- resource limits --------------------------------------------------------
|
||||
# Wyoming is unauthenticated, so buffered audio is attacker-controlled.
|
||||
|
||||
|
||||
def capped_handler(seconds):
|
||||
engine = MagicMock()
|
||||
engine.transcribe = AsyncMock(return_value="ok")
|
||||
h = ParakeetEventHandler(
|
||||
INFO, Namespace(max_audio_seconds=seconds), engine, MagicMock(), MagicMock()
|
||||
)
|
||||
h.write_event = AsyncMock()
|
||||
return h
|
||||
|
||||
|
||||
async def test_audio_buffer_is_capped():
|
||||
handler = capped_handler(1.0)
|
||||
for _ in range(10):
|
||||
await handler.handle_event(chunk(SAMPLE_RATE)) # 10s into a 1s cap
|
||||
assert len(handler.audio) == SAMPLE_RATE * 2
|
||||
|
||||
|
||||
async def test_truncated_audio_is_still_transcribed():
|
||||
"""Cap the memory, don't drop the user's command on the floor."""
|
||||
handler = capped_handler(1.0)
|
||||
for _ in range(5):
|
||||
await handler.handle_event(chunk(SAMPLE_RATE))
|
||||
await handler.handle_event(AudioStop().event())
|
||||
|
||||
handler.engine.transcribe.assert_awaited_once()
|
||||
assert len(handler.engine.transcribe.await_args[0][0]) == SAMPLE_RATE * 2
|
||||
assert Transcript.from_event(handler.write_event.await_args[0][0]).text == "ok"
|
||||
|
||||
|
||||
async def test_truncation_warns_once(caplog):
|
||||
handler = capped_handler(1.0)
|
||||
with caplog.at_level("WARNING"):
|
||||
for _ in range(6):
|
||||
await handler.handle_event(chunk(SAMPLE_RATE))
|
||||
assert caplog.text.count("Audio exceeded") == 1
|
||||
|
||||
|
||||
async def test_cap_resets_between_utterances():
|
||||
handler = capped_handler(1.0)
|
||||
for _ in range(3):
|
||||
await handler.handle_event(chunk(SAMPLE_RATE))
|
||||
await handler.handle_event(AudioStop().event())
|
||||
assert handler.truncated is False
|
||||
|
||||
await handler.handle_event(chunk(SAMPLE_RATE // 2))
|
||||
assert len(handler.audio) == SAMPLE_RATE # accepted again, not still capped
|
||||
|
||||
|
||||
async def test_audio_under_the_cap_is_untouched():
|
||||
handler = capped_handler(DEFAULT_MAX_AUDIO_SECONDS)
|
||||
await handler.handle_event(chunk(SAMPLE_RATE * 3))
|
||||
assert len(handler.audio) == SAMPLE_RATE * 3 * 2
|
||||
|
||||
|
||||
# --- transcript privacy -----------------------------------------------------
|
||||
# Logs are long-lived and readable by other local accounts.
|
||||
|
||||
|
||||
async def test_transcript_text_is_not_logged_at_info(handler, caplog):
|
||||
handler.engine.transcribe = AsyncMock(return_value="unlock the front door")
|
||||
with caplog.at_level("INFO"):
|
||||
await handler.handle_event(chunk(SAMPLE_RATE))
|
||||
await handler.handle_event(AudioStop().event())
|
||||
assert "unlock the front door" not in caplog.text
|
||||
|
||||
|
||||
async def test_transcript_text_is_available_at_debug(handler, caplog):
|
||||
handler.engine.transcribe = AsyncMock(return_value="unlock the front door")
|
||||
with caplog.at_level("DEBUG"):
|
||||
await handler.handle_event(chunk(SAMPLE_RATE))
|
||||
await handler.handle_event(AudioStop().event())
|
||||
assert "unlock the front door" in caplog.text
|
||||
|
||||
Reference in New Issue
Block a user