diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md new file mode 100644 index 00000000..1f264f65 --- /dev/null +++ b/SECURITY_AUDIT.md @@ -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) diff --git a/lib/towerops/mobile_sessions.ex b/lib/towerops/mobile_sessions.ex index 4b5afdb9..41fb9099 100644 --- a/lib/towerops/mobile_sessions.ex +++ b/lib/towerops/mobile_sessions.ex @@ -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 diff --git a/lib/towerops/mobile_sessions/mobile_session.ex b/lib/towerops/mobile_sessions/mobile_session.ex index 87542c6d..307a8a9b 100644 --- a/lib/towerops/mobile_sessions/mobile_session.ex +++ b/lib/towerops/mobile_sessions/mobile_session.ex @@ -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) diff --git a/lib/towerops/snmp.ex b/lib/towerops/snmp.ex index d5b7acbd..6cfc0aa9 100644 --- a/lib/towerops/snmp.ex +++ b/lib/towerops/snmp.ex @@ -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 diff --git a/lib/towerops_web/channels/agent_channel.ex b/lib/towerops_web/channels/agent_channel.ex index 6ef8d994..3cba17ad 100644 --- a/lib/towerops_web/channels/agent_channel.ex +++ b/lib/towerops_web/channels/agent_channel.ex @@ -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 diff --git a/lib/towerops_web/controllers/api/v1/devices_controller.ex b/lib/towerops_web/controllers/api/v1/devices_controller.ex index 5c3a9c00..fd95beb5 100644 --- a/lib/towerops_web/controllers/api/v1/devices_controller.ex +++ b/lib/towerops_web/controllers/api/v1/devices_controller.ex @@ -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 diff --git a/lib/towerops_web/controllers/api/v1/gaiia_webhook_controller.ex b/lib/towerops_web/controllers/api/v1/gaiia_webhook_controller.ex index 1c90cd7c..0fb50822 100644 --- a/lib/towerops_web/controllers/api/v1/gaiia_webhook_controller.ex +++ b/lib/towerops_web/controllers/api/v1/gaiia_webhook_controller.ex @@ -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") diff --git a/lib/towerops_web/controllers/api/v1/pagerduty_webhook_controller.ex b/lib/towerops_web/controllers/api/v1/pagerduty_webhook_controller.ex index fa50c0e1..f5ea7c77 100644 --- a/lib/towerops_web/controllers/api/v1/pagerduty_webhook_controller.ex +++ b/lib/towerops_web/controllers/api/v1/pagerduty_webhook_controller.ex @@ -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 diff --git a/lib/towerops_web/graphql/context.ex b/lib/towerops_web/graphql/context.ex index b5784938..7ffb954f 100644 --- a/lib/towerops_web/graphql/context.ex +++ b/lib/towerops_web/graphql/context.ex @@ -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 diff --git a/lib/towerops_web/graphql/types/device.ex b/lib/towerops_web/graphql/types/device.ex index e7e9cd40..98968d6e 100644 --- a/lib/towerops_web/graphql/types/device.ex +++ b/lib/towerops_web/graphql/types/device.ex @@ -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 diff --git a/lib/towerops_web/plugs/graphql_introspection.ex b/lib/towerops_web/plugs/graphql_introspection.ex new file mode 100644 index 00000000..0d71b688 --- /dev/null +++ b/lib/towerops_web/plugs/graphql_introspection.ex @@ -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 diff --git a/lib/towerops_web/plugs/security_headers.ex b/lib/towerops_web/plugs/security_headers.ex index 0c26d1f1..84dc758b 100644 --- a/lib/towerops_web/plugs/security_headers.ex +++ b/lib/towerops_web/plugs/security_headers.ex @@ -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'" ], "; " ) diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index 42502fdf..a657461d 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -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 diff --git a/lib/towerops_web/user_auth.ex b/lib/towerops_web/user_auth.ex index f39baac5..a2011e4c 100644 --- a/lib/towerops_web/user_auth.ex +++ b/lib/towerops_web/user_auth.ex @@ -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