Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions services/voice-gateway/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ VOICE_GATEWAY_DEEPGRAM_API_KEY=
VOICE_GATEWAY_DEEPGRAM_HOST=api.deepgram.com
VOICE_GATEWAY_DEEPGRAM_TOKEN_TTL_SECONDS=30

# Required to point DEEPGRAM_HOST at anything the URL builder does not know.
# Off by default so a typo fails loudly instead of quietly sending audio
# somewhere unintended; on, for the ADR-013 self-hosted deployment.
VOICE_GATEWAY_DEEPGRAM_ALLOW_SELF_HOSTED_HOST=false

VOICE_GATEWAY_STT_MODEL=nova-3-medical
VOICE_GATEWAY_STT_ENDPOINTING_MS=400
VOICE_GATEWAY_STT_UTTERANCE_END_MS=1500
Expand Down
28 changes: 27 additions & 1 deletion services/voice-gateway/src/voice_gateway/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,37 @@ async def send_audio(self, chunk: bytes) -> None:
await self._ws.send_bytes(chunk)


def _origin_is_allowed(origin: str | None, allowed: list[str]) -> bool:
"""CORS does not cover this. The middleware above guards `fetch`; a browser
sends no preflight for a WebSocket handshake and applies no same-origin rule
to it, so an unchecked `accept()` lets any page on the internet open an
intake session. The Origin header is set by the browser and cannot be forged
from script, which is exactly the attacker this check is for — it is not a
substitute for the session auth that arrives with the real deployment.
"""
if "*" in allowed:
return True
Comment on lines +114 to +115

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 "*" wildcard also admits None-origin (native) clients

The check if "*" in allowed: return True fires before the origin is None guard, so when VOICE_GATEWAY_ALLOWED_ORIGINS=["*"], _origin_is_allowed(None, ["*"]) returns True — admitting wscat, curl, or any raw WebSocket client with no Origin header. The test at line 44 documents this explicitly, and the behaviour is intentional for test/dev environments. It is worth confirming that the production deployment does not set "*" as the only allowed origin, since doing so would bypass the check for native clients while the session-level auth is still marked "not yet" in the code comment.

Prompt To Fix With AI
This is a comment left during a code review.
Path: services/voice-gateway/src/voice_gateway/app.py
Line: 114-115

Comment:
**`"*"` wildcard also admits `None`-origin (native) clients**

The check `if "*" in allowed: return True` fires before the `origin is None` guard, so when `VOICE_GATEWAY_ALLOWED_ORIGINS=["*"]`, `_origin_is_allowed(None, ["*"])` returns `True` — admitting `wscat`, curl, or any raw WebSocket client with no `Origin` header. The test at line 44 documents this explicitly, and the behaviour is intentional for test/dev environments. It is worth confirming that the production deployment does *not* set `"*"` as the only allowed origin, since doing so would bypass the check for native clients while the session-level auth is still marked "not yet" in the code comment.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

if origin is None:
# Not a browser. Native clients are the deployment's problem to
# authenticate, and here that means "not yet".
return False
return origin in allowed


@app.websocket("/v1/intake/stream")
async def intake_stream(websocket: WebSocket) -> None:
await websocket.accept()
settings = app.state.settings

origin = websocket.headers.get("origin")
if not _origin_is_allowed(origin, settings.allowed_origins):
logger.warning("rejected an intake handshake from origin %r", origin)
# Before accept(), so the handshake fails outright rather than opening
# and immediately closing.
await websocket.close(code=1008, reason="origin not allowed")
return

await websocket.accept()

session = IntakeSession(
settings=settings,
channel=_WebSocketChannel(websocket),
Expand Down
18 changes: 17 additions & 1 deletion services/voice-gateway/src/voice_gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ class Settings(BaseSettings):
# holds is not a guarantee.
deepgram_host: str = "api.deepgram.com"

# Setting a host the builder does not recognise is an error unless the
# operator says the unfamiliar host is deliberate. Off by default so a typo
# in `deepgram_host` fails loudly instead of quietly sending audio somewhere
# unintended; on, it is what makes the ADR-013 deployment configurable at
# all rather than a parameter nothing passes.
deepgram_allow_self_hosted_host: bool = False

# Ephemeral tokens only need to be valid at handshake time (05 §2).
deepgram_token_ttl_seconds: int = 30

Expand Down Expand Up @@ -88,7 +95,16 @@ class Settings(BaseSettings):
# --- Server -----------------------------------------------------------
host: str = "127.0.0.1"
port: int = 8080
allowed_origins: list[str] = Field(default_factory=lambda: ["http://127.0.0.1:8080"])
# Gates both CORS and the WebSocket handshake. `localhost` is listed
# alongside `127.0.0.1` because they are different origins to a browser and
# the demo page is reachable at either — a check that rejects half the URLs
# the developer actually types gets widened to `*` and stays there.
allowed_origins: list[str] = Field(
default_factory=lambda: [
"http://127.0.0.1:8080",
"http://localhost:8080",
]
)

@field_validator("deepgram_host")
@classmethod
Expand Down
25 changes: 17 additions & 8 deletions services/voice-gateway/src/voice_gateway/deepgram/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ def build_deepgram_url(
params: dict[str, str | int | float | bool] | None = None,
keyterms: tuple[str, ...] | list[str] = (),
host: str | None = None,
allow_self_hosted_host: bool = False,
allow_self_hosted_host: bool | None = None,
over_http: bool = False,
) -> str:
"""Build a Deepgram URL. Always opted out of model training.
Expand All @@ -107,18 +107,27 @@ def build_deepgram_url(
unless `allow_self_hosted_host` is set.
allow_self_hosted_host: permits a private host for the ADR-013
self-hosted deployment. Note that the opt-out flag is still applied
— it is inert there, and harmless.
— it is inert there, and harmless. Defaults to the configured
setting, so the deployment is reachable by configuration rather than
only by a keyword argument every call site would have to remember to
thread through; pass it explicitly to override.

Raises:
DeepgramUrlError: on a keyterm or parameter that would fail silently.
"""
from voice_gateway.config import get_settings

resolved_host = host or get_settings().deepgram_host
if resolved_host not in DEEPGRAM_HOSTS and not allow_self_hosted_host:
settings = get_settings()
resolved_host = host or settings.deepgram_host
allow_self_hosted = (
settings.deepgram_allow_self_hosted_host
if allow_self_hosted_host is None
else allow_self_hosted_host
)
if resolved_host not in DEEPGRAM_HOSTS and not allow_self_hosted:
raise DeepgramUrlError(
f"{resolved_host!r} is not a known Deepgram host. If this is the "
"self-hosted deployment, pass allow_self_hosted_host=True."
"self-hosted deployment, set deepgram_allow_self_hosted_host."
)

supplied = dict(params or {})
Expand Down Expand Up @@ -166,7 +175,7 @@ def build_listen_url(
utterance_end_ms: int,
keyterms: tuple[str, ...] = (),
host: str | None = None,
allow_self_hosted_host: bool = False,
allow_self_hosted_host: bool | None = None,
) -> str:
"""Streaming STT connection parameters, exactly as pinned in 05 §3."""
if not 1000 <= utterance_end_ms <= 5000:
Expand Down Expand Up @@ -207,7 +216,7 @@ def build_speak_url(
container: str | None = None,
over_http: bool = False,
host: str | None = None,
allow_self_hosted_host: bool = False,
allow_self_hosted_host: bool | None = None,
) -> str:
"""TTS URL, for both the streaming socket and the REST pre-render.

Expand Down Expand Up @@ -238,7 +247,7 @@ def build_speak_url(


def build_auth_grant_url(
*, host: str | None = None, allow_self_hosted_host: bool = False
*, host: str | None = None, allow_self_hosted_host: bool | None = None
) -> str:
"""Ephemeral token grant. Also goes through the builder — "all API
requests" in 05 §6 has no carve-out for the ones that do not carry audio,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@

import argparse
import logging
import secrets

import uvicorn
from fastapi import FastAPI, Header, HTTPException

from voice_gateway.config import get_settings
from voice_gateway.contracts import TurnEvent

logger = logging.getLogger(__name__)
Expand All @@ -40,6 +42,14 @@ async def persist_turn(
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="missing bearer token")

# Compare it, rather than merely observing that it is shaped like a token.
# A stub that accepts any bearer teaches the shape of the check without the
# substance of it, and the TypeScript route gets written from this file.
# `compare_digest` because the comparison is against a shared secret.
expected = get_settings().persist_turn_token
if not secrets.compare_digest(authorization.removeprefix("Bearer "), expected):
raise HTTPException(status_code=401, detail="invalid bearer token")
Comment on lines 42 to +51

_received.append(event)
# Identifiers only, matching what the real route may log. The transcript is
# PHI and is not a log line here or there.
Expand Down
120 changes: 120 additions & 0 deletions services/voice-gateway/tests/test_service_boundaries.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"""The three edges of this service, and what is checked at each of them.

The gateway sits inside the PHI boundary and outside the persistence boundary
(ADR-016). That makes its edges the interesting part: who may open an intake
session, what it presents to the TypeScript tier, and where it is allowed to
send audio. Each of these had a check that looked present and was not.
"""

from __future__ import annotations

import pytest
from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect

from voice_gateway.app import _origin_is_allowed, app
from voice_gateway.config import Settings
from voice_gateway.deepgram.urls import DeepgramUrlError, build_auth_grant_url

# --- who may open an intake session ---------------------------------------


def test_a_page_on_another_origin_cannot_open_a_session() -> None:
"""CORS does not cover this. A browser sends no preflight for a WebSocket
handshake and applies no same-origin rule to it, so the CORS middleware —
which is where `allowed_origins` was wired — never sees this request."""
with TestClient(app) as client:
with pytest.raises(WebSocketDisconnect) as caught:
with client.websocket_connect(
"/v1/intake/stream", headers={"Origin": "https://not-us.example"}
):
pass

assert caught.value.code == 1008


@pytest.mark.parametrize(
("origin", "allowed", "expected"),
[
("http://127.0.0.1:8080", ["http://127.0.0.1:8080"], True),
("https://evil.example", ["http://127.0.0.1:8080"], False),
# No Origin means not a browser. Native clients are the deployment's
# problem to authenticate, and here that means "not yet".
(None, ["http://127.0.0.1:8080"], False),
(None, ["*"], True),
("https://anything.example", ["*"], True),
# Not a prefix match: a domain that merely starts with an allowed one
# is a different origin.
("http://127.0.0.1:8080.evil.example", ["http://127.0.0.1:8080"], False),
],
)
def test_origin_matching(origin: str | None, allowed: list[str], expected: bool) -> None:
assert _origin_is_allowed(origin, allowed) is expected


# --- what it presents to the TypeScript tier ------------------------------


def _post(client: TestClient, token: str | None) -> int:
event = {
"session_id": "11111111-1111-1111-1111-111111111111",
"turn_index": 0,
"question_id": "knee.onset",
"prompt_version": "knee-v0-mock",
"outcome": "reprompt",
"stt_model": "nova-3-medical",
"tts_cache_hit": True,
"answer": None,
}
headers = {} if token is None else {"Authorization": f"Bearer {token}"}
return client.post("/api/internal/intake/turn", json=event, headers=headers).status_code


def test_the_stub_checks_the_token_rather_than_its_shape() -> None:
"""A stub that accepts any bearer teaches the shape of the check without
the substance of it — and the real TypeScript route gets written from this
file. `persist_turn_token` existing in config made it read as enforced."""
from voice_gateway.persistence.stub_server import app as stub

with TestClient(stub) as client:
assert _post(client, None) == 401
assert _post(client, "not-the-secret") == 401
assert _post(client, Settings().persist_turn_token) == 201

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Settings() and get_settings() can diverge under monkeypatching

_post(client, Settings().persist_turn_token) constructs a fresh Settings instance, while the stub endpoint calls the @lru_cache-ed get_settings(). In this file those two agree because neither is monkeypatched here. However, get_settings is cached at the process level, so if any fixture or test that ran earlier in the session replaced it (e.g., the monkeypatch.setattr(config, "get_settings", ...) calls below), and the monkeypatch teardown restored the function reference but the cache was cold again, a subsequent call to get_settings() in the stub endpoint re-populates from the real environment while Settings() also reads from it — still consistent. The subtle risk is that a test helper in another file that patches get_settings without the monkeypatch fixture (i.e., without automatic teardown) could leave the stub endpoint returning a cached token that differs from what Settings() produces. Using get_settings().persist_turn_token instead of Settings().persist_turn_token would keep both sides of the assertion on the same instance.

Prompt To Fix With AI
This is a comment left during a code review.
Path: services/voice-gateway/tests/test_service_boundaries.py
Line: 82

Comment:
**`Settings()` and `get_settings()` can diverge under monkeypatching**

`_post(client, Settings().persist_turn_token)` constructs a fresh `Settings` instance, while the stub endpoint calls the `@lru_cache`-ed `get_settings()`. In this file those two agree because neither is monkeypatched here. However, `get_settings` is cached at the process level, so if any fixture or test that ran earlier in the session replaced it (e.g., the `monkeypatch.setattr(config, "get_settings", ...)` calls below), and the monkeypatch teardown restored the *function reference* but the cache was cold again, a subsequent call to `get_settings()` in the stub endpoint re-populates from the real environment while `Settings()` also reads from it — still consistent. The subtle risk is that a test helper in another file that patches `get_settings` without the `monkeypatch` fixture (i.e., without automatic teardown) could leave the stub endpoint returning a cached token that differs from what `Settings()` produces. Using `get_settings().persist_turn_token` instead of `Settings().persist_turn_token` would keep both sides of the assertion on the same instance.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!



# --- where it is allowed to send audio ------------------------------------


def test_a_self_hosted_host_is_reachable_by_configuration(monkeypatch) -> None:
"""ADR-013 points at self-hosted Deepgram in ca-central-1, because there is
no Canadian Deepgram region. The builder had an `allow_self_hosted_host`
parameter that no call site passed, so that deployment was unreachable —
every URL raised. A flag only the tests can set is not a flag."""
from voice_gateway import config

def _self_hosted() -> Settings:
return Settings(
deepgram_host="deepgram.internal.rehabify.ca",
deepgram_allow_self_hosted_host=True,
)

monkeypatch.setattr(config, "get_settings", _self_hosted)
url = build_auth_grant_url()

assert url.startswith("https://deepgram.internal.rehabify.ca/v1/auth/grant")
# Inert on a self-hosted deployment, and applied anyway. A guarantee that
# holds only while one deployment mode holds is not a guarantee.
assert "mip_opt_out=true" in url


def test_an_unrecognised_host_still_fails_without_the_flag(monkeypatch) -> None:
"""Off by default, so a typo in `deepgram_host` fails loudly instead of
quietly sending audio somewhere unintended."""
from voice_gateway import config

monkeypatch.setattr(
config, "get_settings", lambda: Settings(deepgram_host="api.deepgran.com")
)

with pytest.raises(DeepgramUrlError, match="not a known Deepgram host"):
build_auth_grant_url()