Skip to content

Enforce the checks the service edges only appeared to have - #24

Merged
obro79 merged 1 commit into
slice/voicefrom
slice/voice-boundaries
Aug 3, 2026
Merged

Enforce the checks the service edges only appeared to have#24
obro79 merged 1 commit into
slice/voicefrom
slice/voice-boundaries

Conversation

@obro79

@obro79 obro79 commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Stack — merge bottom to top

  1. Slice 2: Python/FastAPI voice gateway #22 — the gateway (slice/voiceprod)
  2. Bind each turn to the question it was spoken against #23 — turn/question binding fix
  3. ← you are here — service-edge checks

Base is slice/voice-turn-binding, so the diff shown is only this change. Review #22 and #23 first.


Three edges of this service, each with a check that read as present and was not.

Anyone could open an intake session

The WebSocket route called accept() unconditionally. allowed_origins was wired only into CORSMiddleware — which never sees this request. A browser sends no preflight for a WebSocket handshake and applies no same-origin rule to it, so any page on the internet could open a session against a service that sits inside the PHI boundary.

The handshake is now rejected before accept(). The Origin header is set by the browser and cannot be forged from script, which is exactly the attacker this is for; it is not a substitute for the session auth that arrives with the real deployment, and the code says so.

localhost joins 127.0.0.1 in the default allowlist. They are different origins to a browser and the demo page is reachable at either — a check that rejects half the URLs a developer actually types gets widened to * and stays there.

The persistence stub accepted any bearer token

It tested that the header started with Bearer and never compared it to the configured secret, while persist_turn_token sitting in config made it read as enforced. Now compared with compare_digest.

It is a stub and stays one. But the real TypeScript route gets written from this file, and a stub that teaches the shape of a check without the substance of it teaches the wrong thing.

The ADR-013 self-hosted deployment was unreachable

allow_self_hosted_host was threaded through every URL builder and passed by no call site. Pointing deepgram_host at a private host in ca-central-1 — the deployment ADR-013 specifies, because there is no Canadian Deepgram region — raised DeepgramUrlError from every call.

It now defaults from a new deepgram_allow_self_hosted_host setting. Off by default, so a typo in the host still fails loudly rather than quietly sending audio somewhere unintended. A flag only the tests can set is not a flag.

The choke point is unchanged. mip_opt_out=true is still welded on after the caller's parameters, on every endpoint, including the self-hosted path where it is inert — a guarantee that holds only while one deployment mode holds is not a guarantee. The AST test still passes.

Tests

tests/test_service_boundaries.py, ten cases: cross-origin handshake rejection end to end, the origin-matching table (including that it is not a prefix match), the stub rejecting a wrong token and accepting the right one, and the self-hosted host reachable by configuration while an unrecognised host still fails without the flag.

129 pass. Cross-origin rejection also confirmed against the running server: https://evil.example gets HTTP 403 at the handshake, http://127.0.0.1:8080 connects.

🤖 Generated with Claude Code

…d to have

Three edges, each with a check that read as present and was not.

**Anyone could open an intake session.** The WebSocket route called `accept()`
unconditionally. `allowed_origins` was wired only into `CORSMiddleware`, which
never sees this request — a browser sends no preflight for a WebSocket
handshake and applies no same-origin rule to it, so any page on the internet
could open a session. The handshake is now rejected before `accept()` for an
origin that is not allowed. The Origin header cannot be forged from script,
which is exactly the attacker this is for; it is not a substitute for the
session auth that arrives with the real deployment.

`localhost` joins `127.0.0.1` in the default allowlist. They are different
origins to a browser and the demo page is reachable at either — a check that
rejects half the URLs a developer types gets widened to `*` and stays there.

**The persistence stub accepted any bearer token.** It tested that the header
started with `Bearer ` and never compared it to the configured secret, while
`persist_turn_token` sitting in config made it read as enforced. Now compared
with `compare_digest`. It is a stub, but the real TypeScript route gets written
from this file, and a stub that teaches the shape of a check without the
substance of it teaches the wrong thing.

**The ADR-013 self-hosted deployment was unreachable.** `allow_self_hosted_host`
was threaded through every URL builder and passed by no call site, so pointing
`deepgram_host` at a private host in ca-central-1 — the deployment the ADR
specifies — raised `DeepgramUrlError` from every call. It now defaults from a
new `deepgram_allow_self_hosted_host` setting, off by default so a typo in the
host still fails loudly rather than quietly sending audio somewhere unintended.
A flag only the tests can set is not a flag.

The URL choke point itself is unchanged: `mip_opt_out=true` is still welded on
after the caller's parameters, on every endpoint, including this path where it
is inert.

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 3, 2026 02:57
@vercel

vercel Bot commented Aug 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
rehabify Ready Ready Preview Aug 3, 2026 2:57am

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR tightens three “service-edge” checks in the voice gateway: (1) rejecting cross-origin WebSocket intake handshakes before accept(), (2) enforcing the configured persistence stub bearer token, and (3) making the ADR-013 self-hosted Deepgram host reachable via configuration while still failing unknown hosts by default.

Changes:

  • Add explicit Origin allowlist enforcement for /v1/intake/stream before accepting the WebSocket.
  • Enforce persistence stub bearer token value (not just “Bearer ” shape) using constant-time comparison.
  • Default Deepgram self-hosted host allowance from config (deepgram_allow_self_hosted_host) and document it in .env.example, with tests covering these boundaries.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
services/voice-gateway/tests/test_service_boundaries.py Adds end-to-end and unit tests for Origin matching, stub token enforcement, and self-hosted Deepgram host configuration.
services/voice-gateway/src/voice_gateway/persistence/stub_server.py Enforces the configured persistence stub bearer token using secrets.compare_digest.
services/voice-gateway/src/voice_gateway/deepgram/urls.py Makes allow_self_hosted_host default from config and updates the host validation/error path accordingly.
services/voice-gateway/src/voice_gateway/config.py Adds deepgram_allow_self_hosted_host setting and expands default allowed_origins to include localhost.
services/voice-gateway/src/voice_gateway/app.py Adds _origin_is_allowed and rejects disallowed WebSocket Origins prior to accept().
services/voice-gateway/.env.example Documents VOICE_GATEWAY_DEEPGRAM_ALLOW_SELF_HOSTED_HOST.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 42 to +51
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")
@greptile-apps

greptile-apps Bot commented Aug 3, 2026

Copy link
Copy Markdown

Greptile Summary

This PR enforces three security checks in the voice gateway service that appeared to be present but were not actually active: WebSocket origin validation before websocket.accept(), bearer token comparison using secrets.compare_digest in the persistence stub, and Deepgram self-hosted host access via a proper config setting rather than a per-call keyword argument no caller was passing.

  • Origin check: _origin_is_allowed is evaluated before accept() so a browser from any other page cannot open a WebSocket session against the PHI boundary; http://localhost:8080 is added to the default allowlist alongside http://127.0.0.1:8080 because they are different origins to a browser.
  • Token comparison: The stub's persist_turn endpoint now calls secrets.compare_digest instead of only verifying the Bearer prefix — closing the gap between the shape of the check and its substance.
  • Self-hosted host: deepgram_allow_self_hosted_host is added to Settings and defaulted from there in build_deepgram_url, so the ADR-013 deployment is reachable by configuration rather than requiring every call site to thread a flag through.

Confidence Score: 4/5

Safe to merge; all three previously-unenforced checks are now active and covered by ten new test cases, with cross-origin rejection confirmed against the running server.

Each of the three fixes is targeted and correct: close() before accept() is the right ASGI pattern for rejecting a WebSocket handshake, secrets.compare_digest is the right tool for token comparison against a shared secret, and defaulting allow_self_hosted_host from settings rather than leaving it a dead parameter is a clean resolution. The small gap is in the test for stub token enforcement — it constructs Settings() directly to get the expected token while the stub endpoint uses the @lru_cache-ed get_settings(), which could diverge if a poorly-scoped patch leaked from another test. The production concern worth a second read is that setting allowed_origins=["*"] not only opens all browser origins but also all native clients with no Origin header, since the wildcard check runs before the None-origin guard.

Files Needing Attention: tests/test_service_boundaries.py line 82 (token assertion uses a fresh Settings() instance) and app.py lines 114–115 (wildcard allowed_origins semantics for native clients).

Important Files Changed

Filename Overview
services/voice-gateway/src/voice_gateway/app.py Adds _origin_is_allowed helper and moves the origin check before websocket.accept() — correct ASGI pattern for rejecting a handshake at the HTTP layer rather than opening then closing.
services/voice-gateway/src/voice_gateway/config.py Adds deepgram_allow_self_hosted_host (defaults False) and expands allowed_origins default to include http://localhost:8080 alongside http://127.0.0.1:8080; both changes are consistent with the rest of the settings model.
services/voice-gateway/src/voice_gateway/deepgram/urls.py Changes allow_self_hosted_host parameter type from bool = False to `bool
services/voice-gateway/src/voice_gateway/persistence/stub_server.py Adds actual bearer token comparison with secrets.compare_digest — previously the endpoint only verified the Bearer prefix, not the value; now enforces the configured secret against the presented token.
services/voice-gateway/tests/test_service_boundaries.py New test file covering all three edges: cross-origin WS rejection end-to-end, origin-matching table including the prefix-match non-vulnerability, token enforcement, and self-hosted host reachability by configuration. Token test uses Settings() directly rather than the cached get_settings(), which is consistent in practice but creates a subtle coupling.
services/voice-gateway/.env.example Documents the new VOICE_GATEWAY_DEEPGRAM_ALLOW_SELF_HOSTED_HOST variable with a clear note that it is off by default and why.

Sequence Diagram

sequenceDiagram
    participant B as Browser
    participant GW as Voice Gateway (app.py)
    participant S as Settings

    B->>GW: WebSocket Upgrade (Origin: https://evil.example)
    GW->>S: app.state.settings.allowed_origins
    S-->>GW: ["http://127.0.0.1:8080", "http://localhost:8080"]
    GW->>GW: _origin_is_allowed("https://evil.example", allowed) → False
    GW-->>B: websocket.close(1008) — before accept()

    B->>GW: WebSocket Upgrade (Origin: http://localhost:8080)
    GW->>S: app.state.settings.allowed_origins
    S-->>GW: ["http://127.0.0.1:8080", "http://localhost:8080"]
    GW->>GW: _origin_is_allowed("http://localhost:8080", allowed) → True
    GW-->>B: websocket.accept() → 101 Switching Protocols
    GW->>GW: IntakeSession.start()
Loading
Prompt To Fix All With AI
### Issue 1
services/voice-gateway/tests/test_service_boundaries.py:82
**`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.

### Issue 2
services/voice-gateway/src/voice_gateway/app.py:114-115
**`"*"` 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.

Reviews (1): Last reviewed commit: "fix(voice-gateway): enforce the checks t..." | Re-trigger Greptile

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!

Comment on lines +114 to +115
if "*" in allowed:
return True

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.

@obro79
obro79 changed the base branch from slice/voice-turn-binding to slice/voice August 3, 2026 03:47
@obro79
obro79 merged commit a4cbfb0 into slice/voice Aug 3, 2026
5 checks passed
obro79 added a commit that referenced this pull request Aug 3, 2026
Brings in the two review fixes that had not yet landed: frozen wire
collections with the extraction attribution hole closed, and the
failure-recording and splice holes found in review.

Every other slice (schema, intake, eval, voice, voice-boundaries,
voice-turn-binding) was already contained in rebuild via #22-#24.

Co-Authored-By: Claude <noreply@anthropic.com>
@obro79
obro79 deleted the slice/voice-boundaries branch August 3, 2026 20:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants