Skip to content

Security: Ana-Naasan/JusticeLine

Security

SECURITY.md

Security Audit — JusticeLine

Audit date: 2026-05-12 Auditor: Claude Code (claude-sonnet-4-6) Scope: Phase 1 — Call Infrastructure (main.py, config.py, openjustice.py, sms.py, Dockerfile, requirements.txt) Remediation: PR #3 (hotline-fixes-and-security) addresses SEC-001 through SEC-008 — see summary table for current statuses.


Threat Model Summary

JusticeLine exposes two unauthenticated public endpoints on Cloud Run: a POST /twilio-webhook that generates TwiML and a WebSocket /media that opens a live Gemini session. Any attacker with the Cloud Run URL can hit either endpoint without presenting any credential. The system handles caller phone numbers (PII) and routes AI-generated tool calls to external APIs (OpenJustice, Twilio SMS), creating injection and abuse surfaces. The service account key (hackathon-key.json) sits on disk alongside application code and is baked into the Docker image at build time with no .dockerignore guard.


Findings

[SEC-001] No Twilio Signature Validation on /twilio-webhook — UNMITIGATED

Threat: Any actor who discovers the Cloud Run URL can POST arbitrary form data to /twilio-webhook, spoofing a Twilio call. This lets an attacker: (a) poison _pending_callers with a controlled phone number so the next real WebSocket caller's SMS goes to an attacker-chosen number, (b) probe the endpoint for information disclosure, (c) trigger downstream side effects at zero cost.

Location: main.py:90-107

Current state: The handler calls await request.form() and reads CallSid / From directly with no X-Twilio-Signature header check and no call to twilio.request_validator.RequestValidator. Any HTTP client can hit this endpoint.

Recommended fix:

  1. Import twilio.request_validator.RequestValidator and instantiate it with TWILIO_AUTH_TOKEN.
  2. In the handler, read X-Twilio-Signature from headers and the full request URL.
  3. Call validator.validate(url, form_params, signature) — return HTTP 403 immediately on failure.
  4. Add a test asserting that a request without a valid signature is rejected.

[SEC-002] /media WebSocket Accepts Connections from Any Origin — UNMITIGATED

Threat: The /media WebSocket endpoint accepts connections from any client, not just Twilio's media gateway. An attacker can open a WebSocket, send a crafted start event with a fake callSid, then send media events to drive a Gemini Live session at the operator's GCP cost. One Gemini Live session costs real money; this is an unbounded cost-amplification attack.

Location: main.py:142-271

Current state: await websocket.accept() is unconditional (line 144). No origin check, no shared secret, no validation that the connecting IP is a Twilio media gateway range. The start event's callSid is looked up in _pending_callers (line 228), so a missing entry only means caller_number is None — the Gemini session still opens and runs.

Recommended fix:

  1. Require a short-lived token in the WebSocket URL query parameter, generated by /twilio-webhook and stored alongside the pending caller entry; reject connections whose token is absent or expired (60 s TTL is sufficient for Twilio's connection latency).
  2. Alternatively, validate the connecting IP against Twilio's published IP ranges in Cloud Run network policy.
  3. If callSid is not in _pending_callers, close the WebSocket immediately before opening a Gemini session.

[SEC-003] Service Account Key Baked into Docker Image — UNMITIGATED (CRITICAL-003)

Threat: hackathon-key.json contains a live GCP service account private key (project: [redacted], key ID: [redacted]). Because there is no .dockerignore, COPY . . in the Dockerfile copies this key into every built image. Anyone who pulls the image from the registry — including via a misconfigured Cloud Run service or a leaked image tag — obtains full service account credentials.

Location: Dockerfile:5 (COPY . .), absent .dockerignore

Current state: .dockerignore does not exist. hackathon-key.json is present in the working directory (confirmed by ls). The Dockerfile unconditionally copies everything.

Recommended fix:

  1. Create .dockerignore immediately and add at minimum: hackathon-key.json, .env, .venv/, *.json (or specifically *-key.json).
  2. Rotate the service account key in GCP Console right now — treat it as compromised.
  3. Switch to Workload Identity Federation on Cloud Run (no key file needed at all; the runtime service account is attached to the Cloud Run service).
  4. Verify the image is not already published to Artifact Registry with the key inside.

[SEC-004] API Key Logged in Plaintext at Every OpenJustice Call — UNMITIGATED

Threat: The OpenJustice API key is printed to stdout on every query. On Cloud Run, stdout goes to Cloud Logging, which may be accessible to any project member or exported to third-party SIEM systems. This permanently exposes the secret in log storage.

Location: openjustice.py:18

Current state:

print("Using key and flow:", OJ_KEY, OJ_FLOW_ID, flush=True)

This line runs on every call to query_openjustice, printing the full value of OJ_KEY unconditionally.

Recommended fix:

  1. Delete line 18 entirely. The flow ID is already logged on line 24 (truncated), which is sufficient for debugging.
  2. If key presence must be confirmed at startup, log bool(OJ_KEY) or a redacted prefix: OJ_KEY[:4] + "****".

[SEC-005] No Rate Limiting — UNMITIGATED

Threat: /twilio-webhook and /media have no request rate limiting. An attacker (or a runaway Twilio misconfiguration) can open unlimited concurrent Gemini Live sessions, exhausting Vertex AI quota and incurring unbounded GCP billing. One session holds a streaming connection open for up to 10 minutes (<Pause length="600" />).

Location: main.py:90 (webhook), main.py:142 (WebSocket), requirements.txt (no rate-limit library)

Current state: No middleware, no slowapi, no Cloud Run concurrency cap enforced in application logic.

Recommended fix:

  1. Set Cloud Run --max-instances and --concurrency limits in deployment config to cap simultaneous sessions at an affordable number.
  2. Add slowapi or Cloud Armor rate limiting on /twilio-webhook (e.g., 10 req/min per IP).
  3. Track active Gemini sessions in a counter; reject new WebSocket connections beyond a configured maximum.

[SEC-006] Caller Phone Number Logged in Plaintext — PARTIAL

Threat: Caller phone numbers are PII. Logging them verbatim to Cloud Logging means they are stored indefinitely in GCP, potentially accessible to log viewers, exported to BigQuery, or included in support tickets, creating unnecessary data retention risk under PIPEDA / Quebec Law 25.

Location: main.py:97, main.py:134, main.py:229; sms.py:52, sms.py:55

Current state: Three separate print statements emit the full E.164 number. The number is not stored beyond the call (it is popped from _pending_callers at WebSocket start — this part is correct), but log persistence is uncontrolled.

Recommended fix:

  1. Redact the middle digits before logging: e.g., +1***5678 using a helper _mask(n): return n[:3]+"***"+n[-4:].
  2. Set Cloud Logging retention to the minimum required (30 days or less).
  3. Document the data flows and retention policy in a privacy notice.

Why PARTIAL: The in-memory lifetime is correctly bounded (pop at WebSocket start), but log persistence is not addressed.


[SEC-007] Unvalidated Twilio Form Fields Used in In-Memory State — PARTIAL

Threat: CallSid and From are taken verbatim from the POST body with no format validation. A spoofed request (see SEC-001) could inject an arbitrarily long or crafted string into _pending_callers. While this does not directly cause injection into SQL or shell, a very long CallSid key could cause unbounded memory growth if many fake requests arrive.

Location: main.py:93-96

Current state: form.get("CallSid", "") and form.get("From", "") — no length check, no regex validation (e.g., CA[0-9a-f]{32} for CallSid, E.164 for From).

Recommended fix:

  1. Validate CallSid matches ^CA[0-9a-f]{32}$ before inserting into _pending_callers.
  2. Validate From matches an E.164 pattern (^\+[1-9]\d{7,14}$) before storing.
  3. Both validations become moot once SEC-001 (signature validation) is implemented, but defense-in-depth is still valuable.

Why PARTIAL: No injection risk beyond memory, but input is fully unvalidated.


[SEC-008] Stack Traces Printed to stdout on Unhandled Exceptions — PARTIAL

Threat: traceback.print_exc() is called in three error handlers. In Cloud Run, stdout is Cloud Logging. Stack traces can reveal internal file paths, library versions, and variable state, giving an attacker information useful for further exploitation.

Location: main.py:216, main.py:257, main.py:267

Current state: Exceptions in the Gemini receiver, WebSocket handler, and top-level Gemini connection all call traceback.print_exc(). These traces go to Cloud Logging, not HTTP responses, so they are not directly exposed to callers.

Recommended fix:

  1. In production, replace traceback.print_exc() with structured logging that includes a correlation ID but suppresses the full trace, or gate verbose tracing on a DEBUG env flag.
  2. Ensure Cloud Logging IAM grants are restricted to operators only (no public log sink).

Why PARTIAL: Traces go to logs, not HTTP responses, so the blast radius is limited — but log access controls are outside this codebase.


[SEC-009] No SSRF Risk from OpenJustice URL — MITIGATED

Threat: If the OpenJustice base URL or path were caller-controlled, an attacker could redirect HTTP requests to internal GCP metadata endpoints or other internal services.

Location: openjustice.py:4, openjustice.py:26

Current state: OJ_BASE is read from OPENJUSTICE_BASE_URL env var at startup (not per-request), and the path /dialog-flow-executions/run is a hardcoded string literal. The situation and jurisdiction strings from Gemini are placed in the JSON body — not in the URL — so they cannot redirect the HTTP call. No caller-controlled URL construction occurs.

Status: MITIGATED — no action required.


[SEC-010] No Prompt Injection Risk from Caller Audio into SMS Body — MITIGATED

Threat: If caller-spoken text were directly interpolated into the SMS body, an attacker could craft speech to inject malicious content into outbound SMS messages.

Location: sms.py:34-36, main.py:132-138

Current state: The SMS body is selected exclusively from the hardcoded _RESOURCES dict by _body_for_lang(). The language_code parameter routes only to a fixed key lookup (prefix = language_code[:2].lower()). No caller-provided text appears in any SMS body. The situation and jurisdiction fields go only to the OpenJustice JSON payload body.

Status: MITIGATED — no action required.


[SEC-011] Service Account Key Not in Git History — MITIGATED

Threat: hackathon-key.json committed to git would expose credentials to everyone with repo access and permanently in git history.

Location: .gitignore:2 (hackathon-key.json)

Current state: The key is listed in .gitignore. Git log confirms it has never been committed to any branch in this repo. The risk is local-disk only (addressed separately in SEC-003 / Docker layer).

Status: MITIGATED for git history. Docker image exposure remains open (SEC-003).


[SEC-012] No Pinned Dependency Versions — PARTIAL

Threat: Unpinned packages in requirements.txt mean that a future pip install could pull in a dependency version with a known CVE, silently changing the attack surface between builds.

Location: requirements.txt (all 9 dependencies are unpinned)

Current state: Every package (fastapi, uvicorn, google-genai, twilio, etc.) is listed without a version specifier. The google-genai and twilio libraries in particular have had security-relevant releases.

Recommended fix:

  1. Run pip freeze > requirements.txt (or use pip-compile from pip-tools) to lock all transitive dependencies to exact versions.
  2. Integrate pip-audit or Dependabot into CI to flag newly disclosed CVEs against the locked set.

Why PARTIAL: No currently known exploitable vulnerability was identified, but the unpinned state makes the risk unmanageable over time.


Accepted Risks

None formally accepted at this time. SEC-005 (rate limiting) and SEC-006 (PII logging) may be acceptable for a hackathon context with operator acknowledgment, but must be re-evaluated before any production launch handling real caller data.


Summary Table

ID Title Status
SEC-001 No Twilio signature validation on /twilio-webhook MITIGATED (PR #3)
SEC-002 /media WebSocket open to any client MITIGATED (PR #3)
SEC-003 Service account key baked into Docker image PARTIAL — .dockerignore added; key rotation still required
SEC-004 API key logged in plaintext MITIGATED (PR #3)
SEC-005 No rate limiting PARTIAL — session cap added; Cloud Armor not yet configured
SEC-006 Caller phone number logged in plaintext MITIGATED (PR #3)
SEC-007 Unvalidated Twilio form fields MITIGATED (PR #3)
SEC-008 Stack traces in Cloud Logging MITIGATED (PR #3)
SEC-009 SSRF via OpenJustice URL MITIGATED
SEC-010 Prompt injection into SMS body MITIGATED
SEC-011 Service account key in git history MITIGATED
SEC-012 No pinned dependency versions PARTIAL

There aren't any published security advisories