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)
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, :stringlib/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.
4. Remember-Me Cookie same_site Set to Invalid Value
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-78lib/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.
12. Session Cookie Missing encryption_salt
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: Setsx-frame-options: SAMEORIGINlib/towerops_web/router.exCSP: Setsframe-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:
- Password hashing: Argon2 with proper timing-safe comparison and
no_user_verifyto prevent enumeration - TOTP 2FA: Mandatory for all users, with recovery codes
- Session management: Token rotation, browser session tracking, session cleanup workers
- Sudo mode: Sensitive operations require re-authentication within 10 minutes
- Brute force protection: Multi-tier IP banning with escalation to Cloudflare WAF
- Rate limiting: Separate limits for auth (10/min) and API (1000/min) endpoints
- CSRF protection: Phoenix's built-in
protect_from_forgeryin browser pipeline - Input sanitization: Search module properly escapes LIKE wildcards
- Encrypted credentials: SNMPv3 and MikroTik passwords use Cloak (AES-256-GCM) at rest
- Webhook HMAC validation: Gaiia and PagerDuty webhooks use constant-time comparison
- API tokens: Hashed with SHA-256 before storage, prefixed for identification
- Audit logging: Impersonation, login attempts tracked with IP/user-agent
- Organization scoping: All data access is scoped to organization_id
- Agent channel authorization: Token-based auth at join, organization verification on all results
- Ecto parameterized queries: All database queries use parameterized Ecto queries (no raw SQL injection)
- Path traversal protection: MIB upload validates vendor names and filenames
- Cookie consent: GDPR-compliant EU user detection and consent tracking
- Login attempt tracking: Failed and successful logins recorded for security monitoring
- Protobuf validation: Agent channel validates all incoming protobuf messages before processing
- User enumeration prevention: Login errors don't reveal whether email exists
Summary
| Severity | Count |
|---|---|
| Critical | 3 |
| High | 6 |
| Medium | 8 |
| Low | 3 |
Priority fixes:
- Remove SNMP community string from logs (Critical #1)
- Remove SNMPv3 passwords from API responses (Critical #2)
- Remove SNMP community from GraphQL/REST (High #3)
- Fix remember-me cookie
same_sitevalue (High #4) - Add HSTS headers (High #5)
- Remove
unsafe-evalfrom CSP (High #6)