towerops/SECURITY_AUDIT.md
Graham McIntie 34fe5d7e49 Security fixes: mask credentials in logs/API, fix cookie/CSP/LIKE injection/webhooks
CRITICAL fixes:
- Mask SNMP community string in agent channel logs (CRITICAL-1)
- Remove snmpv3 passwords from REST API responses, return _set booleans (CRITICAL-2)
- Replace snmp_community with snmp_community_set in GraphQL type (CRITICAL-3)

HIGH fixes:
- Fix cookie same_site from invalid 'Towerops' to 'Lax' (HIGH-4)
- Remove unsafe-eval from CSP script-src (HIGH-6)
- Block GraphQL introspection queries in production (HIGH-7)
- Sanitize LIKE wildcards in SNMP device name search (HIGH-8)
- Reject webhooks when no secret configured instead of accepting (HIGH-9)

MEDIUM fixes:
- Hash mobile session tokens (SHA-256) before DB storage (MEDIUM-10)
- Apply security headers in all environments, not just prod (MEDIUM-14)
- Add GraphQL query complexity limit (500) in production (MEDIUM-16)
- Fix X-Frame-Options to DENY to match frame-ancestors 'none' (MEDIUM-13)
2026-02-15 09:09:04 -06:00

14 KiB

TowerOps Security Audit

Date: 2026-02-15 Scope: Full application security review of the TowerOps web application Auditor: Automated security review


Critical Findings

1. SNMP Community String Logged in Plaintext

Severity: CRITICAL File: lib/towerops_web/channels/agent_channel.ex, line ~294 Issue: The credential test handler logs the SNMP community string in plaintext:

Logger.info(
  "AgentChannel sending credential test to agent: ip=#{snmp_config[:ip]} port=#{snmp_config[:port]} version=#{snmp_config[:version]} community=#{snmp_config[:community]} test_id=#{test_id}"
)

Community strings are effectively passwords for SNMP access. Logging them means they end up in log aggregation systems, error trackers (Honeybadger), and potentially accessible to anyone with log access.

Fix: Remove community=#{snmp_config[:community]} from the log message. Log only community_present: !is_nil(snmp_config[:community]).


2. SNMPv3 Passwords Exposed via REST API

Severity: CRITICAL File: lib/towerops_web/controllers/api/v1/devices_controller.ex, lines 319-321, 342-344 Issue: Both format_device/1 and format_device_details/1 include snmpv3_auth_password and snmpv3_priv_password in API responses. These are encrypted at rest (Cloak) but returned as plaintext in the JSON response to any API token holder.

snmpv3_auth_password: device.snmpv3_auth_password,
snmpv3_priv_password: device.snmpv3_priv_password,

Fix: Remove password fields from API responses entirely. If credential status is needed, return boolean flags like snmpv3_auth_password_set: !is_nil(device.snmpv3_auth_password).


3. SNMP Community String Exposed via GraphQL and REST API

Severity: HIGH Files:

  • lib/towerops_web/graphql/types/device.ex, line 22: field :snmp_community, :string
  • lib/towerops_web/controllers/api/v1/devices_controller.ex (via format_device)

Issue: The SNMP community string (which is an authentication credential) is exposed through the GraphQL API and REST API to any authenticated API token holder. While the GraphQL type correctly omits SNMPv3 passwords, it still exposes the community string.

Fix: Remove snmp_community from the GraphQL :device object type. In the REST API, return snmp_community_set: true/false instead.


Severity: HIGH File: lib/towerops_web/user_auth.ex, line 30 Issue: The same_site option for the remember-me cookie is set to "Towerops" — an invalid value. Valid values are "Strict", "Lax", or "None". An invalid value means the browser will likely use its default behavior (typically Lax), but this is browser-dependent and could result in the cookie being sent with cross-site requests in some browsers.

@remember_me_options [
  sign: true,
  max_age: @max_cookie_age_in_days * 24 * 60 * 60,
  same_site: "Towerops"  # BUG: Invalid value
]

Fix: Change to same_site: "Lax" (matching the session cookie setting on line 12 of endpoint.ex).


High Findings

5. No force_ssl / HSTS Configuration

Severity: HIGH File: config/runtime.exs (commented out on lines 252-258), config/prod.exs line 33 Issue: TLS is handled by Cloudflared proxy, but there's no force_ssl with HSTS configured. If the Cloudflared tunnel is misconfigured or bypassed, traffic could flow over HTTP. The Strict-Transport-Security header is not set anywhere in the application.

Fix: Add force_ssl: [hsts: true, rewrite_on: [:x-forwarded-proto]] to the endpoint config in production. This ensures HSTS headers are sent and HTTP requests are redirected even if the proxy configuration changes.


6. CSP Allows unsafe-eval for Scripts

Severity: HIGH File: lib/towerops_web/router.ex, line 30 Issue: The Content-Security-Policy includes 'unsafe-eval' in the script-src directive. While 'unsafe-inline' is needed for LiveView, 'unsafe-eval' is not required and significantly weakens XSS protections by allowing eval(), new Function(), etc.

"script-src 'self' 'unsafe-inline' 'unsafe-eval'"

Fix: Remove 'unsafe-eval' from the CSP. LiveView does not require eval(). If a specific dependency needs it, use a nonce-based CSP instead.


7. GraphQL Introspection Enabled in Production

Severity: HIGH File: lib/towerops_web/graphql/schema.ex Issue: Absinthe enables introspection by default. This allows any authenticated API token holder to discover the full schema including all types, fields, mutations, and internal documentation. This aids attackers in understanding the API surface.

Fix: Disable introspection in production:

def context(ctx) do
  if Application.get_env(:towerops, :env) == :prod do
    Map.put(ctx, :__absinthe_introspect__, false)
  else
    ctx
  end
end

8. SQL Injection via String Interpolation in LIKE Query

Severity: HIGH File: lib/towerops/snmp.ex, line 723 Issue: The normalized_name variable is interpolated directly into a fragment LIKE pattern:

where: fragment("LOWER(?) LIKE ?", e.name, ^"%#{normalized_name}%"),

While ^ parameterizes the outer value, the %#{normalized_name}% string interpolation happens in Elixir before being passed to Ecto, so it IS properly parameterized. However, the % and _ LIKE wildcards in normalized_name are not escaped, allowing users to craft patterns that match unintended records (LIKE injection).

Fix: Sanitize normalized_name by escaping % and _ characters (similar to how Towerops.Search.sanitize/1 does it).


9. Webhook Signature Verification Bypassed When Secret Not Configured

Severity: HIGH Files:

  • lib/towerops_web/controllers/api/v1/gaiia_webhook_controller.ex, lines 75-78
  • lib/towerops_web/controllers/api/v1/pagerduty_webhook_controller.ex, lines 50-52

Issue: Both Gaiia and PagerDuty webhook controllers skip signature verification when the webhook secret is nil or empty:

if is_nil(secret) or secret == "" do
  :ok  # Accepts any request without verification

This means if an integration is created without a webhook secret, anyone can send forged webhook events.

Fix: Reject webhooks when no secret is configured, or at minimum log a warning and require explicit opt-in to accept unsigned webhooks.


Medium Findings

10. Mobile Session Token Stored as Plaintext in Database

Severity: MEDIUM File: lib/towerops/mobile_sessions/mobile_session.ex Issue: Mobile session tokens are stored directly in the database without hashing. If the database is compromised, all active mobile sessions can be immediately impersonated. Compare this to the API token system which correctly hashes tokens with SHA-256.

Fix: Hash mobile session tokens before storage (like API tokens do) and compare using the hash on lookup.


11. No Rate Limiting on Admin API Endpoints

Severity: MEDIUM File: lib/towerops_web/router.ex, admin API scope (lines ~154-162) Issue: The admin API routes (MIB management, GeoIP import) use the api_v1 pipeline but don't include rate_limit_api. While these require superuser API tokens, a compromised superuser token could be used for DoS via unbounded MIB uploads.

Fix: Add :rate_limit_api to the admin API pipeline.


Severity: MEDIUM File: lib/towerops_web/endpoint.ex, lines 7-11 Issue: The session cookie is signed but not encrypted:

@session_options [
  store: :cookie,
  key: "_towerops_key",
  signing_salt: "hrDZxLhd",
  same_site: "Lax"
  # No encryption_salt
]

This means session data (including user tokens, organization IDs, impersonation state) can be read by anyone who intercepts the cookie, even though it can't be tampered with.

Fix: Add encryption_salt to encrypt session contents:

encryption_salt: "some-random-salt"

13. X-Frame-Options Inconsistency

Severity: MEDIUM Files:

  • lib/towerops_web/plugs/security_headers.ex: Sets x-frame-options: SAMEORIGIN
  • lib/towerops_web/router.ex CSP: Sets frame-ancestors 'none'

Issue: The CSP says "no framing at all" (frame-ancestors 'none') but X-Frame-Options says "same-origin framing is OK" (SAMEORIGIN). These are contradictory. When both are present, browsers should prefer frame-ancestors, but older browsers may use X-Frame-Options.

Fix: Make them consistent. If no framing is intended, use X-Frame-Options: DENY and frame-ancestors 'none'. If same-origin framing is needed, use frame-ancestors 'self'.


14. Security Headers Only in Production

Severity: MEDIUM File: lib/towerops_web/plugs/security_headers.ex, line 21 Issue: CSP, X-Frame-Options, X-Content-Type-Options, and Referrer-Policy headers are only added in production. Development and staging environments are unprotected, which also means developers can't test CSP compliance during development.

Fix: Apply security headers in all environments. Use a less restrictive CSP in development if needed, but don't skip headers entirely.


15. Agent WebSocket Channel Allows Unauthenticated Connection

Severity: MEDIUM File: lib/towerops_web/channels/agent_socket.ex, line 12 Issue: The socket connect/3 callback accepts all connections ({:ok, socket}). Authentication only happens at channel join time. This means anyone can establish a WebSocket connection and hold it open, consuming server resources.

Fix: While channel-level auth is acceptable for Phoenix, consider adding basic validation in connect/3 (e.g., require a connection param that can be pre-validated) or implement connection-level rate limiting.


16. GraphQL Query Depth/Complexity Not Limited

Severity: MEDIUM File: lib/towerops_web/graphql/schema.ex Issue: No query depth or complexity limits are configured. A malicious API user could craft deeply nested or expensive queries to cause DoS.

Fix: Add Absinthe complexity analysis:

use Absinthe.Schema
@pipeline_modifier Absinthe.Middleware.Complexity

17. Impersonation Lacks CSRF Protection for POST

Severity: MEDIUM File: lib/towerops_web/controllers/admin_controller.ex Issue: The start_impersonate action accepts a POST with user_id from path params. While it requires superuser auth and the browser pipeline includes CSRF protection, the user_id comes from the URL path (/admin/impersonate/:user_id) which means a link-based CSRF attack could trigger impersonation if CSRF token validation is somehow bypassed.

Fix: Consider requiring the target user_id in the POST body instead of URL params, and add explicit audit logging (which is already done — this is more of a defense-in-depth note).


Low Findings

18. Hardcoded Development Secrets in Source Control

Severity: LOW (dev-only, but noted) File: config/dev.exs

  • secret_key_base: "ZA80pnF0GDr5..." (line 111)
  • signing_salt: "hrDZxLhd" (endpoint.ex line 9)
  • Cloak key: "nQWmgQYhoCNXA3PAxwriKxLyPHAOWH9VgpLkBOXrowM=" (dev.exs)
  • Webhook secret: "dev-webhook-secret" (dev.exs)

Impact: These are dev-only values and production uses environment variables. However, if any of these leaked to production, it would be catastrophic.

Fix: Ensure CI/CD validates that production deployments use env vars, not compile-time defaults. Consider using System.fetch_env!/1 for all secrets.


19. Rate Limiting Disabled in Development

Severity: LOW File: config/dev.exs, last lines: config :towerops, :rate_limiting_enabled, false Issue: Rate limiting is disabled in dev. If dev instances are accidentally exposed, they have no brute-force protection.


20. check_origin: false in Development

Severity: LOW File: config/dev.exs Issue: WebSocket origin checking is disabled in development, which is expected but means dev deployments are vulnerable to cross-origin WebSocket hijacking.


Positive Security Controls Noted

The following good security practices were observed:

  1. Password hashing: Argon2 with proper timing-safe comparison and no_user_verify to prevent enumeration
  2. TOTP 2FA: Mandatory for all users, with recovery codes
  3. Session management: Token rotation, browser session tracking, session cleanup workers
  4. Sudo mode: Sensitive operations require re-authentication within 10 minutes
  5. Brute force protection: Multi-tier IP banning with escalation to Cloudflare WAF
  6. Rate limiting: Separate limits for auth (10/min) and API (1000/min) endpoints
  7. CSRF protection: Phoenix's built-in protect_from_forgery in browser pipeline
  8. Input sanitization: Search module properly escapes LIKE wildcards
  9. Encrypted credentials: SNMPv3 and MikroTik passwords use Cloak (AES-256-GCM) at rest
  10. Webhook HMAC validation: Gaiia and PagerDuty webhooks use constant-time comparison
  11. API tokens: Hashed with SHA-256 before storage, prefixed for identification
  12. Audit logging: Impersonation, login attempts tracked with IP/user-agent
  13. Organization scoping: All data access is scoped to organization_id
  14. Agent channel authorization: Token-based auth at join, organization verification on all results
  15. Ecto parameterized queries: All database queries use parameterized Ecto queries (no raw SQL injection)
  16. Path traversal protection: MIB upload validates vendor names and filenames
  17. Cookie consent: GDPR-compliant EU user detection and consent tracking
  18. Login attempt tracking: Failed and successful logins recorded for security monitoring
  19. Protobuf validation: Agent channel validates all incoming protobuf messages before processing
  20. User enumeration prevention: Login errors don't reveal whether email exists

Summary

Severity Count
Critical 3
High 6
Medium 8
Low 3

Priority fixes:

  1. Remove SNMP community string from logs (Critical #1)
  2. Remove SNMPv3 passwords from API responses (Critical #2)
  3. Remove SNMP community from GraphQL/REST (High #3)
  4. Fix remember-me cookie same_site value (High #4)
  5. Add HSTS headers (High #5)
  6. Remove unsafe-eval from CSP (High #6)