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)
This commit is contained in:
parent
51d31d4b7b
commit
34fe5d7e49
14 changed files with 401 additions and 41 deletions
307
SECURITY_AUDIT.md
Normal file
307
SECURITY_AUDIT.md
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
# 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:
|
||||
```elixir
|
||||
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.
|
||||
|
||||
```elixir
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
### 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.
|
||||
|
||||
```elixir
|
||||
@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:
|
||||
```elixir
|
||||
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:
|
||||
```elixir
|
||||
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:
|
||||
```elixir
|
||||
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:
|
||||
```elixir
|
||||
@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:
|
||||
```elixir
|
||||
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:
|
||||
```elixir
|
||||
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)
|
||||
|
|
@ -37,9 +37,10 @@ defmodule Towerops.MobileSessions do
|
|||
"""
|
||||
def get_session_by_token(token) when is_binary(token) do
|
||||
now = DateTime.utc_now()
|
||||
hashed = MobileSession.hash_token(token)
|
||||
|
||||
MobileSession
|
||||
|> where([s], s.token == ^token)
|
||||
|> where([s], s.token == ^hashed)
|
||||
|> where([s], s.expires_at > ^now)
|
||||
|> Repo.one()
|
||||
end
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ defmodule Towerops.MobileSessions.MobileSession do
|
|||
field :push_token, :string
|
||||
field :push_platform, :string
|
||||
|
||||
field :raw_token, :string, virtual: true
|
||||
|
||||
belongs_to :user, Towerops.Accounts.User
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
|
|
@ -66,10 +68,18 @@ defmodule Towerops.MobileSessions.MobileSession do
|
|||
if get_field(changeset, :token) do
|
||||
changeset
|
||||
else
|
||||
put_change(changeset, :token, generate_token())
|
||||
token = generate_token()
|
||||
changeset
|
||||
|> put_change(:token, hash_token(token))
|
||||
|> put_change(:raw_token, token)
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
def hash_token(token) do
|
||||
:crypto.hash(:sha256, token) |> Base.url_encode64(padding: false)
|
||||
end
|
||||
|
||||
defp put_timestamps(changeset) do
|
||||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
default_expiration = now |> DateTime.add(90, :day) |> DateTime.truncate(:second)
|
||||
|
|
|
|||
|
|
@ -720,7 +720,7 @@ defmodule Towerops.Snmp do
|
|||
from(e in DeviceSchema,
|
||||
join: s in assoc(e, :site),
|
||||
where: s.organization_id == ^organization_id,
|
||||
where: fragment("LOWER(?) LIKE ?", e.name, ^"%#{normalized_name}%"),
|
||||
where: fragment("LOWER(?) LIKE ?", e.name, ^"%#{sanitize_like(normalized_name)}%"),
|
||||
select: e,
|
||||
limit: 1
|
||||
)
|
||||
|
|
@ -2278,4 +2278,11 @@ defmodule Towerops.Snmp do
|
|||
|
||||
"#{a}.#{b}.#{c}.#{d}"
|
||||
end
|
||||
|
||||
defp sanitize_like(str) do
|
||||
str
|
||||
|> String.replace("\\", "\\\\")
|
||||
|> String.replace("%", "\\%")
|
||||
|> String.replace("_", "\\_")
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -291,7 +291,7 @@ defmodule ToweropsWeb.AgentChannel do
|
|||
# Handle PubSub broadcast when credential test is requested
|
||||
def handle_info({:credential_test_requested, test_id, snmp_config}, socket) do
|
||||
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}"
|
||||
"AgentChannel sending credential test to agent: ip=#{snmp_config[:ip]} port=#{snmp_config[:port]} version=#{snmp_config[:version]} community_present=#{!is_nil(snmp_config[:community])} test_id=#{test_id}"
|
||||
)
|
||||
|
||||
# Build SNMP device from config
|
||||
|
|
|
|||
|
|
@ -316,9 +316,9 @@ defmodule ToweropsWeb.Api.V1.DevicesController do
|
|||
snmpv3_security_level: device.snmpv3_security_level,
|
||||
snmpv3_username: device.snmpv3_username,
|
||||
snmpv3_auth_protocol: device.snmpv3_auth_protocol,
|
||||
snmpv3_auth_password: device.snmpv3_auth_password,
|
||||
snmpv3_auth_password_set: !is_nil(device.snmpv3_auth_password),
|
||||
snmpv3_priv_protocol: device.snmpv3_priv_protocol,
|
||||
snmpv3_priv_password: device.snmpv3_priv_password,
|
||||
snmpv3_priv_password_set: !is_nil(device.snmpv3_priv_password),
|
||||
inserted_at: device.inserted_at
|
||||
}
|
||||
end
|
||||
|
|
@ -339,9 +339,9 @@ defmodule ToweropsWeb.Api.V1.DevicesController do
|
|||
snmpv3_security_level: device.snmpv3_security_level,
|
||||
snmpv3_username: device.snmpv3_username,
|
||||
snmpv3_auth_protocol: device.snmpv3_auth_protocol,
|
||||
snmpv3_auth_password: device.snmpv3_auth_password,
|
||||
snmpv3_auth_password_set: !is_nil(device.snmpv3_auth_password),
|
||||
snmpv3_priv_protocol: device.snmpv3_priv_protocol,
|
||||
snmpv3_priv_password: device.snmpv3_priv_password,
|
||||
snmpv3_priv_password_set: !is_nil(device.snmpv3_priv_password),
|
||||
inserted_at: device.inserted_at
|
||||
}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -35,6 +35,12 @@ defmodule ToweropsWeb.Api.V1.GaiiaWebhookController do
|
|||
with {:ok, integration} <- get_gaiia_integration(organization_id),
|
||||
:ok <- verify_webhook(conn, integration) do
|
||||
process_webhook(conn, organization_id)
|
||||
else
|
||||
{:error, :no_secret_configured} ->
|
||||
conn |> put_status(:forbidden) |> json(%{error: "Webhook secret not configured"})
|
||||
|
||||
{:error, reason} ->
|
||||
conn |> put_status(:unauthorized) |> json(%{error: "Webhook verification failed: #{reason}"})
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -74,7 +80,8 @@ defmodule ToweropsWeb.Api.V1.GaiiaWebhookController do
|
|||
|
||||
cond do
|
||||
is_nil(secret) or secret == "" ->
|
||||
:ok
|
||||
Logger.warning("Gaiia webhook: rejected - no webhook secret configured")
|
||||
{:error, :no_secret_configured}
|
||||
|
||||
sig_headers == [] ->
|
||||
Logger.warning("Gaiia webhook: secret configured but no signature header received")
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ defmodule ToweropsWeb.Api.V1.PagerdutyWebhookController do
|
|||
{:error, :invalid_signature} ->
|
||||
conn |> put_status(401) |> json(%{error: "Invalid signature"})
|
||||
|
||||
{:error, :no_secret_configured} ->
|
||||
conn |> put_status(403) |> json(%{error: "Webhook secret not configured"})
|
||||
|
||||
{:error, _reason} ->
|
||||
conn |> put_status(500) |> json(%{error: "Internal error"})
|
||||
end
|
||||
|
|
@ -52,7 +55,8 @@ defmodule ToweropsWeb.Api.V1.PagerdutyWebhookController do
|
|||
webhook_secret = get_in(integration.credentials, ["webhook_secret"])
|
||||
|
||||
if is_nil(webhook_secret) or webhook_secret == "" do
|
||||
:ok
|
||||
Logger.warning("PagerDuty webhook: rejected - no webhook secret configured")
|
||||
{:error, :no_secret_configured}
|
||||
else
|
||||
check_pagerduty_hmac(conn, raw_body, webhook_secret, integration.organization_id)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -11,7 +11,15 @@ defmodule ToweropsWeb.GraphQL.Context do
|
|||
|
||||
def call(conn, _opts) do
|
||||
context = build_context(conn)
|
||||
Absinthe.Plug.put_options(conn, context: context)
|
||||
|
||||
opts =
|
||||
if Application.get_env(:towerops, :env) == :prod do
|
||||
[context: context, analyze_complexity: true, max_complexity: 500]
|
||||
else
|
||||
[context: context]
|
||||
end
|
||||
|
||||
Absinthe.Plug.put_options(conn, opts)
|
||||
end
|
||||
|
||||
defp build_context(conn) do
|
||||
|
|
|
|||
|
|
@ -17,7 +17,10 @@ defmodule ToweropsWeb.GraphQL.Types.Device do
|
|||
# SNMP fields
|
||||
field :snmp_enabled, :boolean
|
||||
field :snmp_version, :string
|
||||
field :snmp_community, :string
|
||||
field :snmp_community_set, :boolean do
|
||||
resolve(fn device, _, _ -> {:ok, !is_nil(device.snmp_community)} end)
|
||||
end
|
||||
|
||||
field :snmp_community_source, :string
|
||||
field :snmp_port, :integer
|
||||
field :snmp_transport, :string
|
||||
|
|
|
|||
32
lib/towerops_web/plugs/graphql_introspection.ex
Normal file
32
lib/towerops_web/plugs/graphql_introspection.ex
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
defmodule ToweropsWeb.Plugs.GraphQLIntrospection do
|
||||
@moduledoc """
|
||||
Blocks GraphQL introspection queries in production.
|
||||
|
||||
Introspection exposes the full schema to API consumers. This is useful
|
||||
in development but should be disabled in production to reduce attack surface.
|
||||
"""
|
||||
import Plug.Conn
|
||||
|
||||
def init(opts), do: opts
|
||||
|
||||
def call(conn, _opts) do
|
||||
if Application.get_env(:towerops, :env) == :prod and introspection_query?(conn) do
|
||||
conn
|
||||
|> put_resp_content_type("application/json")
|
||||
|> send_resp(400, Jason.encode!(%{errors: [%{message: "Introspection is disabled"}]}))
|
||||
|> halt()
|
||||
else
|
||||
conn
|
||||
end
|
||||
end
|
||||
|
||||
defp introspection_query?(conn) do
|
||||
case conn.body_params do
|
||||
%{"query" => query} when is_binary(query) ->
|
||||
String.contains?(query, "__schema") or String.contains?(query, "__type")
|
||||
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
defmodule ToweropsWeb.Plugs.SecurityHeaders do
|
||||
@moduledoc """
|
||||
Adds security headers to all responses in production.
|
||||
Adds security headers to all responses.
|
||||
|
||||
Headers added:
|
||||
- Content-Security-Policy: Restricts resource loading to prevent XSS
|
||||
|
|
@ -8,9 +8,6 @@ defmodule ToweropsWeb.Plugs.SecurityHeaders do
|
|||
- X-Content-Type-Options: Prevents MIME type sniffing
|
||||
- Referrer-Policy: Controls referrer information leakage
|
||||
- Permissions-Policy: Disables browsing-topics API
|
||||
|
||||
These headers are only applied in production to avoid interfering with
|
||||
development tools like LiveReload.
|
||||
"""
|
||||
|
||||
import Plug.Conn
|
||||
|
|
@ -20,39 +17,22 @@ defmodule ToweropsWeb.Plugs.SecurityHeaders do
|
|||
def call(conn, _opts) do
|
||||
conn
|
||||
|> put_resp_header("permissions-policy", "browsing-topics=()")
|
||||
|> maybe_add_prod_headers()
|
||||
|> put_resp_header("content-security-policy", csp_header())
|
||||
|> put_resp_header("x-frame-options", "DENY")
|
||||
|> put_resp_header("x-content-type-options", "nosniff")
|
||||
|> put_resp_header("referrer-policy", "strict-origin-when-cross-origin")
|
||||
end
|
||||
|
||||
defp maybe_add_prod_headers(conn) do
|
||||
if Application.get_env(:towerops, :env) == :prod do
|
||||
conn
|
||||
|> put_resp_header("content-security-policy", csp_header())
|
||||
|> put_resp_header("x-frame-options", "SAMEORIGIN")
|
||||
|> put_resp_header("x-content-type-options", "nosniff")
|
||||
|> put_resp_header("referrer-policy", "strict-origin-when-cross-origin")
|
||||
else
|
||||
conn
|
||||
end
|
||||
end
|
||||
|
||||
# Content Security Policy
|
||||
# - default-src 'self': Only load resources from same origin by default
|
||||
# - script-src: Allow inline scripts (needed for LiveView), eval, and self
|
||||
# - style-src: Allow inline styles (needed for Tailwind), unsafe-hashes, and self
|
||||
# - img-src: Allow images from self, data URIs, and https
|
||||
# - font-src: Allow fonts from self and data URIs
|
||||
# - connect-src: Allow WebSocket connections for LiveView
|
||||
# - frame-ancestors: Same as X-Frame-Options (SAMEORIGIN)
|
||||
defp csp_header do
|
||||
Enum.join(
|
||||
[
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
||||
"script-src 'self' 'unsafe-inline'",
|
||||
"style-src 'self' 'unsafe-inline' 'unsafe-hashes'",
|
||||
"img-src 'self' data: https:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self' ws: wss:",
|
||||
"frame-ancestors 'self'"
|
||||
"frame-ancestors 'none'"
|
||||
],
|
||||
"; "
|
||||
)
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ defmodule ToweropsWeb.Router do
|
|||
# WebSocket connection required for LiveView real-time updates
|
||||
"content-security-policy" =>
|
||||
"default-src 'self'; " <>
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " <>
|
||||
"script-src 'self' 'unsafe-inline'; " <>
|
||||
"style-src 'self' 'unsafe-inline'; " <>
|
||||
"img-src 'self' data: https:; " <>
|
||||
"font-src 'self' data:; " <>
|
||||
|
|
@ -65,6 +65,7 @@ defmodule ToweropsWeb.Router do
|
|||
end
|
||||
|
||||
pipeline :graphql_context do
|
||||
plug ToweropsWeb.Plugs.GraphQLIntrospection
|
||||
plug ToweropsWeb.GraphQL.Context
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ defmodule ToweropsWeb.UserAuth do
|
|||
@remember_me_options [
|
||||
sign: true,
|
||||
max_age: @max_cookie_age_in_days * 24 * 60 * 60,
|
||||
same_site: "Towerops"
|
||||
same_site: "Lax"
|
||||
]
|
||||
|
||||
# How old the session token should be before a new one is issued. When a request is made
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue