mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-15 21:00:42 -07:00
audio_path mode took any absolute filesystem path and returned its decoded contents as transcribed text with no caller verification beyond the existence/size checks. The X-Voicebox-Client-Id middleware records the header but never rejects an absent or fake one, so a Voicebox bound to 0.0.0.0 (the documented "remote access" mode) was effectively an unauthenticated arbitrary-local-file read primitive. The middleware now stashes the request's remote address in a ContextVar alongside the existing client_id, and audio_path mode refuses anything that doesn't parse as a loopback address (IPv4 127.0.0.0/8, IPv6 ::1). audio_base64 mode is unchanged — that path was always bounded to bytes the caller already has. Loopback callers (the Tauri webview, local CLI scripts, MCP clients on the same machine) keep working. Remote callers now have to send the audio over the wire if they want it transcribed.
128 lines
4.5 KiB
Python
128 lines
4.5 KiB
Python
"""Per-request client identity for MCP calls.
|
|
|
|
MCP clients identify themselves via an ``X-Voicebox-Client-Id`` HTTP header
|
|
(direct-HTTP clients set it in their MCP config; the stdio shim forwards it
|
|
from the ``VOICEBOX_CLIENT_ID`` env var). Middleware copies the value into a
|
|
ContextVar so tool implementations can read it without plumbing the request
|
|
object through every service call.
|
|
"""
|
|
|
|
import ipaddress
|
|
import logging
|
|
from contextvars import ContextVar
|
|
from datetime import datetime
|
|
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
from starlette.requests import Request
|
|
from starlette.responses import Response
|
|
from starlette.types import ASGIApp
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
CLIENT_ID_HEADER = "X-Voicebox-Client-Id"
|
|
|
|
# Tool handlers read this to apply per-client voice bindings.
|
|
current_client_id: ContextVar[str | None] = ContextVar(
|
|
"current_client_id", default=None
|
|
)
|
|
|
|
# Remote address of the in-flight request. Used by tools that gate
|
|
# host-filesystem access to loopback callers (see voicebox.transcribe).
|
|
current_remote_addr: ContextVar[str | None] = ContextVar(
|
|
"current_remote_addr", default=None
|
|
)
|
|
|
|
|
|
def request_is_loopback() -> bool:
|
|
"""True when the in-flight request originated on the loopback interface.
|
|
|
|
Returns False if no request is in flight or the remote address can't be
|
|
parsed — callers gating filesystem reads on this should treat that as
|
|
"deny".
|
|
"""
|
|
addr = current_remote_addr.get()
|
|
if not addr:
|
|
return False
|
|
try:
|
|
return ipaddress.ip_address(addr).is_loopback
|
|
except ValueError:
|
|
return False
|
|
|
|
# Endpoints that consume X-Voicebox-Client-Id for its MCP-semantic
|
|
# meaning (per-client profile resolution + per-client default_personality).
|
|
# These are the paths where a stamp into last_seen_at is accurate.
|
|
# Unrelated REST traffic that happens to set the header is intentionally
|
|
# ignored so the Settings UI's "last heard from" column only reflects
|
|
# calls that actually acted on the client's bindings.
|
|
#
|
|
# - /mcp — FastMCP tool calls (voicebox.speak, voicebox.transcribe, …)
|
|
# and the /mcp/bindings admin surface. The admin surface is never
|
|
# called with the header in practice (the frontend manages bindings
|
|
# over plain REST), so the `startswith("/mcp")` match doesn't cause
|
|
# false stamps.
|
|
# - /speak — REST mirror of voicebox.speak for non-MCP agents (shell
|
|
# scripts, ACP, A2A). Uses the same per-client binding lookup, so its
|
|
# callers belong in the last-seen list too.
|
|
_STAMPED_PATH_PREFIXES: tuple[str, ...] = ("/mcp", "/speak")
|
|
|
|
|
|
class ClientIdMiddleware(BaseHTTPMiddleware):
|
|
"""Copy X-Voicebox-Client-Id into a ContextVar and stamp last_seen_at
|
|
for requests that act on the caller's MCP bindings."""
|
|
|
|
def __init__(self, app: ASGIApp) -> None:
|
|
super().__init__(app)
|
|
|
|
async def dispatch(self, request: Request, call_next) -> Response:
|
|
client_id = request.headers.get(CLIENT_ID_HEADER)
|
|
remote_addr = request.client.host if request.client else None
|
|
client_token = current_client_id.set(client_id)
|
|
addr_token = current_remote_addr.set(remote_addr)
|
|
try:
|
|
response = await call_next(request)
|
|
finally:
|
|
current_client_id.reset(client_token)
|
|
current_remote_addr.reset(addr_token)
|
|
|
|
if client_id and _is_stamped_path(request.url.path):
|
|
_stamp_last_seen(client_id)
|
|
return response
|
|
|
|
|
|
def _is_stamped_path(path: str) -> bool:
|
|
# Require a path boundary so a future ``/speakers`` or ``/mcpfoo``
|
|
# route doesn't silently inherit the stamp from ``/speak`` / ``/mcp``.
|
|
return any(path == p or path.startswith(p + "/") for p in _STAMPED_PATH_PREFIXES)
|
|
|
|
|
|
def _stamp_last_seen(client_id: str) -> None:
|
|
"""Update or create the MCPClientBinding row for this client_id."""
|
|
try:
|
|
from ..database import get_db
|
|
from ..database.models import MCPClientBinding
|
|
except Exception:
|
|
return
|
|
try:
|
|
db = next(get_db())
|
|
except Exception:
|
|
return
|
|
try:
|
|
row = (
|
|
db.query(MCPClientBinding)
|
|
.filter(MCPClientBinding.client_id == client_id)
|
|
.first()
|
|
)
|
|
if row is None:
|
|
row = MCPClientBinding(client_id=client_id)
|
|
db.add(row)
|
|
row.last_seen_at = datetime.utcnow()
|
|
db.commit()
|
|
except Exception:
|
|
logger.debug(
|
|
"Could not stamp last_seen_at for %s", client_id, exc_info=True
|
|
)
|
|
db.rollback()
|
|
finally:
|
|
db.close()
|