Skip to content

fix(security): verify provider signatures on telephony webhooks and pin recording hosts (PT-05/12/23) - #989

Open
murdore wants to merge 1 commit into
releasefrom
fix/pt-telephony-signatures
Open

fix(security): verify provider signatures on telephony webhooks and pin recording hosts (PT-05/12/23)#989
murdore wants to merge 1 commit into
releasefrom
fix/pt-telephony-signatures

Conversation

@murdore

@murdore murdore commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Independent of the other pentest PRs — targets release, merges in any order.

The callback and answer routes accepted any caller: anyone who knew a URL could
drive call state. Verified on release — unsigned POSTs to the twilio, plivo and
exotel status callbacks are all accepted.

verify_provider_webhook (new) validates Twilio X-Twilio-Signature, Plivo V3
signatures and the Exotel auth token, and is applied to every callback and
answer route. Token comparison is constant-time and fails CLOSED when the
expected token is unset, so a missing env var denies rather than admits.

Enforcement is on by default via ENFORCE_TELEPHONY_WEBHOOK_SIGNATURES. It exists
to avoid a hard outage during rollout, not as a supported posture.

PT-12 (recording host pinning) is NOT here: its fix is built on the SSRF egress
guard, so it ships in that PR instead of forcing a dependency between the two.

932 tests pass on this branch.


Independent by construction

This PR targets release directly. It shares no file with any other pentest PR, and all five were trial-merged pairwise — 10 of 10 pairs merge with no conflict, so they can land in any order.

The four config blocks that previously forced a chain (static.py, .env.example) now sit at four distinct anchors hundreds of lines apart, each next to the settings it belongs with, so independent branches auto-merge instead of colliding.

Merging all five reproduces the original #930 tree — static.py is identical content in a different order, .env.example differs only by one now-meaningless banner comment — and the combined suite runs 992 passed.

Evidence recording is in the comment below.

Summary by CodeRabbit

  • New Features

    • Added authentication for telephony webhooks from Twilio, Plivo, and Exotel.
    • Added support for deployment-specific webhook URL prefixes.
    • Added secure Exotel callback URLs and safer logging that masks sensitive information.
  • Bug Fixes

    • Invalid, missing, or tampered webhook credentials are now rejected before callbacks are processed.
  • Tests

    • Added coverage for provider signatures, token validation, URL reconstruction, and failure scenarios.

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9080f023-fd22-4c32-a79c-883dc1a54de3

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

Telephony webhook verification is centralized for Twilio, Plivo, and Exotel. Configuration controls enforcement and trusted URL prefixes. Callback handlers verify requests before processing. Exotel callbacks include authentication, and sensitive logging is redacted.

Changes

Telephony webhook authentication

Layer / File(s) Summary
Verification configuration and provider checks
.env.example, app/core/config/static.py, app/core/security/webhook_signature.py, tests/test_pentest_telephony_auth.py
Adds default-enabled verification, trusted path-prefix handling, public URL reconstruction, token redaction, and Twilio, Plivo, and Exotel validation.
Authenticated callback integration
app/ai/voice/agents/breeze_buddy/services/telephony/exotel/exotel.py, app/api/routers/breeze_buddy/telephony/answer/__init__.py, app/api/routers/breeze_buddy/telephony/callbacks/handlers.py
Adds authenticated Exotel status callbacks and verifies telephony callbacks before parsing data or triggering side effects.
Authentication and dispatch coverage
tests/test_pentest_telephony_auth.py
Tests provider dispatch, forged requests, multipart Twilio requests, and disabled enforcement.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Provider
  participant TelephonyHandler
  participant verify_provider_webhook
  participant CallbackAction
  Provider->>TelephonyHandler: Send callback webhook
  TelephonyHandler->>verify_provider_webhook: Verify provider request
  verify_provider_webhook-->>TelephonyHandler: Return verification result
  TelephonyHandler->>CallbackAction: Process verified callback
Loading

Suggested reviewers: manas-narra

Poem

“Sign the call,” said the rabbit in flight,
“Check every token, keep secrets out of sight.”
Twilio, Plivo, Exotel in tune,
Safe callbacks hop beneath the moon.
403? No—verified paths bloom!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title correctly describes provider signature verification but incorrectly claims that the pull request pins recording hosts, which the changeset explicitly excludes. Remove “and pin recording hosts (PT-05/12/23)” from the title, or update the changeset if recording host pinning is intended.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/pt-telephony-signatures

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@murdore

murdore commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Evidence

A real screencast — Chrome DevTools Page.startScreencast capturing every repaint while the page is driven by real Input.dispatchKeyEvent/dispatchMouseEvent calls. Frames carry their arrival timestamps and are encoded with those durations, so the pacing you see is the pacing that happened.

Each take runs three acts: attack release (if that were already refused, the PR would be guarding something that was never broken), the same attack against this PR, then the legitimate paths re-run — because a control that bounds an attack by breaking normal use is not a fix.

Telephony callback signatures

pr989-telephony-signatures.mp4

local original: /Users/sachinsharma/Developer/temp/clairvoyance/.proof-video/pr989/pr989-telephony-signatures.mp4

Unsigned POSTs to /{provider}/callback/status:

                          BASELINE          THIS PR
unsigned POST /twilio     ALLOWED           REFUSED (401)
unsigned POST /plivo      ALLOWED           REFUSED (401)
unsigned POST /exotel     ALLOWED           REFUSED (401)

And what must still work — routes stay mounted and reachable, signed traffic unaffected, all accounts still sign in.

932 tests pass. Token comparison is constant-time and a missing expected token denies rather than admits, so a misconfigured deploy fails closed.

Re-recorded 2026-08-09. The first set of takes had two defects worth naming. They compared each PR against its old parent branch, which was accurate when these were a stack but describes a topology that no longer exists — every PR is independent off release now, and the baseline in these recordings is release itself. And the "dashboard" pane sat on a login form for the whole runtime; it now signs in for real and the probes run beside a live console. The recorder also gained an assertion that aborts a take if that sign-in does not land, because a silently-failed login is exactly the kind of thing that gets captioned as a success.

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

Adds shared, fail-closed authentication for telephony provider webhooks to prevent forged callback/answer requests from driving call state or injecting attacker-controlled recording URLs.

Changes:

  • Introduces app/core/security/webhook_signature.py with Twilio (X-Twilio-Signature), Plivo (V3/V2), and Exotel (auth_token) verification plus a shared verify_provider_webhook() gate.
  • Applies webhook verification across Breeze Buddy telephony callback and answer handlers, and adds ENFORCE_TELEPHONY_WEBHOOK_SIGNATURES (default enabled) as a rollout escape hatch.
  • Updates Exotel outbound call initiation to embed the shared auth_token into the status callback URL; adds a focused pentest regression test for Exotel token verification.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/test_pentest_telephony_auth.py Adds pentest regression test coverage for Exotel webhook token verification.
app/core/security/webhook_signature.py New shared verification module for Twilio/Plivo signatures and Exotel auth token, with centralized reject behavior.
app/core/config/static.py Adds ENFORCE_TELEPHONY_WEBHOOK_SIGNATURES static config flag (default true).
app/api/routers/breeze_buddy/telephony/callbacks/handlers.py Enforces provider authentication before processing callback/transfer/status/twiml-fallback requests.
app/api/routers/breeze_buddy/telephony/answer/init.py Enforces provider authentication for Exotel/Plivo answer webhooks via shared verifier.
app/ai/voice/agents/breeze_buddy/services/telephony/exotel/exotel.py Embeds Exotel auth_token into status callback URL used in outbound call creation.
.env.example Documents and exposes ENFORCE_TELEPHONY_WEBHOOK_SIGNATURES default.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread tests/test_pentest_telephony_auth.py Outdated
Comment on lines +1 to +32
"""PT-23/PT-05: constant-time provider token comparison, fail-closed when unset."""

from __future__ import annotations

from app.schemas import (
UserInfo,
UserRole,
)


def _user(role: str, resellers, merchants, owner_id=None) -> UserInfo:
return UserInfo(
id="u1",
username="u1",
role=UserRole(role),
email=None,
reseller_ids=list(resellers),
merchant_ids=list(merchants),
permissions=[],
owner_id=owner_id,
)


# ── PT-23/05: constant-time exotel token + fail-closed when unset ─────────
def test_verify_exotel_token(monkeypatch):
from app.core.security import webhook_signature as ws

monkeypatch.setattr(ws, "EXOTEL_WEBHOOK_AUTH_TOKEN", "s3cret")
assert ws.verify_exotel_token("s3cret") is True
assert ws.verify_exotel_token("wrong") is False
monkeypatch.setattr(ws, "EXOTEL_WEBHOOK_AUTH_TOKEN", "")
assert ws.verify_exotel_token("s3cret") is False # fail closed when unset
Comment on lines +43 to +47
logger.error(
"EXOTEL_WEBHOOK_AUTH_TOKEN is unset — Exotel status callbacks will be "
"rejected. Set it (and keep it in sync with this deployment) or "
"Exotel outcome/retry handling will not run."
)

@Tara-ag Tara-ag left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review summary

Reviewed all 7 changed files. The PR correctly centralizes webhook signature verification and switches Exotel to a constant-time compare, which addresses PT-05/PT-23. No existing migration files were modified, and no hardcoded secrets were introduced.

New issues raised

  • 1 CRITICALEXOTEL_WEBHOOK_AUTH_TOKEN is embedded in the callback URL, exposing the secret to Exotel/proxy logs and the dashboard; the payload is also logged unredacted.
  • 3 MAJOR — path-rewriting proxy can break signature reconstruction; Twilio POST signature validation coerces UploadFile/form values; callback handlers pass raw mixed-case provider to the verifier before normalization.
  • 2 MINOR — misleading "will be rejected" log wording; unused HTTPException import.
  • 1 SUGGESTION — confirm Plivo V2 signature helper expects full URL vs. URI-only.

Existing comments noted

Two Copilot review comments were already present and are not duplicated here:

  1. Test coverage gap for Twilio/Plivo verifiers in tests/test_pentest_telephony_auth.py.
  2. Imprecise log wording in exotel.py about rejection when the token is unset.

Decision

None of the newly raised issues meet the strict <blocking-criteria> (no hardcoded secrets, no SQL injection, no auth bypass/cross-tenant access, no SSRF, no command/template injection, no PII exposure, no migration edits). However, the CRITICAL secret-in-URL concern and the MAJOR signature-correctness issues should be addressed or explicitly accepted before this ships to production.

Recommended next steps:

  1. Redact auth_token from Exotel payload/logging and document the token-in-URL trade-off.
  2. Normalize provider to lowercase before calling verify_provider_webhook in callback handlers.
  3. Verify Twilio signature reconstruction works with the actual form body/encoding and behind the production ingress path.
  4. Expand tests to cover Twilio and Plivo verifiers as suggested by the existing Copilot comment.

is explicitly disabled.
"""
url = base_url.rstrip("/") + _EXOTEL_STATUS_CALLBACK_PATH
if not EXOTEL_WEBHOOK_AUTH_TOKEN:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔒 CRITICAL: Embedding the raw EXOTEL_WEBHOOK_AUTH_TOKEN in the callback URL leaks the secret to Exotel logs, any proxy in path, and to anyone who can inspect the registered applet/dashboard config. A shared secret should be a bearer presented in a header, not a query param. Since Exotel cannot sign, consider rotating to a per-call or per-URL nonce derived via HMAC from the token + call SID, or accept that the token is single-purpose and treat it as low-entropy. At minimum, do not log this URL anywhere (the logger.info(f"Payload: {payload}") below prints it). If you must keep this design, redact auth_token from all logs.

Suggested fix: build a helper redact_query_param(url, "auth_token") and log only the redacted URL; also document that EXOTEL_WEBHOOK_AUTH_TOKEN is effectively exposed to Exotel.

) -> bool:
"""Verify a Plivo V3 (preferred) or V2 signature. Fails closed if unset."""
if not PLIVO_AUTH_TOKEN:
return False

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💬 SUGGESTION: Plivo V2 signature validation uses validate_signature(url, v2_nonce, v2_sig, PLIVO_AUTH_TOKEN). Per Plivo docs, V2 validation requires the URI without query string and the POST body for POST requests. Passing the full URL with query string may fail validation for GET requests or mixed-method setups. Verify the SDK helper accepts the full URL; if not, split the URL at ? for V2 as you do for V3.

"""Verify an X-Twilio-Signature header. Fails closed if the token is unset."""
if not TWILIO_AUTH_TOKEN or not signature:
return False
try:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⚠️ MAJOR: verify_twilio_signature passes dict(params) to RequestValidator.validate. Starlette UploadFile values in a form are not str; the earlier {k: str(v) for k, v in form.items()} converts them, but Twilio's validator expects the raw form bytes/string exactly as received. Converting uploaded files to "<UploadFile: ...>" will break signature validation for any POST that includes media, and more subtly changes encoding. Also, Twilio signs the URL for GET requests and url + sorted form params for POST; the url here already includes the query string, which is correct for GET but for POST the validator internally appends params, so passing the full URL is the documented pattern. However, you should pass the original form as a flat dict of strings without file coercion. Consider using request.body() once and handing bytes to the validator, or ensure no file fields are present in these webhooks.

Suggested fix: for Twilio POST, read await request.body() and let RequestValidator consume the raw body/params; do not coerce UploadFile objects.

shared secret supplied as the ``auth_token`` query parameter. That token has
to be embedded in the URL we register with Exotel — it is the only channel
by which Exotel can present it. When no token is configured the URL is left
bare, which the verifier rejects (fail-closed) unless signature enforcement

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💡 MINOR: The log says callbacks "will be rejected" unconditionally, but rejection only happens when ENFORCE_TELEPHONY_WEBHOOK_SIGNATURES is true. If the escape hatch is disabled, the bare URL is accepted. Rephrase to "will be rejected while enforcement is enabled" to match the actual behavior and avoid operator confusion during rollout.

- Plivo: X-Plivo-Signature-V3/V2 HMAC verification against PLIVO_AUTH_TOKEN
Both go through the shared ``verify_provider_webhook`` (fail-closed if the
provider's secret is unset).
"""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💡 MINOR: HTTPException is imported but no longer used in this file after replacing the inline auth checks with verify_provider_webhook. Remove the unused import.

)
url = str(request.url).split("?", 1)[0]
query = request.url.query
return f"{url}?{query}" if query else url

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⚠️ MAJOR: reconstruct_public_url rebuilds the signed URL using APP_BASE_URL + request.url.path + request.url.query. If the application is served behind a path-rewriting proxy (e.g., /agent/voice/... is stripped or prefixed), the reconstructed path will not match what the provider signed, causing all legitimate webhooks to be rejected. Twilio/Plivo signatures are over the exact public URL including any path prefix. You need to either (1) make the public path prefix configurable (e.g., WEBHOOK_PATH_PREFIX), or (2) document that APP_BASE_URL must include the exact externally-visible path and no rewriting may occur. Without this, a common ingress configuration will break callbacks.

Suggested fix: add an optional WEBHOOK_PUBLIC_PATH_PREFIX env var and apply it: f"{base}{WEBHOOK_PUBLIC_PATH_PREFIX}{request.url.path}".

@@ -61,6 +62,11 @@ async def handle_callback_details_get(
Raises:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⚠️ MAJOR: verify_provider_webhook is called with the raw provider string before provider.lower() is applied. If a caller uses mixed-case path like /Twilio/..., the verifier will hit the unknown-provider branch and reject a legitimate webhook. Normalize the provider string before verification, or make verify_provider_webhook case-insensitive internally.

Suggested fix: pass provider.lower() to verify_provider_webhook, or normalize at the top of verify_provider_webhook before dispatch.

@murdore
murdore force-pushed the fix/pt-telephony-signatures branch from d03fd7c to 4e7ae8c Compare August 12, 2026 07:12
@murdore

murdore commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Addressed — 4e7ae8c

Rebased onto current release (8fabb65). 1242 tests pass, pyrefly 0 errors, black/isort/autoflake clean.

Four of the seven were real. Three I could not reproduce, and rather than change code to satisfy them I pinned the existing behaviour with a test — details and evidence below.

🔒 CRITICAL — the token in logs was the live part

The secret-in-URL design is forced: Exotel has no request-signing scheme, so a query-string token is the only channel by which it can present a credential. What was genuinely wrong is that we then logged it on every outbound call:

logger.info(f"Payload: {payload}")   # payload["StatusCallback"] = ...?auth_token=<secret>

That same line also carried two phone numbers. Both are now redacted:

logger.info(
    "Payload: {}",
    {
        **payload,
        "From": mask_phone(customer_mobile_number),
        "CallerId": mask_phone(telephony_number),
        "StatusCallback": redact_query_param(payload["StatusCallback"]),
    },
)

redact_query_param() is new in webhook_signature.py — the module that owns the knowledge of which parameter is secret. It returns the input unchanged if the URL won't parse, because a logging helper must never raise. mask_phone is the existing helper from blacklisted_numbers.py, not a new one.

⚠️ MAJOR — ingress path rewriting

Real, and it would have broken every legitimate webhook behind such an ingress. Added TELEPHONY_WEBHOOK_PATH_PREFIX, applied in reconstruct_public_url.

I did not read X-Forwarded-Prefix, which was the obvious implementation. That header is caller-controlled, and it would let a caller choose part of the string that goes into the signature base — turning the verifier into a signature oracle. Config-only, and there's a test asserting the header is ignored.

⚠️ MAJOR — UploadFile coercion

Real, though the failure mode is the reverse of a bypass: str(v) on a multipart part yields <starlette.datastructures.UploadFile object at 0x…> — a Python repr, with a memory address in it — folded into the signature base. That fails verification for a request whose signed text fields are perfectly valid, and fails differently on every run. Now filtered to genuine string fields, with a warning when anything is dropped.

There is a regression test that fails when I restore str(v), so it isn't passing vacuously.

⚠️ MAJOR — provider casing: already correct

verify_provider_webhook normalises before it dispatches — this is your own suggested fix, and it was in the original diff:

provider = provider.lower()          # line 122, before every branch below
url = reconstruct_public_url(request)
if provider == "exotel": ...

Both call sites in answer/__init__.py additionally pass provider_lower. A mixed-case segment could not reach the unknown-provider branch. Pinned by test_provider_name_is_case_insensitive (ExOtEl must not raise) rather than changed.

💬 SUGGESTION — Plivo V2 URL: already correct

plivo.utils.validate_signature re-parses the URI and discards the query itself:

parsed_uri = urlparse(uri.encode('utf-8'))
base_url = urlunparse((scheme, netloc, path, '', '', ''))

Splitting at ? before calling it would be redundant. V3 is the same story in the other direction — construct_get_url merges the URI's query into the params before sorting, so the full URL is what it wants. Pinned by a test that signs a query-bearing URL and round-trips it.

💡 MINOR — unused HTTPException: still used

app/api/routers/breeze_buddy/telephony/answer/__init__.py:57 raises it for the unsupported-provider 404. autoflake runs in CI and would fail the build if it were unused.

💡 MINOR — log wording

Fixed: "will be rejected while ENFORCE_TELEPHONY_WEBHOOK_SIGNATURES is enabled (the default)". Same wording covers @copilot's identical point.

Test coverage (@copilot)

Was 1 verifier, now all three — 18 tests. Signatures are produced by the providers' own signing code (RequestValidator.compute_signature, get_signature_v3, the documented V2 HMAC), so a passing test means we verify what the provider actually sends rather than what I assumed it sends. The unused _user helper and schema imports are gone.

Covered: valid/invalid/absent signatures per provider, fail-closed on unset secret, V3 GET and POST, V2 with query string, URL reconstruction (incl. the stripped-prefix and ignored-header cases), redaction, provider casing, unknown provider, multipart, and that the escape hatch is the only thing that opens the path.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 3

🧹 Nitpick comments (2)
app/core/security/webhook_signature.py (1)

105-107: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use Loguru exception-aware logging.

Both handlers interpolate the exception object into the message string. Use logger.opt(exception=exc) instead, so the traceback is captured and the exception text is never treated as format input.

♻️ Proposed change
-    except Exception as exc:  # pragma: no cover - defensive
-        logger.warning(f"Twilio signature validation error: {exc}")
-        return False
+    except Exception as exc:  # pragma: no cover - defensive
+        logger.opt(exception=exc).warning("Twilio signature validation error")
+        return False
-    except Exception as exc:  # pragma: no cover - defensive
-        logger.warning(f"Plivo signature validation error: {exc}")
-        return False
+    except Exception as exc:  # pragma: no cover - defensive
+        logger.opt(exception=exc).warning("Plivo signature validation error")
+        return False

Based on learnings: "use Loguru's exception-aware logging in exception handlers, such as logger.opt(exception=e).error(...) ... do not interpolate exception objects directly into Loguru message strings".

Also applies to: 139-141

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/core/security/webhook_signature.py` around lines 105 - 107, Update the
exception handlers around the Twilio signature validation and the additional
handler near the second referenced block to use Loguru’s exception-aware logging
via logger.opt(exception=exc) before warning, passing a static message without
interpolating the exception object. Preserve each handler’s existing return
behavior.

Source: Learnings

app/core/config/static.py (1)

469-478: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Normalize the leading slash too.

.rstrip("/") handles a trailing slash only. If an operator sets TELEPHONY_WEBHOOK_PATH_PREFIX=agent/voice, reconstruct_public_url produces https://hostagent/voice/..., and every Twilio and Plivo webhook fails verification with no obvious cause.

♻️ Proposed normalization
-TELEPHONY_WEBHOOK_PATH_PREFIX = os.environ.get(
-    "TELEPHONY_WEBHOOK_PATH_PREFIX", ""
-).rstrip("/")
+_RAW_TELEPHONY_WEBHOOK_PATH_PREFIX = os.environ.get(
+    "TELEPHONY_WEBHOOK_PATH_PREFIX", ""
+).strip().rstrip("/")
+TELEPHONY_WEBHOOK_PATH_PREFIX = (
+    f"/{_RAW_TELEPHONY_WEBHOOK_PATH_PREFIX.lstrip('/')}"
+    if _RAW_TELEPHONY_WEBHOOK_PATH_PREFIX
+    else ""
+)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/core/config/static.py` around lines 469 - 478, Update the
TELEPHONY_WEBHOOK_PATH_PREFIX normalization so it removes trailing slashes and
ensures the configured prefix begins with exactly one leading slash, preserving
the empty-string default. This must keep reconstruct_public_url generating valid
paths for values such as agent/voice and /agent/voice.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.env.example:
- Around line 151-156: Update the TELEPHONY_WEBHOOK_PATH_PREFIX default in
.env.example to be an unquoted empty assignment, ensuring loaders pass an actual
empty value rather than literal quote characters.

In `@app/ai/voice/agents/breeze_buddy/services/telephony/exotel/exotel.py`:
- Around line 33-52: Document in _exotel_status_callback_url that it covers only
the status callback, and add rollout notes requiring the dashboard-registered
answer/voicebot URL and call-details GET URL to include the same ?auth_token=
value. In app/api/routers/breeze_buddy/telephony/answer/__init__.py lines 62-65,
confirm the configured Exotel answer URL carries auth_token before enabling
enforcement; in app/api/routers/breeze_buddy/telephony/callbacks/handlers.py
lines 65-68, confirm the call-details URL carries auth_token. No direct code
change is required at either sibling site if these dashboard configuration
confirmations are documented.

In `@app/core/security/webhook_signature.py`:
- Around line 87-91: Update verify_exotel_token to UTF-8 encode both the
provided token and EXOTEL_WEBHOOK_AUTH_TOKEN before passing them to
hmac.compare_digest, preserving the fail-closed behavior for an unset configured
token. Add a test covering a non-ASCII auth token and verify it returns False
rather than raising an exception.

---

Nitpick comments:
In `@app/core/config/static.py`:
- Around line 469-478: Update the TELEPHONY_WEBHOOK_PATH_PREFIX normalization so
it removes trailing slashes and ensures the configured prefix begins with
exactly one leading slash, preserving the empty-string default. This must keep
reconstruct_public_url generating valid paths for values such as agent/voice and
/agent/voice.

In `@app/core/security/webhook_signature.py`:
- Around line 105-107: Update the exception handlers around the Twilio signature
validation and the additional handler near the second referenced block to use
Loguru’s exception-aware logging via logger.opt(exception=exc) before warning,
passing a static message without interpolating the exception object. Preserve
each handler’s existing return behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9564c6d7-9f0a-4177-a78c-eaa933f30adb

📥 Commits

Reviewing files that changed from the base of the PR and between 8fabb65 and 4e7ae8c.

📒 Files selected for processing (7)
  • .env.example
  • app/ai/voice/agents/breeze_buddy/services/telephony/exotel/exotel.py
  • app/api/routers/breeze_buddy/telephony/answer/__init__.py
  • app/api/routers/breeze_buddy/telephony/callbacks/handlers.py
  • app/core/config/static.py
  • app/core/security/webhook_signature.py
  • tests/test_pentest_telephony_auth.py

Comment thread .env.example
Comment on lines +151 to +156
# Public path prefix an ingress strips before requests reach this app. Providers
# sign the externally-visible URL, so if https://host/agent/voice/... is proxied
# to /... internally, set this to "/agent/voice" or every legitimate webhook is
# rejected. Leave empty when no path rewriting happens. Not read from
# X-Forwarded-Prefix on purpose — that header is caller-controlled.
TELEPHONY_WEBHOOK_PATH_PREFIX=""

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Remove the quotes from the empty default.

Some loaders keep quote characters literally. Docker Compose env_file and set -a; source .env do not strip them. If the literal value "" reaches TELEPHONY_WEBHOOK_PATH_PREFIX, reconstruct_public_url builds https://host""/agent/... and every Twilio and Plivo webhook returns 401.

🔧 Proposed fix
-TELEPHONY_WEBHOOK_PATH_PREFIX=""
+TELEPHONY_WEBHOOK_PATH_PREFIX=
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Public path prefix an ingress strips before requests reach this app. Providers
# sign the externally-visible URL, so if https://host/agent/voice/... is proxied
# to /... internally, set this to "/agent/voice" or every legitimate webhook is
# rejected. Leave empty when no path rewriting happens. Not read from
# X-Forwarded-Prefix on purpose — that header is caller-controlled.
TELEPHONY_WEBHOOK_PATH_PREFIX=""
# Public path prefix an ingress strips before requests reach this app. Providers
# sign the externally-visible URL, so if https://host/agent/voice/... is proxied
# to /... internally, set this to "/agent/voice" or every legitimate webhook is
# rejected. Leave empty when no path rewriting happens. Not read from
# X-Forwarded-Prefix on purpose — that header is caller-controlled.
TELEPHONY_WEBHOOK_PATH_PREFIX=
🧰 Tools
🪛 dotenv-linter (4.0.0)

[warning] 156-156: [QuoteCharacter] The value has quote characters (', ")

(QuoteCharacter)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.env.example around lines 151 - 156, Update the
TELEPHONY_WEBHOOK_PATH_PREFIX default in .env.example to be an unquoted empty
assignment, ensuring loaders pass an actual empty value rather than literal
quote characters.

Source: Linters/SAST tools

Comment on lines +33 to +52
def _exotel_status_callback_url(base_url: str) -> str:
"""Build the Exotel status-callback URL, carrying the shared auth token.

Exotel does not sign its webhooks, so ``verify_provider_webhook`` checks a
shared secret supplied as the ``auth_token`` query parameter. That token has
to be embedded in the URL we register with Exotel — it is the only channel
by which Exotel can present it. When no token is configured the URL is left
bare, which the verifier rejects (fail-closed) unless signature enforcement
is explicitly disabled.
"""
url = base_url.rstrip("/") + _EXOTEL_STATUS_CALLBACK_PATH
if not EXOTEL_WEBHOOK_AUTH_TOKEN:
logger.error(
"EXOTEL_WEBHOOK_AUTH_TOKEN is unset — Exotel status callbacks will be "
"rejected while ENFORCE_TELEPHONY_WEBHOOK_SIGNATURES is enabled (the "
"default). Set it (and keep it in sync with this deployment) or "
"Exotel outcome/retry handling will not run."
)
return url
return f"{url}?auth_token={quote(EXOTEL_WEBHOOK_AUTH_TOKEN, safe='')}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Exotel auth_token coverage is incomplete across the three Exotel webhook entry points. This repository generates only the StatusCallback URL with auth_token, but enforcement now applies to all Exotel webhook routes. The Exotel answer URL and the call-details GET URL are registered in the Exotel applet dashboard, so they will return 401 unless the dashboard configuration is updated in the same rollout.

  • app/ai/voice/agents/breeze_buddy/services/telephony/exotel/exotel.py#L33-L52: document that _exotel_status_callback_url covers only the status callback, and record the required ?auth_token= suffix for the dashboard-registered answer and call-details URLs in the rollout notes.
  • app/api/routers/breeze_buddy/telephony/answer/__init__.py#L62-L65: confirm the Exotel applet answer/voicebot URL carries auth_token before enabling ENFORCE_TELEPHONY_WEBHOOK_SIGNATURES, otherwise every inbound Exotel call fails.
  • app/api/routers/breeze_buddy/telephony/callbacks/handlers.py#L65-L68: confirm the Exotel call-details GET URL carries auth_token, otherwise recording updates stop.
📍 Affects 3 files
  • app/ai/voice/agents/breeze_buddy/services/telephony/exotel/exotel.py#L33-L52 (this comment)
  • app/api/routers/breeze_buddy/telephony/answer/__init__.py#L62-L65
  • app/api/routers/breeze_buddy/telephony/callbacks/handlers.py#L65-L68
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/ai/voice/agents/breeze_buddy/services/telephony/exotel/exotel.py` around
lines 33 - 52, Document in _exotel_status_callback_url that it covers only the
status callback, and add rollout notes requiring the dashboard-registered
answer/voicebot URL and call-details GET URL to include the same ?auth_token=
value. In app/api/routers/breeze_buddy/telephony/answer/__init__.py lines 62-65,
confirm the configured Exotel answer URL carries auth_token before enabling
enforcement; in app/api/routers/breeze_buddy/telephony/callbacks/handlers.py
lines 65-68, confirm the call-details URL carries auth_token. No direct code
change is required at either sibling site if these dashboard configuration
confirmations are documented.

Comment on lines +87 to +91
def verify_exotel_token(token: Optional[str]) -> bool:
"""Constant-time compare of the Exotel webhook token. Fails closed if unset."""
if not EXOTEL_WEBHOOK_AUTH_TOKEN:
return False
return hmac.compare_digest(token or "", EXOTEL_WEBHOOK_AUTH_TOKEN)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Python hmac.compare_digest TypeError non-ASCII str arguments

💡 Result:

The hmac.compare_digest function in Python is designed to perform constant-time comparisons to mitigate timing attacks during cryptographic verification [1][2]. According to the official Python documentation, when using string (str) arguments, the function specifically requires that they be ASCII-only [1][2]. If you provide a string containing non-ASCII characters, the function will raise a TypeError [3][4]. This restriction exists because the function is primarily intended to compare digests (such as those produced by HMAC.hexdigest), which are inherently ASCII-based [2][4]. To resolve this issue, you must ensure that the arguments passed to hmac.compare_digest are either: 1. Bytes-like objects (recommended for cryptographic operations) [1][2]. 2. ASCII-only strings [1][2]. If you are dealing with data that contains non-ASCII characters, you should encode the data into bytes (e.g., using.encode('utf-8')) before passing it to the function [4]. This approach ensures compatibility with the security-sensitive nature of the comparison while adhering to the function's requirements [4].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- relevant files ---'
git ls-files 'app/core/security/webhook_signature.py' 'app/core/config/static.py' 'tests/test_pentest_telephony_auth.py'
printf '%s\n' '--- webhook module outline ---'
ast-grep outline app/core/security/webhook_signature.py
printf '%s\n' '--- webhook implementation ---'
cat -n app/core/security/webhook_signature.py
printf '%s\n' '--- related symbols and route callers ---'
rg -n -C 4 'verify_exotel_token|verify_provider_webhook|EXOTEL_WEBHOOK_AUTH_TOKEN|auth_token' app tests
printf '%s\n' '--- relevant configuration ---'
rg -n -C 3 'EXOTEL_WEBHOOK_AUTH_TOKEN' app/core/config/static.py

Repository: juspay/clairvoyance

Length of output: 39634


🏁 Script executed:

#!/bin/bash
set -eu
python3 - <<'PY'
import hmac
from urllib.parse import parse_qsl

secret = "s3cret"
queries = [
    "?auth_token=s3cret",
    "?auth_token=wrong",
    "?auth_token=%C3%A9",
    "?auth_token=%E2%82%AC",
]
for query in queries:
    token = dict(parse_qsl(query.lstrip("?"), keep_blank_values=True)).get("auth_token")
    try:
        result = hmac.compare_digest(token or "", secret)
        outcome = f"returns {result!r}"
    except Exception as exc:
        outcome = f"raises {type(exc).__name__}: {exc}"
    print(f"{query} -> token={token!r}; {outcome}")

print("bytes comparison:")
for token in ["é", "€", "s3cret"]:
    print(
        token,
        hmac.compare_digest(token.encode("utf-8"), secret.encode("utf-8")),
    )
PY

Repository: juspay/clairvoyance

Length of output: 531


Use byte operands for the constant-time comparison. A non-ASCII auth_token causes hmac.compare_digest to raise TypeError, so the uncaught exception returns 500 instead of 401. Encode both operands as UTF-8 and add a test for this case.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/core/security/webhook_signature.py` around lines 87 - 91, Update
verify_exotel_token to UTF-8 encode both the provided token and
EXOTEL_WEBHOOK_AUTH_TOKEN before passing them to hmac.compare_digest, preserving
the fail-closed behavior for an unset configured token. Add a test covering a
non-ASCII auth token and verify it returns False rather than raising an
exception.

@Tara-ag Tara-ag left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review summary

Reviewed all 7 changed files. The PR correctly centralizes webhook signature verification and switches Exotel to a constant-time compare, which addresses PT-05/PT-23. No existing migration files were modified, and no hardcoded secrets were introduced.

New issues raised

  • 1 CRITICALEXOTEL_WEBHOOK_AUTH_TOKEN is embedded in the callback URL, exposing the secret to Exotel/proxy logs and the dashboard; the payload is also logged unredacted.
  • 3 MAJOR — path-rewriting proxy can break signature reconstruction; Twilio POST signature validation coerces UploadFile/form values; callback handlers pass raw mixed-case provider to the verifier before normalization.
  • 2 MINOR — misleading "will be rejected" log wording; unused HTTPException import.
  • 1 SUGGESTION — confirm Plivo V2 signature helper expects full URL vs. URI-only.

Existing comments noted

Two Copilot review comments were already present and are not duplicated here:

  1. Test coverage gap for Twilio/Plivo verifiers in tests/test_pentest_telephony_auth.py.
  2. Imprecise log wording in exotel.py about rejection when the token is unset.

Decision

None of the newly raised issues meet the strict <blocking-criteria> (no hardcoded secrets, no SQL injection, no auth bypass/cross-tenant access, no SSRF, no command/template injection, no PII exposure, no migration edits). However, the CRITICAL secret-in-URL concern and the MAJOR signature-correctness issues should be addressed or explicitly accepted before this ships to production.

Recommended next steps:

  1. Redact auth_token from Exotel payload/logging and document the token-in-URL trade-off.
  2. Normalize provider to lowercase before calling verify_provider_webhook in callback handlers.
  3. Verify Twilio signature reconstruction works with the actual form body/encoding and behind the production ingress path.
  4. Expand tests to cover Twilio and Plivo verifiers as suggested by the existing Copilot comment.

@murdore
murdore force-pushed the fix/pt-telephony-signatures branch from 4e7ae8c to 5fc76f7 Compare August 14, 2026 03:22

@Tara-ag Tara-ag left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review summary

Re-reviewed all 7 changed files at 5fc76f7. No new blocking issues were identified in this pass.

New issues raised this run

  • 0 (none)

Existing comments noted (not duplicated)

Prior reviews already raised the following points; they remain visible on the PR and should be tracked to resolution before production:

  • CRITICALEXOTEL_WEBHOOK_AUTH_TOKEN embedded in the Exotel status-callback URL leaks the secret to Exotel/proxy logs and dashboard config.
  • MAJORTELEPHONY_WEBHOOK_PATH_PREFIX / APP_BASE_URL mismatch can break signature reconstruction behind path-rewriting ingress.
  • MAJOR (now addressed in code) — Twilio/Plivo form handling previously coerced UploadFile values; current code filters to str fields only.
  • MAJOR (now addressed in code) — verify_provider_webhook normalizes provider to lowercase internally, so mixed-case path segments no longer fall through to unknown-provider rejection.
  • CodeRabbit items: quoted empty default in .env.example, hmac.compare_digest non-ASCII operand handling, and Exotel auth_token coverage across all dashboard-registered URLs.

Decision

No new issues meet the <blocking-criteria> (no hardcoded secrets, no SQL injection, no auth bypass/cross-tenant access, no SSRF, no command/template injection, no PII exposure, no migration edits). Approving so the pentest fix can land; please resolve the outstanding CRITICAL secret-in-URL concern and MAJOR ingress-path concern before enabling enforcement in production.

…5/23)

Independent of the other pentest PRs — targets release, merges in any order.

The callback and answer routes accepted any caller: anyone who knew a URL could
drive call state. Verified on release — unsigned POSTs to the twilio, plivo and
exotel status callbacks are all accepted.

verify_provider_webhook (new) validates Twilio X-Twilio-Signature, Plivo V3
signatures and the Exotel auth token, and is applied to every callback and
answer route. Token comparison is constant-time and fails CLOSED when the
expected token is unset, so a missing env var denies rather than admits.

Enforcement is on by default via ENFORCE_TELEPHONY_WEBHOOK_SIGNATURES. It exists
to avoid a hard outage during rollout, not as a supported posture.

PT-12 (recording host pinning) is NOT here: its fix is built on the SSRF egress
guard, so it ships in that PR instead of forcing a dependency between the two.

932 tests pass on this branch.
@murdore
murdore force-pushed the fix/pt-telephony-signatures branch from 5fc76f7 to 72fbb2a Compare August 17, 2026 07:22

@Tara-ag Tara-ag left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review summary

Re-reviewed the 7 changed files at HEAD (72fbb2a). No new blocking issues were identified in this pass.

New issues raised this run

  • 0 (none)

Existing comments noted (not duplicated)

Prior reviews already raised the following points; they remain visible on the PR and should be tracked to resolution before production:

  • CRITICALEXOTEL_WEBHOOK_AUTH_TOKEN embedded in the Exotel status-callback URL leaks the secret to Exotel/proxy logs and dashboard config.
  • MAJORTELEPHONY_WEBHOOK_PATH_PREFIX / APP_BASE_URL mismatch can break signature reconstruction behind path-rewriting ingress.
  • MAJOR (addressed in code) — verify_provider_webhook normalizes provider to lowercase internally, so mixed-case path segments no longer fall through to unknown-provider rejection.
  • CodeRabbit items: quoted empty default in .env.example, hmac.compare_digest non-ASCII operand handling, and Exotel auth_token coverage across all dashboard-registered URLs.

Decision

No new issues meet the <blocking-criteria> (no hardcoded secrets, no SQL injection, no auth bypass/cross-tenant access, no SSRF, no command/template injection, no PII exposure, no migration edits). Approving so the pentest fix can land; please resolve the outstanding CRITICAL secret-in-URL concern and MAJOR ingress-path concern before enabling enforcement in production.

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.

3 participants