towerops/lib/towerops_web/controllers/api/account_data_controller.ex
Graham McIntire 1d928d4356
security: implement comprehensive security audit fixes
Critical Fixes:
- Remove /health/time endpoint exposing system time information
  * Prevents attackers from detecting time sync issues for TOTP attacks
  * Removed route and controller function, updated tests
- Add email confirmation check to account data export
  * GDPR export now requires confirmed email address
  * Prevents unconfirmed accounts from accessing data export
- Add path traversal validation for MIB archive uploads
  * Extract to temp directory, validate all paths, then copy if safe
  * Prevents malicious tar/zip files from writing outside target directory
  * Added validate_extracted_paths/1 helper function

High Priority Fixes:
- Add comprehensive input validation for mobile auth
  * Length limits: device_name (255), device_os (100), app_version (50), push_token (512)
  * Prevents database corruption and storage exhaustion
- Add heartbeat rate limiting to agent channel
  * Limit database updates to once per 30 seconds (max ~2/min per agent)
  * Prevents malicious agents from exhausting database connections
- Sanitize 500 error responses
  * Return generic error messages to clients
  * Log full details server-side with request_id for support
  * Prevents leaking stack traces and module names
- Add message size limits to agent channel
  * 10MB maximum for all protobuf messages (result, heartbeat, error)
  * Prevents DoS attacks via oversized payloads

Medium Priority Fixes:
- Add GraphQL query depth limits (max_depth: 10)
  * Prevents DoS from deeply nested queries
  * Complements existing complexity limits

Code Quality:
- Refactor agent channel handlers to reduce nesting depth
  * Extract message processing into separate private functions
  * Fixes Credo warnings about excessive nesting
  * Improves code readability and maintainability

Files changed:
- lib/towerops_web/controllers/health_controller.ex
- lib/towerops_web/controllers/api/account_data_controller.ex
- lib/towerops_web/controllers/api/v1/mib_controller.ex
- lib/towerops/mobile_sessions/mobile_session.ex
- lib/towerops_web/channels/agent_channel.ex
- lib/towerops_web/controllers/error_json.ex
- lib/towerops_web/router.ex
- CHANGELOG.txt
- priv/static/changelog.txt
- test/towerops_web/controllers/health_controller_test.exs
- test/towerops_web/controllers/error_json_test.exs

All 7,424 tests passing.
2026-03-05 13:08:10 -06:00

171 lines
4.5 KiB
Elixir

defmodule ToweropsWeb.Api.AccountDataController do
@moduledoc """
API controller for GDPR Right to Access - allows users to download all their data in JSON format
"""
use ToweropsWeb, :controller
alias Towerops.Admin
alias Towerops.Admin.AuditLogger
alias Towerops.Alerts
alias Towerops.Devices
alias Towerops.Organizations
@doc """
Returns all user data in JSON format (GDPR Right to Access compliance)
"""
def show(conn, _params) do
user = conn.assigns.current_scope.user
# Verify user account is confirmed before allowing data export
if !user.confirmed_at do
conn
|> put_status(:forbidden)
|> json(%{error: "Email address must be confirmed before exporting account data"})
|> halt()
end
# Log the data export for audit trail
AuditLogger.log_user_data_exported(conn, user.id)
# Gather all user data
data = %{
profile: build_profile_data(user),
organizations: build_organizations_data(user),
devices: build_devices_data(user),
alerts: build_alerts_data(user),
audit_logs: build_audit_logs_data(user),
export_info: %{
exported_at: DateTime.utc_now(),
format: "JSON",
gdpr_article: "Article 15 - Right to Access"
}
}
conn
|> put_resp_content_type("application/json")
|> put_resp_header(
"content-disposition",
"attachment; filename=\"towerops-data-#{user.id}-#{DateTime.to_unix(DateTime.utc_now())}.json\""
)
|> json(data)
end
# Build profile data
defp build_profile_data(user) do
%{
id: user.id,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
timezone: user.timezone,
is_superuser: user.is_superuser,
confirmed_at: user.confirmed_at,
inserted_at: user.inserted_at,
updated_at: user.updated_at
}
end
# Build organizations data
defp build_organizations_data(user) do
user.id
|> Organizations.list_user_organizations()
|> Enum.map(fn org ->
# Organization has the user's membership preloaded (only their membership)
[membership] = org.memberships
%{
id: org.id,
name: org.name,
slug: org.slug,
role: if(membership.role == :owner, do: "owner", else: "member"),
inserted_at: org.inserted_at,
updated_at: org.updated_at
}
end)
end
# Build devices data
defp build_devices_data(user) do
org_ids =
user.id
|> Organizations.list_user_organizations()
|> Enum.map(& &1.id)
if Enum.empty?(org_ids) do
[]
else
org_ids
|> Devices.list_devices_for_organizations()
|> Enum.map(fn device ->
%{
id: device.id,
name: device.name,
ip_address: device.ip_address,
snmp_community: "[REDACTED]",
monitoring_enabled: device.monitoring_enabled,
snmp_enabled: device.snmp_enabled,
status: device.status,
organization: %{
id: device.organization_id,
name: device.organization.name
},
site: %{
id: device.site_id,
name: device.site.name
},
inserted_at: device.inserted_at,
updated_at: device.updated_at
}
end)
end
end
# Build alerts data (last 90 days)
defp build_alerts_data(user) do
org_ids =
user.id
|> Organizations.list_user_organizations()
|> Enum.map(& &1.id)
if Enum.empty?(org_ids) do
[]
else
ninety_days_ago = DateTime.add(DateTime.utc_now(), -90, :day)
org_ids
|> Alerts.list_alerts_for_organizations(since: ninety_days_ago)
|> Enum.map(fn alert ->
%{
id: alert.id,
alert_type: alert.alert_type,
message: alert.message,
triggered_at: alert.triggered_at,
resolved_at: alert.resolved_at,
acknowledged_at: alert.acknowledged_at,
device: %{
id: alert.device_id,
name: alert.device.name
},
inserted_at: alert.inserted_at
}
end)
end
end
# Build audit logs data
defp build_audit_logs_data(user) do
user.id
|> Admin.list_audit_logs_for_user(limit: 1000)
|> Enum.map(fn log ->
%{
id: log.id,
action: log.action,
actor_email: if(log.superuser, do: log.superuser.email),
target_user_id: log.target_user_id,
metadata: log.metadata,
ip_address: log.ip_address,
inserted_at: log.inserted_at
}
end)
end
end