Skip to content

fix(security): remediate Breeze Buddy pentest findings (BB-DEEPDIVE-2026-001) - #930

Closed
murdore wants to merge 1 commit into
releasefrom
fix/pentest-clairvoyance-2026-001
Closed

fix(security): remediate Breeze Buddy pentest findings (BB-DEEPDIVE-2026-001)#930
murdore wants to merge 1 commit into
releasefrom
fix/pentest-clairvoyance-2026-001

Conversation

@murdore

@murdore murdore commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Breeze Buddy pentest remediation — backend (Clairvoyance)

White-box pentest BB-DEEPDIVE-2026-001 (25 findings; 22 backend, 3 frontend). This PR closes the 22 backend findings. The 3 frontend findings (PT-06/10/25) land in the companion loom PR.

Based on release. Single commit (CI-enforced). New shared modules: app/core/security/{ssrf,webhook_signature,token_revocation,password_policy}.py.

Findings → fix

ID Sev Fix
PT-01 Critical python_code compiled under RestrictedPython (blocks __class__/__subclasses__ dunder-traversal escape at compile time) and gated OFF by default via ENABLE_CUSTOM_PYTHON_FUNCTIONS at flow-build.
PT-02 Critical Query-param template lookup scoped to the dialed outbound number — an unauth media WebSocket can no longer build an arbitrary template UUID. (Combined with PT-01 + PT-09 the unauth→RCE chain is broken; a WS connect-token auth dependency is the recommended follow-up.)
PT-03 Critical MCP server URL run through the shared SSRF egress guard before any credential header is built or the build-time discovery fires.
PT-04 Critical Already remediated on release (legacy dashboard login removed in 052f600); regression guard added.
PT-05 Critical Provider signature/token verification on recording-details webhooks + recording downloads host-pinned to the provider's own domains (master creds never leave Twilio/Plivo/Exotel).
PT-07 High HttpRequestExecutor uses the shared SSRF guard (resolves DNS, blocks internal/metadata) with per-hop redirect revalidation.
PT-08 High Merchant user-create branch now validates reseller_ids against the creator's scope.
PT-09 High Template create/update authorize on merchant scope (validate_template_access), incl. destination-merchant re-check on move.
PT-11 Medium send_webhook_with_retry validates the URL (fail-closed, zero attempts) + redirect-safe fetch.
PT-12 Medium Signature verification on status/transfer/answer callbacks; exotel dial-up agent-number leak closed.
PT-13 Medium Real RBAC filtering + ownership check on /numbers.
PT-14/15 Medium Null merchant_id = reseller-scoped → deny for merchant/user roles across templates/configurations/leads/chat/numbers RBAC (shared merchant_scope_permitted).
PT-16 Medium /auth/accounts email branch requires password proof; generic 401 removes the enumeration oracle.
PT-17 Medium LLM-sourced values rejected in URL host position; HTTP-tool SSRF closed via the shared guard.
PT-18 Medium Cross-IP aggregate rate cap per public_widget_key.
PT-19 Medium Chat-demo client IP taken from the trusted last XFF hop.
PT-20 Medium Scope resolution fails closed (deny) on a broken owner chain instead of granting wildcard.
PT-21 Medium S2S token_lifetime_days capped at 365.
PT-22 Medium Server-side JWT revocation denylist + per-request is_active liveness recheck; logout revokes.
PT-23 Low Plivo answer now signature-verified; Exotel token compared constant-time (shared verifier).
PT-24 Low Password policy: length 12, complexity, common/identifier deny-list, bcrypt 72-byte guard (fail-closed in the hasher).

Verification

  • black --check, isort --check, autoflake, pyrefly check — clean.
  • uv run pytest680 passed (2 pre-existing test_chat_analytics failures are unrelated to this PR — they fail on release too). New tests: test_ssrf_egress.py, test_custom_python_sandbox.py, test_pentest_authz.py.

⚠️ Operational rollout notes

  • ENFORCE_TELEPHONY_WEBHOOK_SIGNATURES=true (default). Requires TWILIO_AUTH_TOKEN / PLIVO_AUTH_TOKEN / EXOTEL_WEBHOOK_AUTH_TOKEN and a correct APP_BASE_URL per provider in every env, or those webhooks will 401. A temporary escape hatch exists (set to false) but must not be used in prod.
  • ENABLE_CUSTOM_PYTHON_FUNCTIONS=false (default) — any template relying on custom python_code will have that function skipped until an operator opts in.
  • SSRF_ALLOW_PRIVATE_EGRESS=false (default) — set true only for local dev against localhost backends.

Follow-ups (documented, out of scope for this PR)

  • PT-02: add a minted WS connect-token auth dependency (touches Smart-Router/Twilio URL construction; needs careful rollout).
  • PT-17: force-gate credential-bearing tools behind HITL approval in anonymous demo/widget sessions.
  • PT-01: out-of-process sandbox for custom python (process isolation, rlimits, no-egress).

Summary by CodeRabbit

  • Security Enhancements

    • Added SSRF protection for outbound requests, redirects, webhooks, MCP connections, and telephony recordings.
    • Enabled telephony webhook signature verification by default.
    • Added server-side JWT revocation and immediate blocking for inactive accounts.
    • Strengthened role-based access controls across merchants, resellers, templates, leads, configurations, and phone numbers.
    • Disabled custom Python functions by default and hardened sandbox execution.
  • Account & Password Improvements

    • Enforced stronger password requirements and capped S2S token lifetimes.
    • Improved account-listing verification to reduce unauthorized discovery.
  • Testing

    • Added coverage for security, authorization, sandboxing, and SSRF protections.

Copilot AI review requested due to automatic review settings July 20, 2026 20:25

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 implements a broad set of backend security remediations for Breeze Buddy based on pentest BB-DEEPDIVE-2026-001, introducing shared security modules (SSRF egress guard, webhook signature verification, token revocation, password policy) and wiring them through RBAC, webhook, telephony, MCP, and template execution paths.

Changes:

  • Adds shared security primitives (SSRF egress validation + redirect revalidation, telephony webhook verification, JWT revocation denylist, password policy enforcement).
  • Tightens authorization and scope handling across templates/configurations/leads/chat/numbers and fixes “fail-open” scope resolution behavior to fail-closed.
  • Adds targeted tests covering SSRF, sandboxed custom python, RBAC/scope fixes, and updates existing MCP approval tests to bypass SSRF during non-network logic.

Reviewed changes

Copilot reviewed 49 out of 50 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/test_ssrf_egress.py Adds coverage for SSRF egress URL validation and allowlist host matching.
tests/test_pentest_authz.py Adds regression tests for authorization/scope/token/password fixes from the pentest.
tests/test_mcp_approval.py Updates MCP approval tests to bypass SSRF validation for placeholder hosts.
tests/test_custom_python_sandbox.py Adds tests proving RestrictedPython sandboxing + kill-switch behavior for custom python_code.
pyproject.toml Adds restrictedpython dependency for sandbox compilation.
app/schemas/breeze_buddy/users.py Enforces shared password policy on user create/update schemas.
app/schemas/breeze_buddy/signup.py Enforces password policy in signup schema and requires password for email-based account listing.
app/schemas/breeze_buddy/auth.py Caps S2S token lifetime to 365 days.
app/database/accessor/breeze_buddy/users.py Adds short-lived Redis cache for per-request user liveness checks + invalidation hooks.
app/core/security/webhook_signature.py New shared provider webhook authentication (Twilio/Plivo signatures, Exotel token).
app/core/security/token_revocation.py New Redis-backed JWT revocation denylist keyed by SHA-256 of the token.
app/core/security/ssrf.py New shared SSRF egress guard + redirect-safe aiohttp request helper.
app/core/security/scope.py Changes wildcard scope resolution to fail closed on broken owner chains.
app/core/security/password.py Enforces bcrypt 72-byte limit as a hard failure (UTF-8 byte length).
app/core/security/password_policy.py New centralized password strength policy (length/diversity/common/identifier denylist).
app/core/security/authorization.py Introduces merchant_scope_permitted helper to correctly handle null-merchant reseller-scoped rows.
app/core/config/static.py Adds feature flags for telephony webhook enforcement and custom python enablement.
app/api/security/breeze_buddy/rbac_token.py Makes token verification async; adds revocation + user liveness enforcement.
app/api/routers/feature_flags/rbac.py Awaits async RBAC token verification.
app/api/routers/breeze_buddy/widget_common.py Adds cross-IP aggregate rate limiting per public_widget_key.
app/api/routers/breeze_buddy/webhooks/woocommerce/services.py Awaits async RBAC token verification for webhook token validation.
app/api/routers/breeze_buddy/webhooks/breeze/services.py Awaits async RBAC token verification for webhook token validation.
app/api/routers/breeze_buddy/users/handlers.py Validates merchant-created users’ reseller_ids against creator scope (prevents escalation).
app/api/routers/breeze_buddy/templates/rbac.py Enforces merchant scope (including null merchant reseller-scoped templates) via shared helper.
app/api/routers/breeze_buddy/templates/handlers.py Revalidates authz on any (reseller, merchant) ownership move.
app/api/routers/breeze_buddy/templates/init.py Switches template-create authorization to validate_template_access (reseller + merchant scope).
app/api/routers/breeze_buddy/telephony/callbacks/handlers.py Adds provider webhook authentication across callback endpoints.
app/api/routers/breeze_buddy/telephony/answer/init.py Adds provider webhook authentication for answer webhooks (Exotel/Plivo).
app/api/routers/breeze_buddy/signup/handlers.py Requires password proof for email-based account listing; returns generic 401 to reduce enumeration.
app/api/routers/breeze_buddy/signup/init.py Plumbs password through to account listing handler.
app/api/routers/breeze_buddy/numbers/rbac.py Adds ownership validation and real RBAC filtering for numbers.
app/api/routers/breeze_buddy/numbers/handlers.py Enforces number ownership checks on read-by-id (prevents IDOR).
app/api/routers/breeze_buddy/leads/rbac.py Enforces correct handling of null merchant_id (reseller-scoped) leads/recordings.
app/api/routers/breeze_buddy/configurations/rbac.py Enforces correct handling of null merchant_id (reseller-scoped) configurations.
app/api/routers/breeze_buddy/chat/rbac.py Enforces correct handling of null merchant_id (reseller-scoped) chat sessions.
app/api/routers/breeze_buddy/chat/demo.py Switches demo IP extraction to shared client_ip implementation (trusted last XFF hop).
app/api/routers/breeze_buddy/auth/handlers.py Implements server-side logout by revoking the presented JWT.
app/api/routers/breeze_buddy/auth/init.py Requires auth header on logout and passes token to revocation logout handler.
app/ai/voice/agents/breeze_buddy/utils/parser.py Replaces AST denylist with RestrictedPython compilation/execution.
app/ai/voice/agents/breeze_buddy/utils/common.py Adds SSRF validation + redirect-safe posting for outbound webhooks.
app/ai/voice/agents/breeze_buddy/template/global_function.py Gates custom python global functions behind ENABLE_CUSTOM_PYTHON_FUNCTIONS.
app/ai/voice/agents/breeze_buddy/services/telephony/twilio/recording.py Uses SSRF-safe request + host allowlist before sending Twilio credentials.
app/ai/voice/agents/breeze_buddy/services/telephony/plivo/recording.py Uses SSRF-safe request + host allowlist before sending Plivo credentials.
app/ai/voice/agents/breeze_buddy/services/telephony/exotel/recording.py Uses SSRF-safe request + host allowlist before sending Exotel credentials.
app/ai/voice/agents/breeze_buddy/mcp/init.py Adds SSRF egress validation for MCP server URL before building credential headers.
app/ai/voice/agents/breeze_buddy/handlers/transport/http_requester.py Replaces bespoke URL checks with shared SSRF guard + redirect revalidation.
app/ai/voice/agents/breeze_buddy/handlers/transport/http_handler.py Blocks LLM-sourced fields from being used in URL host position.
app/ai/voice/agents/breeze_buddy/agent/inbound.py Scopes template selection by dialed number to break unauth WebSocket template-id abuse chain.
.env.example Documents new security flags and SSRF dev escape hatch.

Comment thread app/core/security/webhook_signature.py
Comment thread app/core/security/webhook_signature.py
Comment thread app/core/security/ssrf.py Outdated

@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

Files reviewed: 50 changed files (focused on security-critical additions and modifications).
New issues raised this run: 2

  • ⚠️ MAJOR × 1: app/core/security/ssrf.py reads SSRF_ALLOW_PRIVATE_EGRESS directly via os.getenv at import time instead of centralizing the env read in app/core/config/static.py (project convention). Suggested moving the flag to static.py and importing it.
  • 💡 MINOR × 1: app/core/security/webhook_signature.py reconstruct_public_url() appends the raw query string; this overlaps with the existing copilot review comment and should be aligned with provider library expectations.

Existing comments respected: The copilot reviewer's points on webhook_signature.py (query-string inclusion and missing query params in signature verification) were already raised; I did not duplicate them.

Blocking criteria check: No new hardcoded secrets, SQL injections, auth bypasses, SSRF/PII exposures, or migration edits were introduced. The MAJOR issue is a configuration-discipline concern, not a blocking security vulnerability.

Decision: Approve. The pentest remediation is comprehensive and the security fixes (SSRF guard, webhook signature verification, RBAC hardening, token revocation, password policy, custom-python kill switch) are well-scoped. Please address the configuration-centralization comment in a follow-up.

Comment thread app/core/security/ssrf.py Outdated
Comment thread app/core/security/webhook_signature.py
@murdore
murdore force-pushed the fix/pentest-clairvoyance-2026-001 branch from a5708a7 to dd7c6b6 Compare July 21, 2026 05:06
@coderabbitai

coderabbitai Bot commented Jul 21, 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: fa891a9e-65b2-4405-b629-59021f49c2ef

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

This PR adds Breeze Buddy security hardening across SSRF-protected outbound requests, telephony webhook verification, JWT revocation, RBAC scope checks, password validation, custom Python sandboxing, account verification, and aggregate widget rate limiting.

Changes

Security hardening

Layer / File(s) Summary
SSRF-safe outbound requests
app/core/security/ssrf.py, app/ai/voice/agents/breeze_buddy/handlers/transport/*, .../mcp/*, .../telephony/*/recording.py, .../utils/common.py, tests/test_ssrf_egress.py
Outbound URLs and redirect hops are validated, private destinations are blocked, and credentials are restricted to approved telephony hosts.
Custom Python execution controls
app/ai/voice/agents/breeze_buddy/utils/parser.py, .../template/global_function.py, tests/test_custom_python_sandbox.py, pyproject.toml
Custom functions use RestrictedPython and are disabled by default.
Telephony webhook verification
app/core/security/webhook_signature.py, app/api/routers/breeze_buddy/telephony/*, .env.example
Exotel, Twilio, and Plivo requests require provider-specific verification before processing.
JWT revocation and liveness
app/core/security/token_revocation.py, app/core/security/breeze_buddy/rbac_token.py, app/api/routers/breeze_buddy/auth/*, app/database/accessor/breeze_buddy/users.py
Logout revokes JWTs, while RBAC verification checks revocation and user activity.
Tenant and merchant scope enforcement
app/core/security/{authorization.py,scope.py}, app/api/routers/breeze_buddy/{chat,configurations,leads,numbers,templates,users}/*, app/ai/voice/agents/breeze_buddy/agent/inbound.py
Merchant, reseller, number, template, lead, and inbound-template access checks are tightened and fail closed.
Password and account verification
app/core/security/{password.py,password_policy.py}, app/schemas/breeze_buddy/*, app/api/routers/breeze_buddy/signup/*, tests/test_pentest_authz.py
Password strength, byte limits, account-listing proof, and S2S token lifetime constraints are enforced.
Aggregate widget rate limiting
app/api/routers/breeze_buddy/widget_common.py, app/api/routers/breeze_buddy/chat/demo.py
Widget traffic is capped across source IPs using Redis-backed aggregate limits.

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

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant WebhookRouter
  participant WebhookVerifier
  participant ProviderHandler
  Caller->>WebhookRouter: submit telephony webhook
  WebhookRouter->>WebhookVerifier: verify provider signature
  WebhookVerifier-->>WebhookRouter: verification result
  WebhookRouter->>ProviderHandler: dispatch verified request
  ProviderHandler-->>Caller: response or fallback TwiML
Loading

Possibly related PRs

Suggested reviewers: swaroopvarma2359

Poem

A rabbit guards the webhook gate,
While private paths meet frosty fate.
Tokens sleep in Redis deep,
Strong passwords their secrets keep.
Scope and sandbox, neat and bright—
Hop, hop, security takes flight!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.69% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the PR’s main change: remediating Breeze Buddy security findings from the named pentest.
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 unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/pentest-clairvoyance-2026-001

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.

@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: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/api/routers/breeze_buddy/auth/__init__.py (1)

158-193: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Docstring now contradicts the implemented behavior. The body still asserts the backend cannot invalidate the token and that the "Token remains valid until expiration but client discards it," which is stale now that logout revokes the token server-side via the denylist. Trim the client-only narrative so it matches the revocation semantics captured in the updated Note.

🤖 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/api/routers/breeze_buddy/auth/__init__.py` around lines 158 - 193, Update
the logout endpoint docstring above logout_handler to remove the stale
client-only claims that the backend cannot invalidate tokens and that tokens
remain valid until expiration. Retain concise client cleanup guidance and
document that logout adds the token to the server-side revocation denylist,
preventing further use before expiry.
🧹 Nitpick comments (1)
tests/test_custom_python_sandbox.py (1)

37-46: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a handler test that mutates state to guard against the missing RestrictedPython guards.

test_legitimate_handler_compiles_and_runs only returns a dict literal and uses sum(), so it passes even without _write_/_inplacevar_/_print_. A handler doing e.g. out = {}; out['k'] = 1; total = 0; total += args['n'] would surface the missing guards flagged in app/ai/voice/agents/breeze_buddy/utils/parser.py. Adding such a case locks in correct runtime behavior once the guards are added.

🤖 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 `@tests/test_custom_python_sandbox.py` around lines 37 - 46, Add a
state-mutating handler case to test_legitimate_handler_compiles_and_runs,
including dictionary item assignment and augmented assignment, then assert
compile_custom_function executes it successfully with the expected mutated
values. Ensure the test exercises the RestrictedPython _write_, _inplacevar_,
and _print_ guard requirements without changing the existing legitimate-handler
coverage.
🤖 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 `@app/ai/voice/agents/breeze_buddy/agent/inbound.py`:
- Around line 168-184: Update the inbound flow authorization in
handle_inbound_call so a missing to_number fails closed before the template
association check, matching the existing missing-number behavior. Ensure
template flow construction cannot proceed when all to/to_number sources are
absent, while preserving the current outbound-number authorization for present
numbers.

In `@app/ai/voice/agents/breeze_buddy/utils/parser.py`:
- Around line 83-93: Update _build_restricted_namespace() to inject the missing
RestrictedPython handlers _write_, _inplacevar_, and _print_ alongside the
existing guarded hooks. Reuse the project’s established restricted handler
implementations so assignments, augmented assignments, and print output execute
successfully.

In `@app/api/routers/breeze_buddy/leads/rbac.py`:
- Around line 106-108: Remove the unnecessary f-string prefix from the
`HTTPException` detail in the lead-not-found branch, keeping the existing `"Lead
not found"` message and 404 status unchanged.

In `@app/api/routers/breeze_buddy/signup/handlers.py`:
- Around line 426-440: Update the password verification flow in the
email/password branch to always perform at least one bcrypt verification, using
the established dummy hash when users is empty or a user’s password_hash is
missing. Keep active-user matching and the generic 401 response unchanged, and
ensure SSO users without passwords cannot trigger a 500.

In `@app/core/security/ssrf.py`:
- Around line 127-135: Restructure the IP-literal handling in the validation
flow so only ipaddress.ip_address(hostname) is caught for ValueError; perform
ip_block_reason(hostname), raise SSRFError when blocked, and return the literal
outside that try/except. Keep non-literal hosts falling through to
_resolve_host(hostname).

---

Outside diff comments:
In `@app/api/routers/breeze_buddy/auth/__init__.py`:
- Around line 158-193: Update the logout endpoint docstring above logout_handler
to remove the stale client-only claims that the backend cannot invalidate tokens
and that tokens remain valid until expiration. Retain concise client cleanup
guidance and document that logout adds the token to the server-side revocation
denylist, preventing further use before expiry.

---

Nitpick comments:
In `@tests/test_custom_python_sandbox.py`:
- Around line 37-46: Add a state-mutating handler case to
test_legitimate_handler_compiles_and_runs, including dictionary item assignment
and augmented assignment, then assert compile_custom_function executes it
successfully with the expected mutated values. Ensure the test exercises the
RestrictedPython _write_, _inplacevar_, and _print_ guard requirements without
changing the existing legitimate-handler coverage.
🪄 Autofix (Beta)

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

Run ID: 139558ee-779d-4388-86e4-8e45884c1420

📥 Commits

Reviewing files that changed from the base of the PR and between 28122a9 and dd7c6b6.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (49)
  • .env.example
  • app/ai/voice/agents/breeze_buddy/agent/inbound.py
  • app/ai/voice/agents/breeze_buddy/handlers/transport/http_handler.py
  • app/ai/voice/agents/breeze_buddy/handlers/transport/http_requester.py
  • app/ai/voice/agents/breeze_buddy/mcp/__init__.py
  • app/ai/voice/agents/breeze_buddy/services/telephony/exotel/recording.py
  • app/ai/voice/agents/breeze_buddy/services/telephony/plivo/recording.py
  • app/ai/voice/agents/breeze_buddy/services/telephony/twilio/recording.py
  • app/ai/voice/agents/breeze_buddy/template/global_function.py
  • app/ai/voice/agents/breeze_buddy/utils/common.py
  • app/ai/voice/agents/breeze_buddy/utils/parser.py
  • app/api/routers/breeze_buddy/auth/__init__.py
  • app/api/routers/breeze_buddy/auth/handlers.py
  • app/api/routers/breeze_buddy/chat/demo.py
  • app/api/routers/breeze_buddy/chat/rbac.py
  • app/api/routers/breeze_buddy/configurations/rbac.py
  • app/api/routers/breeze_buddy/leads/rbac.py
  • app/api/routers/breeze_buddy/numbers/handlers.py
  • app/api/routers/breeze_buddy/numbers/rbac.py
  • app/api/routers/breeze_buddy/signup/__init__.py
  • app/api/routers/breeze_buddy/signup/handlers.py
  • app/api/routers/breeze_buddy/telephony/answer/__init__.py
  • app/api/routers/breeze_buddy/telephony/callbacks/handlers.py
  • app/api/routers/breeze_buddy/templates/__init__.py
  • app/api/routers/breeze_buddy/templates/handlers.py
  • app/api/routers/breeze_buddy/templates/rbac.py
  • app/api/routers/breeze_buddy/users/handlers.py
  • app/api/routers/breeze_buddy/webhooks/breeze/services.py
  • app/api/routers/breeze_buddy/webhooks/woocommerce/services.py
  • app/api/routers/breeze_buddy/widget_common.py
  • app/api/routers/feature_flags/rbac.py
  • app/api/security/breeze_buddy/rbac_token.py
  • app/core/config/static.py
  • app/core/security/authorization.py
  • app/core/security/password.py
  • app/core/security/password_policy.py
  • app/core/security/scope.py
  • app/core/security/ssrf.py
  • app/core/security/token_revocation.py
  • app/core/security/webhook_signature.py
  • app/database/accessor/breeze_buddy/users.py
  • app/schemas/breeze_buddy/auth.py
  • app/schemas/breeze_buddy/signup.py
  • app/schemas/breeze_buddy/users.py
  • pyproject.toml
  • tests/test_custom_python_sandbox.py
  • tests/test_mcp_approval.py
  • tests/test_pentest_authz.py
  • tests/test_ssrf_egress.py

Comment thread app/ai/voice/agents/breeze_buddy/agent/inbound.py
Comment thread app/ai/voice/agents/breeze_buddy/utils/parser.py
Comment thread app/api/routers/breeze_buddy/leads/rbac.py
Comment thread app/api/routers/breeze_buddy/signup/handlers.py
Comment thread app/core/security/ssrf.py Outdated
@murdore
murdore force-pushed the fix/pentest-clairvoyance-2026-001 branch from dd7c6b6 to 492d5b4 Compare July 21, 2026 05:52

@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

Files reviewed: 50 changed files (security, authz, telephony, MCP, widget, schemas, tests, config).

New issues raised this run: 0 blocking, 0 major, 0 minor, 0 suggestions.

Existing comments noted: Several points were already raised by prior reviewers (Copilot/CodeRabbit/Tara-ag) and addressed by the author in 492d5b4, including:

  • Fail-closed PT-02 guard when to_number is absent.
  • Missing RestrictedPython hooks (_write_, _inplacevar_, _print_).
  • Constant-time bcrypt work for account-list anti-enumeration.
  • SSRF IP-literal short-circuit fix.
  • Redundant f-string cleanup.

Blocking criteria assessment: No new blocking criteria are met. No hardcoded secrets, no SQL injection, no authz bypass, no SSRF bypass, no PII exposure, and no existing migration files were modified.

Key observations (non-blocking):

  • The async refactor of verify_rbac_token and _build_server_params is consistently awaited at all call sites in this PR.
  • New SSRF guard (app/core/security/ssrf.py) is sound: resolves DNS before validating, blocks internal/metadata, re-validates redirect hops, and strips credentials off allow-list redirects.
  • Webhook signature module fails closed when provider secrets are unset and uses constant-time comparison for Exotel.
  • JWT revocation + liveness recheck covers logout and disabled/deleted accounts.
  • RBAC fixes close the null-merchant skip, template cross-merchant planting, numbers IDOR, and scope fail-open issues.

Recommendation: Approve. The PR addresses the backend pentest findings comprehensively and includes regression tests. Operational rollout notes about webhook signature enforcement and the custom-python kill switch should be followed closely.

Comment thread app/ai/voice/agents/breeze_buddy/agent/inbound.py
@murdore
murdore force-pushed the fix/pentest-clairvoyance-2026-001 branch from 492d5b4 to f4407f2 Compare July 21, 2026 06:36
Comment thread .env.example
Comment thread app/core/security/password/password_policy.py
Comment thread app/core/security/ssrf.py
Comment thread app/api/routers/breeze_buddy/telephony/answer/__init__.py
@narsimhaReddyJuspay

Copy link
Copy Markdown
Contributor

PR #930fix(security): remediate Breeze Buddy pentest findings (BB-DEEPDIVE-2026-001)

In-depth review (50 files, +4281/-2724). Verdict: request changes — 5 majors. Gates green: black/isort/autoflake/pyrefly (0 errors) ✅; pytest 700 passed (the PR's 4 new test files — test_pentest_authz, test_ssrf_egress, test_custom_python_sandbox, test_mcp_approval — pass). Note: 2 failures in test_chat_analytics.py are pre-existing on release (verified — they fail at origin/release too), not caused by this PR.

🟥 Major (inline comments posted)

  1. validate_lead_access (lead write path) still skips the merchant check when merchant_id is None — PT-15 half-fixed (read path hardened, write path not). (leads/rbac.py:52)
  2. validate_chat_create_access same null-merchant skip — creates merchant_id=None sessions that the hardened session-access check then 404s (self-DoS). (chat/rbac.py:58)
  3. Username-enumeration timing oracle on /login + /auth/s2s/token (401 on unknown user with no bcrypt; full bcrypt on wrong password) + no rate limiting on any credential endpoint. (auth/handlers.py:132)
  4. ssrf_safe_request (the per-hop redirect re-validation) has zero test coverage — the most load-bearing SSRF control is untested. (core/security/ssrf.py:184)
  5. Telephony answer + callbacks now require valid provider signatures — correctly fail-closed, but a hard breaking change: 401 on every inbound call if TWILIO/PLIVO/EXOTEL auth tokens or provider signing aren't configured. (telephony/answer/__init__.py:65)

🟨 Minor (not blocking)

  • bcrypt runs synchronously on the event loop in verify_password/hash_password (cost-12) — blocks the loop per login; wrap in asyncio.to_thread. Also _DUMMY_PASSWORD_HASH runs bcrypt at module import.
  • UserUpdate (admin reset) uses a weaker password policy than signup — only the email local-part is passed as a disallowed substring, not the username/account_id.
  • is_user_active / is_token_revoked fail OPEN on DB/Redis error (documented availability trade-off — during a combined outage, a disabled user's tokens stay valid till expiry).
  • MCP SSRF only upfront-validates (validate_egress_url once at build); no per-hop re-validation through the MCP transport (inconsistent with the http/webhook paths).
  • /list-accounts now requires password for the email branch (PT-16) — breaking for any client that called it post-login with only {email}; confirm all clients send it.
  • restrictedpython>=8.4 is a security-critical dep on a mutable lower bound — consider >=8.4,<9.
  • Inbound (PT-02) now requires a resolvable to_number — deployments whose inbound provider doesn't pass a usable to will be rejected.

🟦 Migration / continuity note

Self-signup default reseller changed "breeze""breeze-self-serve" (static.py:313). Migration 036 creates the new reseller row + a real fk_merchants_reseller, but does not backfill existing self-serve merchants (still reseller_id='breeze') → the self-serve population splits across two reseller buckets. Env-overridable (BB_SELF_SIGNUP_RESELLER_ID=breeze); verify whether prod currently has self-serve merchants under breeze and add a backfill or pin the env before rollout.

🟩 Verified clean / well-done

  • Python sandbox genuinely hardened: weak AST blocklist + bare exec replaced with RestrictedPython (compile_restricted_exec + safer_getattr + full_write_guard); dunder-escape / __subclasses__Popen rejected at compile time (tests assert it). Kill switch ENABLE_CUSTOM_PYTHON_FUNCTIONS defaults off.
  • Webhook signatures fail-closed for all three providers + unknown providers; Twilio/Plivo use provider constant-time validators, Exotel/WooCommerce/Breeze use hmac.compare_digest; signature required (absent → 401). ENFORCE_TELEPHONY_WEBHOOK_SIGNATURES defaults true.
  • Token revocation IS consulted on the hot path (verify_rbac_tokenis_token_revoked), keyed by SHA-256 of the raw token (covers already-issued tokens without jti), TTL = remaining token life.
  • Password policy enforced server-side (Pydantic model_validator); bcrypt 72-byte path now rejects instead of truncating; cost 12.
  • SSRF ranges complete (loopback, RFC1918, link-local incl. 169.254.169.254, ULA, CGN via not is_global backstop, v4-mapped IPv6 unwrap); numeric-encoding bypasses (2130706433, 0x7f…) caught because validation runs on the resolved IP; redirect re-validation implemented with credential stripping on host change; allowlist uses dot-boundary suffix matching.
  • Numbers / templates / configurations RBAC hardened and fail-closed; inbound PT-02 fails closed (missing to_number/outbound_number_id rejected).
  • All security toggles default SECURE: ENFORCE_TELEPHONY_WEBHOOK_SIGNATURES=true, ENABLE_CUSTOM_PYTHON_FUNCTIONS=false, SSRF_ALLOW_PRIVATE_EGRESS=false.

Residual (accepted, not a bug)

The SSRF guard validates the resolved IP but ssrf_safe_request still hands the hostname to the connection — a DNS-rebinding window remains on the initial hop (the module docstring acknowledges it "narrows" rather than closes the window). Pinning the validated IP to the connection would close it.

In-depth review via 5 domain fan-outs (authz/RBAC, SSRF/egress, password/token/auth, webhook/sandbox/MCP, tests/regression); every posted finding re-verified against the code at f4407f293b70.

Comment thread app/api/routers/breeze_buddy/leads/rbac.py
Comment thread app/api/routers/breeze_buddy/chat/rbac.py
Comment thread app/api/routers/breeze_buddy/signup/handlers.py Outdated
@murdore
murdore force-pushed the fix/pentest-clairvoyance-2026-001 branch from f4407f2 to 2ec67bc Compare July 21, 2026 10:03

@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

Files reviewed: 50 changed files (security, authz, telephony, MCP, widget, schemas, tests, config).

New issues raised this run: 0 blocking, 0 major, 0 minor, 0 suggestions.

Blocking criteria assessment: No hardcoded secrets, no SQL injection, no authz bypass, no SSRF bypass, no PII exposure, and no existing migration files were modified.

Assessment:

  • The pentest remediation is comprehensive and addresses the 22 backend findings with appropriate shared security primitives (SSRF egress guard, webhook signature verification, JWT revocation denylist, password policy).
  • RBAC fixes correctly close the null-merchant skip, template cross-merchant planting, numbers IDOR, and scope fail-open issues.
  • Async refactor of verify_rbac_token and _build_server_params is consistently awaited at all call sites in this PR.
  • New tests cover SSRF, sandboxed custom python, authorization/scope/token/password fixes.
  • Operational rollout notes about webhook signature enforcement and the custom-python kill switch should be followed closely.

Decision: Approve.

Comment thread app/api/routers/breeze_buddy/auth/rate_limit.py
@murdore
murdore force-pushed the fix/pentest-clairvoyance-2026-001 branch from 2ec67bc to 7817a1a Compare July 21, 2026 14:47
@murdore

murdore commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Review round addressed — force-pushed to 7817a1a

Thanks all. Every open thread now has a reply; one new code change this round:

@Tara-ag's CRITICAL on auth/rate_limit.py — fixed:

  • The per-username Redis identifier is now SHA-256 hashed (fixed 64-hex-char key), so an oversized username can't inflate key memory or inject bytes; new test test_long_username_is_hashed_to_bounded_key (100k-char input → 64-char key).
  • The XFF/trusted-proxy invariant is now documented explicitly in the module docstring.

Stale Round‑1 threads (Copilot + Tara-ag) — these were against an earlier revision and are already resolved in the current head; replies posted inline:

  • ssrf.py SSRF_ALLOW_PRIVATE_EGRESS centralization → already moved to static.py and imported.
  • webhook_signature.py query-string in reconstruct_public_url() → verified false positive (Twilio/Plivo sign the full URL incl. query; moving to params would double-count and break GET verification).
  • inbound.py PT‑02 fail-open → fixed (fails closed before lookup when to_number absent).

Gates: black/isort/autoflake/pyrefly clean; 702 passed (the 2 test_chat_analytics.py failures are pre-existing on release, unrelated to this diff).

@murdore

murdore commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

@narsimhaReddyJuspay @Tara-ag @swaroopvarma1 — this one is ready for a final pass and merge. 🙏

  • All 25 pentest findings (BB-DEEPDIVE-2026-001) are remediated, and every review thread — CodeRabbit, Copilot, @Tara-ag's rate-limit CRITICAL, and the 5 majors from @narsimhaReddyJuspay — is fixed and answered inline.
  • Gates are green: black / isort / autoflake / pyrefly clean, 702 tests passing. reviewDecision is APPROVED.
  • The only red check is Yama Review — a verified infra timeout (the v2.7.1 action's 30s doGenerate timeout on a large diff), not a code verdict. The durable fix is the neurolink bump in fix(deps): bump @juspay/neurolink to 10.1.2 for litellm timeout defaults yama#62 (pending a release); until that lands, merging this needs an admin override past the Yama check.

Could one of you do the final review and merge (admin-override the Yama check)? Happy to walk through any thread.

@murdore
murdore force-pushed the fix/pentest-clairvoyance-2026-001 branch from 7817a1a to 1641bba Compare July 22, 2026 17:12
@Tara-ag

Tara-ag commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Review summary

Files reviewed: 50 changed files (security, authz, telephony, MCP, widget, schemas, tests, config).

New issues raised this run: 0 blocking, 0 major, 0 minor, 0 suggestions.

Existing comments respected: All prior review threads from Copilot, CodeRabbit, Tara-ag, narsimhaReddyJuspay, and swaroopvarma1 were reviewed. Issues already raised and addressed by the author (fail-closed PT-02 guard, RestrictedPython hooks, constant-time bcrypt, SSRF IP-literal short-circuit, configuration centralization, webhook signature query-string handling, test gaps, write-path RBAC hardening, rate-limit key hashing/XFF) were not duplicated.

Blocking criteria assessment: No new blocking criteria are met. No hardcoded secrets, no SQL injection, no authz bypass, no SSRF bypass, no PII exposure, and no existing migration files were modified.

Key observations:

  • The SSRF egress guard (app/core/security/ssrf.py) correctly resolves DNS before validating, blocks internal/metadata addresses, re-validates redirect hops, strips credentials off allow-list redirects, and has regression tests for redirect-to-metadata and auth stripping.
  • Webhook signature verification fails closed when provider secrets are unset and uses constant-time comparison for Exotel.
  • JWT revocation + per-request liveness recheck covers logout and disabled/deleted accounts.
  • RBAC fixes close the null-merchant skip across templates, configurations, leads, chat, and recordings; template create/update now authorize on merchant scope; numbers get ownership checks and scoped list filtering.
  • Custom Python functions are gated off by default and compiled under RestrictedPython.
  • Credential endpoints get per-IP + per-username rate limiting and timing-equalizing dummy bcrypt checks.

Decision: Approve. The PR addresses the backend pentest findings comprehensively and includes targeted regression tests. Operational rollout notes about webhook signature enforcement and the custom-python kill switch should be followed closely.

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

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

Comments suppressed due to low confidence (1)

app/api/routers/breeze_buddy/numbers/handlers.py:266

  • get_telephony_number is documented to return 404 for out-of-scope numbers (and it already applies filter_numbers_by_rbac with template-pinned IDs). Calling validate_number_access here can return 403 before the router’s 404 logic runs, leaking that the number exists and breaking the “owned or template-pinned” visibility rule.
        validate_number_access(
            current_user,
            number.reseller_id,
            number.merchant_id,
            operation="access outbound number",

Comment thread app/api/routers/breeze_buddy/numbers/handlers.py Outdated
Comment thread app/api/routers/breeze_buddy/numbers/rbac.py Outdated
@murdore
murdore force-pushed the fix/pentest-clairvoyance-2026-001 branch from 1641bba to 90eaf97 Compare July 23, 2026 11:57
@murdore

murdore commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

@swaroopvarma1 — this one is verified and ready to merge; requesting your review/merge.

Full proof is in this thread: written verification plus a recorded run. Summary of the wider pass across the Breeze Buddy PR set — 86/86 controls held against a live stack (Postgres + Redis, real JWT logins, real browser):

PR State
clairvoyance #930 APPROVED · 0 unresolved · 7/8 checks (Yama red = known litellm 15m timeout, no verdict produced, not a required check)
clairvoyance #922 APPROVED · 0 unresolved · all 7 checks green
loom #244 APPROVED · CLEAN · all checks green
loom #241 CLEAN · all checks green · no reviewer since 19 Jul
loom #239 CLEAN · all checks green · no reviewer since 17 Jul

All five are MERGEABLE with 0 unresolved threads.

@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

Files reviewed: 55 changed files (focused on security-critical additions and modifications).

New issues raised this run: 6

  • ⚠️ MAJOR × 4
    • app/core/security/ssrf.py: ssrf_safe_request strips auth on off-allow-list/host-changing redirects but does not strip header-based credentials (Authorization, Cookie), so bearer tokens / session cookies can leak to attacker-controlled redirect targets.
    • app/api/routers/breeze_buddy/signup/handlers.py: PT-16 account-list timing oracle still leaks the number of active accounts per email (N password checks vs. one dummy check). Pad to a fixed cap.
    • app/api/routers/breeze_buddy/signup/handlers.py: select_account_handler can 500 on empty password or SSO-only account with no password_hash because verify_password raises ValueError.
    • app/api/routers/breeze_buddy/auth/handlers.py: login_handler and generate_s2s_token_handler can 500 on empty password because the Pydantic schemas do not enforce min_length=1.
  • 💡 MINOR × 2
    • app/api/security/breeze_buddy/rbac_token.py: PT-22 liveness recheck is cached for 30 seconds, so disabled/deleted accounts' tokens remain usable for up to 30 seconds — clarify or shorten TTL.
    • app/api/routers/breeze_buddy/signup/handlers.py: google_login_handler still returns 404 no_account, an account-enumeration oracle not covered by PT-16.

Existing comments respected: Points already raised by prior reviewers (Copilot/CodeRabbit/Tara-ag) and addressed by the author were not duplicated. This includes the PT-02 fail-closed guard, RestrictedPython hooks, SSRF IP-literal short-circuit, constant-time bcrypt work, and configuration centralization.

Blocking criteria assessment: The new MAJOR issues are security-adjacent but do not meet the explicit blocking criteria (no hardcoded secrets, no SQL injection, no authz bypass, no SSRF bypass, no PII exposure, no migration edits). However, the credential-leak and 500/empty-password issues are material enough that I am requesting changes before approval.

Next steps:

  1. Strip Authorization/Cookie headers in ssrf_safe_request when auth is stripped.
  2. Cap/pad account-list password checks to a fixed number.
  3. Guard select_account_handler against empty/missing passwords.
  4. Add min_length=1 to password fields in LoginRequest/S2STokenRequest.
  5. Optionally address the 30-second liveness cache and Google-login enumeration oracle.

Comment thread app/api/routers/breeze_buddy/signup/handlers.py Outdated
Comment thread app/api/routers/breeze_buddy/auth/handlers.py
Comment thread app/api/routers/breeze_buddy/signup/handlers.py
Comment thread app/api/routers/breeze_buddy/signup/handlers.py
Comment thread app/api/security/breeze_buddy/rbac_token.py
Comment thread app/api/routers/breeze_buddy/signup/handlers.py
Comment thread app/api/routers/breeze_buddy/signup/handlers.py
Comment thread app/api/routers/breeze_buddy/auth/handlers.py
Comment thread app/api/security/breeze_buddy/rbac_token.py
Comment thread app/api/routers/breeze_buddy/signup/handlers.py

@manas-narra manas-narra 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.

Argus review — see summary comment.

Comment thread app/api/routers/breeze_buddy/telephony/callbacks/handlers.py
Comment thread app/ai/voice/agents/breeze_buddy/handlers/transport/http_handler.py Outdated
Comment thread app/api/routers/breeze_buddy/auth/handlers.py
Comment thread app/ai/voice/agents/breeze_buddy/utils/common.py
Comment thread app/api/routers/breeze_buddy/numbers/rbac.py Outdated
@manas-narra

Copy link
Copy Markdown
Collaborator

This PR is the remediation pass for the BB-DEEPDIVE-2026-001 pentest: it adds a shared SSRF egress guard (app/core/security/ssrf.py) that every outbound sink now routes through, provider signature/token verification on all telephony webhooks, a Redis JWT revocation denylist plus a per-request user-liveness recheck, and a shared merchant_scope_permitted predicate that closes the "null merchant_id means no restriction" hole across templates, leads, chat, configurations and numbers. It also swaps the AST-denylist custom-function sandbox for RestrictedPython behind an off-by-default kill switch, adds a password strength policy and credential-endpoint rate limiting, and makes verify_rbac_token / get_user_from_websocket async. Roughly 50 files, with four new test modules covering the SSRF guard, the sandbox, the authz fixes and the rate limiter.

5 new issues found.

@murdore
murdore force-pushed the fix/pentest-clairvoyance-2026-001 branch from ab8093a to 4b7ac3c Compare August 3, 2026 06:09
@murdore

murdore commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Review round addressed — pushed 4b7ac3c

All 17 threads answered; 15 fixed and resolved, 2 left open where I'm pushing back (below). Gates: black / isort / autoflake / pyrefly clean, 860 passed (the 4 test_chat_analytics failures are pre-existing on release — that file is byte-identical to release and nothing in its import graph is in this diff).

@Tara-ag

Finding Fix
MAJOR · PT-16 leaks account count list_accounts_handler now spends a constant 5 bcrypts (ACCOUNT_PASSWORD_CHECK_BUDGET), padding with the dummy hash
MAJOR · empty password → 500 LoginRequest/S2STokenRequest/SelectAccountRequest passwords are min_length=1 → 422 at the boundary
MAJOR · select_account_handler no-hash → 500 guards the missing-hash case, spends one dummy bcrypt, returns generic 401
MAJOR · redirect leaks header creds credential-dropping hops now reduce caller headers to a 5-entry allow-list (deny-list would miss X-Api-Key etc.)
MINOR · PT-22 "immediate" vs 30s cache TTL 30s → 10s and the comment now states the real guarantee
MINOR · SSO enumeration pushing back — see the thread

@manas-narra

Finding Fix
Exotel callbacks would 401 in prod callback URL now carries ?auth_token=…; unset token logs ERROR. This was the most serious one — thank you
urlparse netloc empty for {base_url}/orders replaced with _authority_region(); verified against 6 template shapes
logout claims revocation that didn't happen returns success: false + honest message when revoke_token fails
reporting_webhook_url https-only regression not deliberate — allow_http=True restored, SSRF checks unchanged, plaintext logged
validate_number_access has no callers deleted; its tests retargeted onto filter_numbers_by_rbac (the control the router runs), plus a new pin-aware case

Two notes on my own verification

The PT-16 fix exposed a hole in my testing, not just the code: the old test asserted calls["n"] == 1 for the empty case, so it passed while the count-leak was live. It's replaced with an assertion that the bcrypt count is identical across 0/1/3/5/9 accounts — which fails against the old implementation.

Likewise the numbers tests asserted on a helper nothing called. Both were false confidence, and both are now pointed at the code that actually executes.

@murdore
murdore requested a review from manas-narra August 3, 2026 06:12

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

Autonomous review summary

Scope: 56 changed files across the Breeze Buddy pentest remediation (BB-DEEPDIVE-2026-001).

Existing review state: 40 prior review threads loaded. The majority are resolved; the remaining unresolved thread (google_login_handler 404 for unknown Google emails) has a reasoned pushback from the author that I agree with — probing an email requires a Google-signed token for that email, so it is not a practical enumeration oracle.

New issues raised in this run: 0

Blocking criteria check:

  • No hardcoded secrets/API keys/credentials in source.
  • No string-interpolated / f-string / %-formatted asyncpg SQL observed in changed code.
  • No existing database/migrations/*.sql files modified.
  • No new critical auth/authz bypass, SSRF, command/template injection, or PII exposure identified beyond what was already discussed and fixed in prior review rounds.

Assessment: The PR comprehensively remediates the backend pentest findings with:

  • A shared SSRF egress guard (app/core/security/ssrf.py) that resolves DNS and blocks internal/metadata/private addresses, plus per-hop redirect revalidation and credential-header stripping.
  • Provider webhook signature/token verification (app/core/security/webhook_signature.py) for Twilio/Plivo/Exotel, fail-closed when secrets are unset.
  • RBAC hardening via merchant_scope_permitted across templates, configurations, leads, chat, and numbers.
  • Server-side JWT revocation denylist + per-request is_active recheck.
  • Constant-time bcrypt budget for account listing and login/S2S no-such-user paths.
  • Per-IP and per-username rate limiting on credential endpoints.
  • RestrictedPython compilation + default-OFF gating for custom python_code.
  • Recording downloads pinned to provider hosts with ssrf_safe_request.

All identified concerns from prior reviewers appear to have been addressed in the latest commit. No new blocking issues remain.

Decision: APPROVE.

@murdore

murdore commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Verification for this round — with a negative control

The earlier suite reported "86/86 controls held" and still missed everything @Tara-ag and @manas-narra found. That was a testing failure as much as a code one, so this round adds a regression for each miss and checks each one actually fails against the pre-fix code (ab8093a). A test that passes either way proves nothing — which is precisely how the original PT-16 check gave false confidence.

15/15 held against the live stack (real server, Postgres 16 + 43 migrations, Redis, real JWT logins).

Gap Why the old suite missed it Now asserted
PT-16 count leak only tested 0-vs-1 accounts bcrypt count identical across 0/1/3/5/9
PT-07 header creds only tested aiohttp BasicAuth Authorization/Cookie/X-Api-Key/X-Auth-Token all withheld on a credential-dropping hop, plus an unrecognised header name (allow-list, not deny-list)
PT-17 host guard only tested https://{host}/path authority region correct for 5 template shapes incl. {base_url}/orders
PT-13 dead helper asserted on a function with no callers no orphan definition remains; router applies filter_numbers_by_rbac (3 refs); cross-tenant and pool reads 404 over HTTP
empty password never sent one /login, /auth/s2s/token, /auth/accounts all 422, never 500
logout honesty only tested the happy path success and revoked must agree
Exotel callback only sent unsigned callbacks the URL we hand Exotel carries auth_token, and it's exactly what verify_exotel_token accepts

Negative control — the same checks against ab8093a (pre-fix)

PT-16 old-code bcrypt counts: [1, 1, 3, 5, 9]  -> VARIES (leak present)
      new-code bcrypt counts: [5, 5, 5, 5, 5]  -> constant

PT-17 _authority_region present on old code: False
      old guard netloc('{base_url}/orders') = ''  -> bypass

PT-07 _without_credential_headers present on old code: False
PT-13 orphan validate_number_access present on old code: True

So each new assertion genuinely detects the bug it was written for.

Full suite 860 passed; black / isort / autoflake / pyrefly clean. The 4 test_chat_analytics failures are pre-existing on release (that file is byte-identical to release and isn't in this diff).

The harness and raw transcripts are reproducible locally at bb-pentest-proof/harness/attack_suite3.py; a recorded run is available — I couldn't attach it here because the browser session I use for uploads isn't signed in to GitHub right now. Say the word and I'll attach it.

@murdore

murdore commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Proof of testing — recorded run

Gates

black   → clean        isort → clean        pyrefly → INFO 0 errors

Security suites added by this PR

$ uv run pytest tests/test_ssrf_egress.py tests/test_custom_python_sandbox.py \
    tests/test_pentest_authz.py -q

tests/test_pentest_authz.py alone now carries 35 regression tests.

Negative control for the last review round

PT-16 was a bcrypt timing oracle leaking how many accounts share an email. The fix spends a constant budget regardless:

pre-fix   bcrypt calls for [0,1,3,5,9] accounts → [1, 1, 3, 5, 9]   LEAKS
post-fix  bcrypt calls for [0,1,3,5,9] accounts → [5, 5, 5, 5, 5]   constant
$ uv run pytest tests/test_pentest_authz.py -q -k 'bcrypt_count_is_constant'
1 passed

The other fixes from that round are each present and covered:

app/core/security/ssrf.py:192                  _without_credential_headers
app/core/security/ssrf.py:269                  (applied on credential-dropping hops)
.../handlers/transport/http_handler.py:44      _authority_region
.../handlers/transport/http_handler.py:132     (host-position guard)
.../telephony/exotel/exotel.py:31              _exotel_status_callback_url
.../telephony/exotel/exotel.py:118             (token on the registered callback)

Live-stack attack-suite evidence (42/42 and 86/86 controls held against real Postgres + Redis, real JWT logins, genuine vs tampered Plivo signatures, cross-IP rate caps) is already attached to this PR from the earlier rounds.

Status: APPROVED · 8/8 checks green · 1 commit. Two threads deliberately left open where I pushed back on the SSO-enumeration finding — happy to be overruled.

@murdore

murdore commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Proof of testing — recorded end to end

Recorded against this branch at 4b7ac3c. Real command output only.

p2-clairvoyance-930-pentest-backend.mp4

What the run shows, in order:

  1. Gates. black --check, isort --check --profile black, pyrefly check — clean.
  2. The security suites this PR adds. tests/test_ssrf_egress.py, tests/test_custom_python_sandbox.py, tests/test_pentest_authz.py.
  3. Full suite.
  4. Negative control on the last review round’s fix — PT-16, the bcrypt timing oracle.
  5. Every other fix from that round carries its own regression test — the video counts the tests in tests/test_pentest_authz.py and greps the three call sites: _authority_region in the SSRF guard, credential-header stripping on redirect in the global-function HTTP handler, and the Exotel status-callback URL.

On PT-16

The pre-fix code ran one bcrypt verification per matching account, so response time leaked how many accounts share an email — an unauthenticated enumeration primitive. The fix spends a constant budget (ACCOUNT_PASSWORD_CHECK_BUDGET) and pads the loop. bcrypt verifications by account count:

accounts sharing the email 0 1 3 5 9
pre-fix 1 1 3 5 9 leaks the count
post-fix 5 5 5 5 5 constant

Pinned by test_bcrypt_count_is_constant, run in the video.


The live-stack attack-suite videos from the earlier rounds are already attached to this PR (42/42 and 86/86 controls held against a running server). This recording is the current-HEAD re-verification plus the negative control for the newest fixes.

@swaroopvarma1 ready for review + merge.

@murdore

murdore commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Attack-side proof of every control, and one gap it found

For a security PR, a green suite proves the tests pass, not that the controls hold. Each control below was exercised by running the attack it exists to stop against the shipped modules imported from this worktree, with an unsandboxed negative control alongside so the difference is visible rather than asserted.

First, the suite this PR adds, each file individually and verbatim — 93 tests, all green:

test_auth_rate_limit.py          7 passed
test_custom_python_sandbox.py    7 passed
test_mcp_approval.py            20 passed
test_pentest_authz.py           35 passed
test_ssrf_egress.py             11 passed
test_stt_stream.py              13 passed

PT-01 — RestrictedPython sandbox, 12 escape attempts

RestrictedPython enforces at two stages, so each payload was compiled and executed through safe_globals + safer_getattr, the way parser.py:134-172 does it:

PASS  blocked at COMPILE  dunder-subclasses traversal   "__subclasses__" is an invalid attribute name
PASS  blocked at COMPILE  __globals__ via a lambda      "__globals__" is an invalid attribute name
PASS  blocked at COMPILE  __import__('os').system       "__import__" is an invalid variable name
PASS  blocked at COMPILE  eval                          Eval calls are not allowed.
PASS  blocked at COMPILE  exec                          Exec calls are not allowed.
PASS  blocked at COMPILE  __mro__ traversal             "__mro__" is an invalid attribute name
PASS  blocked at COMPILE  builtins via __builtins__     "__builtins__" is an invalid variable name
PASS  blocked at RUNTIME  open('/etc/passwd')           NameError: name 'open' is not defined
PASS  blocked at RUNTIME  getattr(obj, '__class__')     NameError: name 'getattr' is not defined
PASS  blocked at RUNTIME  import os (statement)         ImportError: __import__ not found
PASS  blocked at RUNTIME  subprocess.check_output       ImportError: __import__ not found
PASS  blocked at RUNTIME  write a file                  NameError: name 'open' is not defined
PASS  negative control: unsandboxed exec runs them   12/12 succeed with no sandbox

The negative control is not rhetorical — running the same list through a plain exec actually printed uid=501(sachinsharma) gid=20(staff) … into the log. Under the sandbox, nothing runs. ENABLE_CUSTOM_PYTHON_FUNCTIONS also reads False by default, so the sandbox is the second line of defence, not the first.

PT-03/07/11 — SSRF egress guard

Every reserved range denied, a public address allowed, and scheme/hostname handling checked:

PASS  127.0.0.1        loopback address 127.0.0.1
PASS  ::1              loopback address ::1
PASS  10.0.0.5         private address 10.0.0.5
PASS  172.16.0.1       private address 172.16.0.1
PASS  192.168.1.1      private address 192.168.1.1
PASS  169.254.169.254  link-local address (includes cloud metadata)
PASS  169.254.170.2    link-local address (ECS metadata)
PASS  0.0.0.0          private address
PASS  100.64.0.1       non-global address (CGNAT)
PASS  fd00::1          private address (unique-local v6)
PASS  fe80::1          link-local address (v6)
PASS  93.184.216.34 ALLOWED  (a public address is not collateral damage)

PASS  http://example.com/x            Disallowed URL scheme 'http'; allowed: ('https',)
PASS  file:///etc/passwd              Disallowed URL scheme 'file'
PASS  gopher://x/                     Disallowed URL scheme 'gopher'
PASS  https://127.0.0.1/x             Blocked egress to loopback address 127.0.0.1
PASS  https://169.254.169.254/latest/meta-data/   Blocked egress to link-local address
PASS  https://localhost/x             'localhost' — resolves to loopback address

PT-17 — allow-list matching is suffix-based, not substring

PASS  https://api.example.com      + allow ["example.com"]  -> True
PASS  https://example.com.evil.net + allow ["example.com"]  -> False

That second one is the whole point — a naive in check would have allowed it.

PT-24 — password policy

Tested against the rule the code actually implements (≥12 chars, ≥3 of 4 character classes, not in the common list, no identifier substring), not a stricter one imagined from the commit message:

PASS  rejected  under 12 chars              PASS  rejected  only 1 class (lower)
PASS  rejected  only 2 classes (lower+digit) PASS  rejected  only 2 classes (upper+digit)
PASS  rejected  only 1 class (symbol)       PASS  rejected  common password (password123, qwerty123)
PASS  rejected  over bcrypt's 72-byte limit PASS  rejected  empty
PASS  accepted  upper+lower+digit+symbol    PASS  accepted  lower+digit+symbol (3 of 4)
PASS  accepted  upper+digit+symbol (3 of 4)
PASS  rejects a password containing the username
PASS  identifier match is case-insensitive
PASS  empty disallowed entries are ignored, not matched

PT-05/12/23 — provider webhook signatures

PASS  empty Exotel token rejected     PASS  wrong Exotel token rejected
PASS  garbage Twilio signature rejected   PASS  missing Twilio signature rejected
PASS  garbage Plivo signature rejected

The gap: PT-21 caps one of the two S2S mint paths

- PT-21: cap S2S token lifetime at 365 days.

That cap landed on S2STokenRequest in app/schemas/breeze_buddy/auth.py. There is a second path that mints an S2S token through the same helper, and this PR does not touch it:

POST /auth/s2s/token POST /merchant with issue_token=true
who may call it ADMIN only — explicit 403 otherwise (auth/handlers.py:215) ADMIN or RESELLER (merchants/handlers.py:25-31)
lifetime schema ge=1, le=365, default 365 ge=1, le=365000, default 3650
mints via rbac_token_manager.create_access_token_with_rbac the same call, merchants/handlers.py:128
changed by this PR yes (auth.py, +7/−3) no

Executed against the shipped schemas:

PASS  /auth/s2s/token rejects 366 days
FAIL  POST /merchant rejects 366 days       ACCEPTED — mints an S2S JWT valid ~1 years
FAIL  POST /merchant rejects 3650 days      ACCEPTED — mints an S2S JWT valid ~10 years
FAIL  POST /merchant rejects 365000 days    ACCEPTED — mints an S2S JWT valid ~1000 years
FAIL  POST /merchant DEFAULT lifetime <= 365 days   default=3650 days (~10 years)

The token it issues is a real RBAC JWT — UserRole.MERCHANT, scoped to the reseller and merchant, stored on the merchant row and used as the webhook HMAC secret (webhooks/breeze/services.py:12). So a reseller, not only an admin, can mint a credential that is valid for ten years by default on an endpoint that PT-21 was meant to bound at one.

Fairness on severity: this is not privilege escalation — the caller is already an admin or a reseller acting within their own scope — and PT-22's denylist does cover these tokens (rbac_token.py:154 calls is_token_revoked on the shared verify path), so a known compromised token can be killed. The exposure is the one PT-21 exists to bound: how long a leaked S2S credential stays useful before anyone notices. The 10-year default is the sharp edge, because nobody has to ask for it.

The fix is one field:

# app/schemas/breeze_buddy/merchants.py
token_lifetime_days: int = Field(
    default=365, ge=1, le=365,
    description="Lifetime of the issued token in days (only when issue_token).",
)

Happy to push that onto this branch — say the word and I'll amend, or it can go as a follow-up if you'd rather not re-run the approval.

Recorded, not run

The rate-limiting, RBAC/IDOR and revocation controls (PT-08/09/13/14/15/16/18/19/20/22) are covered by the 35-case test_pentest_authz.py and 7-case test_auth_rate_limit.py suites above, but their full attack-side proof needs a live Redis and a running API with seeded multi-tenant fixtures. That is listed as outstanding rather than claimed.

…026-001)

Backend remediation for the white-box pentest. New shared security modules
(ssrf, webhook_signature, token_revocation, password_policy) plus per-finding
fixes:

- PT-01 RCE: template python_code now compiled under RestrictedPython (blocks
  __class__/__subclasses__ dunder-traversal escape) AND gated OFF by default
  via ENABLE_CUSTOM_PYTHON_FUNCTIONS at flow-build time.
- PT-02: scope the query-param template lookup to the dialed outbound number so
  an unauthenticated media WebSocket can't build an arbitrary template.
- PT-03/07/11: single hardened SSRF egress guard (resolve host, deny
  private/loopback/link-local/metadata, https-only, per-hop redirect
  revalidation) applied to MCP, HttpRequestExecutor and the webhook sender.
- PT-05/12/23: provider signature/token verification (Twilio/Plivo/Exotel) on
  all telephony callbacks + answer routes; recording downloads pinned to the
  provider's own hosts so master credentials never leak to an attacker URL.
- PT-08: validate reseller_ids in the merchant user-create branch.
- PT-09/14/15: template create/update authorize on merchant scope; null
  merchant_id treated as reseller-scoped (deny for merchant/user roles) across
  templates/configurations/leads/chat/numbers RBAC — on the read AND the
  write/create paths (validate_lead_access, validate_chat_create_access) so a
  write can never be looser than its read sibling.
- PT-13: real RBAC filtering + ownership check on /numbers.
- PT-16: /auth/accounts email branch requires password proof (no enumeration
  oracle); /login and /auth/s2s/token also spend one bcrypt against a shared
  dummy hash on the no-such-user branch so an unknown username is timing-
  indistinguishable from a wrong password; per-IP and per-username fixed-window
  brute-force rate limiting (fail-open on a Redis outage) guards /login,
  /auth/s2s/token, /signup and /auth/accounts.
- PT-17: block LLM-sourced values in URL host position; SSRF guard on HTTP tools.
- PT-18: cross-IP aggregate rate cap per public_widget_key.
- PT-19: derive chat-demo client IP from the trusted last XFF hop.
- PT-20: scope resolution fails closed (deny) on a broken owner chain.
- PT-21: cap S2S token lifetime at 365 days on BOTH mint paths. There are
  two: POST /auth/s2s/token (admin-only) and POST /merchant with
  issue_token=true, which is reachable by resellers as well as admins and
  hands its value to the same create_access_token_with_rbac. The merchant one
  defaulted to 3650 days and allowed 365000, so capping only the first left a
  reseller able to mint a ten-year credential by default. Both now read
  MAX_S2S_TOKEN_LIFETIME_DAYS rather than their own literal, which is how they
  drifted apart in the first place.
- PT-22: server-side JWT revocation denylist + per-request is_active liveness
  recheck; logout now revokes.
- PT-24: password policy (length, complexity, common/identifier deny-list,
  bcrypt 72-byte guard).

PT-04 (legacy admin login) was already removed on release; a regression test
guards it. Adds tests: SSRF egress (incl. per-hop redirect revalidation +
credential stripping on off-allow-list redirects), custom-python sandbox, an
authz/scope/policy/token suite, and credential-endpoint rate limiting.
@murdore
murdore force-pushed the fix/pentest-clairvoyance-2026-001 branch from 4b7ac3c to fc00f48 Compare August 7, 2026 00:18
@murdore

murdore commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

PT-21 gap closed, plus the rate-limit and revocation controls proven against live infrastructure

Amended to fc00f48 (still one commit).

The fix

Both S2S mint paths now read one constant instead of their own literal — which is exactly how they drifted to 365 and 365000:

# app/schemas/breeze_buddy/auth.py
# PT-21. Every path that mints a long-lived S2S token must bound it by this,
# not by its own literal — there is more than one such path (POST /auth/s2s/token
# and POST /merchant with issue_token=true), and they both hand the value
# straight to rbac_token_manager.create_access_token_with_rbac. A per-schema
# literal is how the two drifted to 365 and 365000 in the first place.
MAX_S2S_TOKEN_LIFETIME_DAYS = 365

MerchantCreate.token_lifetime_days goes from default=3650, le=365000 to default=le=MAX_S2S_TOKEN_LIFETIME_DAYS. Re-running the same probe that found it:

PASS  /auth/s2s/token rejects 366 days
PASS  POST /merchant rejects 366 days
PASS  POST /merchant rejects 3650 days
PASS  POST /merchant rejects 365000 days
PASS  POST /merchant DEFAULT lifetime <= 365 days   default=365 days (~1 years)

test_merchant_issue_token_lifetime_capped_at_365 guards it, and asserts the two schemas share a default so they cannot drift again. It is a real guard, not a passing assertion — reverting the schema to default=3650, le=365000 fails it:

$ pytest tests/test_pentest_authz.py -k lifetime          2 passed
$ (revert merchants.py) && pytest ... -k lifetime          1 failed, 1 passed
   FAILED test_merchant_issue_token_lifetime_capped_at_365
$ (restore) && pytest tests/test_pentest_authz.py -q       36 passed

Full suite: 861 passed, 1 xfailed — plus the 4 pre-existing test_chat_analytics.py failures that #973 repairs. black, isort, autoflake, pyrefly all clean.


PT-16 / PT-18 / PT-19 / PT-22 against a live Redis

tests/test_auth_rate_limit.py monkeypatches check_rate_limit, so it verifies the calling logic but never the limiter: no key is written, no window rolls, and the "identifier is hashed" claim is never observed. Run against a real Redis 7, driving enforce_credential_rate_limit until it actually blocks — 22/22:

caps in force: per-IP 40/hour, per-username 15/hour

=== PT-16 — per-IP cap, one attacker hammering many usernames ===
  PASS  per-IP cap trips (allowed 40, blocked on attempt 41)
  PASS  subsequent attempts stay blocked   status=429
  PASS  429 carries Retry-After            Retry-After=2284
  PASS  a DIFFERENT IP is unaffected

=== PT-16 — per-username cap, distributed guess from many IPs ===
  PASS  per-username cap trips despite rotating IPs (allowed 15)
  PASS  a DIFFERENT username from a fresh IP still works

=== PT-16 — the 429 must not be an enumeration oracle ===
  PASS  identical 429 for a real-looking and a bogus identifier
        (429, 'Too many authentication attempts. Please try again later.') both times

=== PT-16 — the identifier is hashed, so key memory is bounded ===
  PASS  a 10 KB username produced a key
  PASS  the key segment is a 64-hex SHA-256, not the raw username
  PASS  it is the SHA-256 of the lowercased identifier
  PASS  the raw username does not appear in any key
  PASS  every bucket carries a bounded TTL   ttls=[3605, 3605]

=== PT-16 — the per-IP bucket is shared across endpoints ===
  PASS  burning the cap on one endpoint blocks the others too

=== PT-19 — client IP is the LAST forwarded hop ===
  PASS  auth:credential_ip:198.51.100.11:496128
        (from "1.2.3.4, 5.6.7.8, 198.51.100.11" — a spoofed leading hop is ignored)

=== PT-16 — fail-open when Redis is unreachable ===
  PASS  operators are not locked out during a Redis outage

=== PT-22 — JWT revocation denylist ===
  PASS  a fresh token is not revoked          PASS  revoke_token succeeds
  PASS  the same token is now revoked         PASS  an unrelated token is unaffected
  PASS  denylist stores a hash, not the raw token
        jwt:revoked:76de2f1d90104f0464ac7b6722a1d0bf9557135c767f23693f885d68eef37c85
  PASS  denylist entry expires with the token, not forever   ttl=3599s
  PASS  revoking an already-expired token does not create an entry

The per-username result is the one worth reading twice: 15 guesses against ceo@company.com from 15 different source IPs were allowed and the 16th was blocked, so rotating IPs does not buy an attacker more attempts against one account.

One more, found by accident

Wiring the harness tripped two startup guards that turn out to be worth stating explicitly:

JWT_SECRET_KEY=<empty>  JWT_ALGORITHM=<empty>  -> RuntimeError: JWT_SECRET_KEY env var is empty.
JWT_SECRET_KEY=s3cret   JWT_ALGORITHM=none     -> RuntimeError: JWT_ALGORITHM env var is 'none';
                                                  must be one of ['ES256','HS256',…]
JWT_SECRET_KEY=s3cret   JWT_ALGORITHM=HS256    -> IMPORTED OK

The service refuses to boot with an unsigned-token algorithm rather than accepting alg: none at verify time.

Still not proven

The RBAC/IDOR controls (PT-08/09/13/14/15/20) are covered by the 36-case test_pentest_authz.py, but their attack-side proof needs the API running with seeded multi-tenant rows — a reseller token genuinely failing to read another reseller's templates over HTTP. That is the remaining gap and I have not claimed otherwise.

@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 — PR #930

Scope: Breeze Buddy pentest remediation (22 backend findings). 57 files changed (+5,040 / −2,776), single commit.

New issues raised this run: 0

I walked the diff file-by-file against the project standards and the existing review threads. No new blocking security, authz, SSRF, SQL-injection, secret-leak, migration-editing, or PII-exposure issues were found in the current head. Existing review comments (including the resolved/outdated ones) already cover the points that were iterated on, and the author has addressed them in subsequent commits.

Key areas verified:

  • SSRF: validate_egress_url resolves DNS and blocks internal/metadata; ssrf_safe_request re-validates every redirect hop and strips auth/headers on cross-host/off-allow-list redirects.
  • Telephony webhooks: Twilio/Plivo signatures and Exotel token verified via shared verify_provider_webhook; recording downloads host-pinned to provider domains.
  • Authz: merchant_scope_permitted consistently denies null-merchant access to merchant/user roles across templates, leads, chat, configurations, and numbers; scope resolution fails closed on broken owner chains.
  • Auth: credential endpoints have per-IP + per-username rate limiting and bcrypt timing equalization; JWT revocation denylist + liveness recheck added.
  • Custom Python: gated off by default and compiled under RestrictedPython.
  • Migrations: no existing migration files were edited.
  • Tests: new coverage for SSRF egress, custom-python sandbox, authz/scope/policy, and credential rate limiting.

Decision: Approve. The remaining open thread (google_login_handler 404) is a MINOR suggestion with an unresolved disagreement; it is not a blocking criterion under the project standards.

@murdore

murdore commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

The last block: RBAC/IDOR driven over HTTP against the real app

Previously listed as outstanding, now closed. The unit suite asserts the RBAC helpers in isolation, which cannot catch a control that is correct but never wired into a route. This mounts the actual FastAPI app — real routers, real dependencies, real token verification — on the live Postgres and Redis, and issues real requests with real JWTs.

Tenancy: reseller rsl-one owns merchant mrc-one, reseller rsl-two owns mrc-two, one template each. 24/24 held.

=== PT-15 — cross-tenant template READ is refused ===
  PASS  r1 reads its OWN template                        200
  PASS  r2 CANNOT read r1's template                     403 {"detail":"Access denied to reseller rsl-one"}
  PASS  r1 CANNOT read r2's template                     403 {"detail":"Access denied to reseller rsl-two"}
  PASS  merchant m1 CANNOT read a foreign reseller's     403
  PASS  admin can read either                            200

=== PT-13/15 — list endpoints are FILTERED, not just gated ===
  PASS  r1's list contains its own template   ['aaaaaaaa-…-0001']
  PASS  r1's list does NOT leak r2's template
  PASS  r2's list does NOT leak r1's template ['aaaaaaaa-…-0002']
  PASS  admin sees both

=== PT-09 — the WRITE path is not looser than the read path ===
  PASS  r2 CANNOT create a template scoped to r1         403
  PASS  r2 CANNOT update r1's template                   403
  PASS  r1's template row is untouched after the attempt name=r1-m1-template

=== PT-14 — a null merchant_id is reseller-scoped ===
  PASS  merchant denied on a null merchant_id   "Access denied to reseller-scoped template"
  PASS  reseller allowed on its own reseller-scoped template
  PASS  merchant denied on a FOREIGN merchant_id

=== PT-22 — revocation + is_active liveness ===
  PASS  a live token works before revocation             200
  PASS  the SAME token is refused once revoked           401
  PASS  a newly-minted token still works                 200
  PASS  within the 10s liveness cache the token still works (documented)
  PASS  once the cache is invalidated the UNREVOKED token dies mid-life   401
  PASS  re-activating the user revives the same token    200

=== token integrity ===
  PASS  no token at all is refused        PASS  a tampered signature is refused
  PASS  an alg:none token is refused

The PT-09 update case sends the template back fully formed (fetched as admin, name changed) rather than a partial body — a partial body 422s on schema validation before authz ever runs, which would have looked like a pass while proving nothing.

Two things I got wrong first, and the code was right both times

merchant_scope_permitted requires a non-null merchant_id to be in merchant_ids for resellers too, not just merchant-role callers. I seeded reseller tokens with empty merchant_ids and read the resulting 403 on their own template as a failure. It is the intended rule (authorization.py:32-36) — a reseller's token carries its merchants.

The liveness recheck looked broken and is not. I flipped is_active = false in the DB and the token kept working. is_user_active is Redis-cached for _USER_ACTIVE_CACHE_TTL = 10 seconds and my earlier requests had warmed it. Both halves of that window are now asserted: the token survives inside the cache TTL, and dies the moment invalidate_user_active_cache is called — which is exactly what the deactivate path does. Documented behaviour, fail-open on infra trouble, with the revocation denylist as the hard stop.

Coverage now

Every PT-* control in this PR has attack-side evidence: PT-01 sandbox (12 payloads, blocked at compile or runtime, 12/12 executing unsandboxed), PT-03/07/11/17 SSRF, PT-05/12/23 signatures, PT-16/18/19 rate limiting against live Redis, PT-21 both mint paths, PT-22 revocation and liveness, PT-24 password policy, and PT-08/09/13/14/15/20 over HTTP above.

@murdore

murdore commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Superseded — split into seven reviewable PRs

This PR was 57 files and +5040/-2776 in a single commit covering 20 pentest findings, which is not a reviewable unit. It has been split along the security-control seams, one PR per control, each with its own tests:

PR Findings
1 #987 PT-03/07/11/17 — SSRF egress guard
2 #988 PT-01 — template python_code sandbox + kill switch
3 #989 PT-05/12/23 — telephony signature verification, recording host pinning
4 #990 PT-02/08/09/13/14/15/20 — cross-tenant read and write paths
5 #991 PT-24 — password strength policy
6 #992 PT-16/18/19 — credential rate limiting, enumeration and timing
7 #993 PT-21/22 — JWT revocation, liveness recheck, S2S lifetime cap

They form a stacked chain — #987 targets release, each subsequent PR targets the previous one — so they should be merged in order 1→7.

The split is verified, not asserted

The seven slices reassemble byte-for-byte into this PR's tree across app/, .env.example, pyproject.toml and uv.lock, and all 36 pentest tests are routed into exactly one slice each. Both are machine-checked by the splitter, so a lost hunk or a dropped test fails the build rather than passing quietly.

One thing found while splitting

This branch was behind release and its diff removed #922's TTS catalog config — merging it as-is would have reverted merged work. Rebasing onto bff6425 surfaced three latent breaks that no CI run here would have caught:

  • the PT-16 dummy-hash branches called the sync verify_password, while release had since moved real password checks to an async wrapper to keep bcrypt off the event loop. Left alone, the one branch an attacker can drive at will would block the loop ~240ms per guess — precisely the hazard that wrapper exists to prevent.
  • the new password/ package's __init__.py did not re-export those async wrappers, so every import of app.main failed and 13 test modules errored on collection.
  • signup/handlers.py called verify_password inside a list comprehension on the /auth/accounts email branch, which would have been a NameError in production.

All three are fixed in the split. Closing this in favour of the seven; the branch is untouched if anything needs to be recovered from it.

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.

6 participants