fix: 3 more high-severity bugs (H19, H20, H25)

- H19: /api/v1/mobile/auth/qr/verify no longer returns user_email. Knowing
  a QR token now only tells the caller the token is valid; the email is
  only revealed by /complete which consumes the token.
- H20: CoverageWorker max_attempts dropped from 3 to 1. The fail/2 path
  already returns :ok and writes the failure onto the coverage record,
  so the 3-attempt retry policy was never reachable and was misleading.
- H25: ChecksController.create rejects device_ids that don't belong to
  the caller's organization (via ScopedResource.fetch). Without this an
  API token could attach service checks to devices in another tenant.

Also strip bug ID references from in-code comments (per feedback that
H/M/L numbers don't survive past their bugs.md removal); commit history
keeps the audit trail.
This commit is contained in:
Graham McIntire 2026-05-12 11:32:20 -05:00
parent d9afb6a3e4
commit abfd44fd75
7 changed files with 53 additions and 49 deletions

24
bugs.md
View file

@ -55,30 +55,6 @@
---
### H19. QR Token Verify Leaks User Email
**File:** `lib/towerops_web/controllers/api/mobile_auth_controller.ex:43-45`
**Severity:** HIGH — Token validity + user email disclosed to anyone with a QR token
**Description:** `json(conn, %{valid: true, user_email: qr_token.user.email})` — reveals the associated email on token verify.
**Fix:** Only return `valid: true/false`. Don't disclose email until token is consumed.
---
### H20. CoverageWorker Contradictory Retry Settings
**File:** `lib/towerops/workers/coverage_worker.ex:23-25, 485-486`
**Severity:** HIGH — `max_attempts: 3` but `fail/2` returns `:ok`
**Description:** Configured for retries, but the `fail` path returns `:ok`, preventing Oban from ever retrying. Misleading configuration.
**Fix:** Remove `max_attempts` (use default 1) or return `{:error, reason}` for transient failures.
---
### H21. Unbounded Sequential Processing in Sync Workers
**Files:**

View file

@ -20,9 +20,12 @@ defmodule Towerops.Workers.CoverageWorker do
worker refuses to run if the loaded coverage's org doesn't match.
"""
# max_attempts: 1 — `fail/2` returns :ok and writes the failure onto the
# coverage row itself, so there's nothing for Oban to retry. The previous
# max_attempts: 3 implied retries that never actually happened.
use Oban.Worker,
queue: :coverage,
max_attempts: 3,
max_attempts: 1,
unique: [
fields: [:args],
keys: [:coverage_id],

View file

@ -36,14 +36,12 @@ defmodule ToweropsWeb.Api.MobileAuthController do
|> put_status(:unauthorized)
|> json(%{valid: false, error: "Invalid or expired token"})
qr_token ->
# Preload user to return email
qr_token = Towerops.Repo.preload(qr_token, :user)
json(conn, %{
valid: true,
user_email: qr_token.user.email
})
_qr_token ->
# Don't leak the associated user's email here. Anyone who guesses or
# captures a QR token would otherwise be able to enumerate emails by
# calling /verify. The email is only meaningful at /complete (which
# consumes the token and creates a session).
json(conn, %{valid: true})
end
end

View file

@ -10,6 +10,7 @@ defmodule ToweropsWeb.Api.V1.ChecksController do
import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1]
alias Towerops.Devices.Device
alias Towerops.Monitoring
alias Towerops.Monitoring.Check
alias ToweropsWeb.Api.ParamFilter
@ -50,17 +51,28 @@ defmodule ToweropsWeb.Api.V1.ChecksController do
check_type = Map.get(check_params, "check_type", "")
if check_type in @service_check_types do
attrs =
check_params
|> ParamFilter.strip_sensitive()
|> Map.put("organization_id", organization_id)
cond do
check_type not in @service_check_types ->
conn
|> put_status(:bad_request)
|> json(%{
error: "Invalid check type '#{check_type}'. Must be one of: #{Enum.join(@service_check_types, ", ")}"
})
create_and_respond(conn, attrs)
else
conn
|> put_status(:bad_request)
|> json(%{error: "Invalid check type '#{check_type}'. Must be one of: #{Enum.join(@service_check_types, ", ")}"})
not device_in_org?(check_params["device_id"], organization_id) ->
# Reject up-front so an attacker can't attach a check to a device in
# another organization by guessing its ID.
conn
|> put_status(:forbidden)
|> json(%{error: "Access denied to the referenced device"})
true ->
attrs =
check_params
|> ParamFilter.strip_sensitive()
|> Map.put("organization_id", organization_id)
create_and_respond(conn, attrs)
end
end
@ -180,6 +192,18 @@ defmodule ToweropsWeb.Api.V1.ChecksController do
end
end
# Verify that an inbound device_id refers to a device in the caller's
# organization. Returns true when device_id is missing (the changeset
# validation will then reject it as required).
defp device_in_org?(nil, _organization_id), do: true
defp device_in_org?(device_id, organization_id) do
case ScopedResource.fetch(Device, device_id, organization_id) do
{:ok, _device} -> true
_ -> false
end
end
defp format_check(check) do
%{
id: check.id,

View file

@ -17,9 +17,9 @@ defmodule Towerops.VaultTest do
end
test "overrides ciphers when CLOAK_KEY env is set in :prod" do
# The env-var override only fires under :env == :prod (L5) so a
# dev shell with a prod CLOAK_KEY set doesn't encrypt local data
# with the prod key. Flip :env for the duration of the test.
# The env-var override only fires under :env == :prod so a dev
# shell with a prod CLOAK_KEY set doesn't encrypt local data with
# the prod key. Flip :env for the duration of the test.
original_env = System.get_env("CLOAK_KEY")
original_env_setting = Application.get_env(:towerops, :env)
key = 32 |> :crypto.strong_rand_bytes() |> Base.encode64()

View file

@ -18,7 +18,7 @@ defmodule ToweropsWeb.Api.MobileAuthControllerTest do
assert json_response(conn, 401)["valid"] == false
end
test "returns valid + user_email for an active QR token", %{conn: conn} do
test "returns valid for an active QR token without leaking the user email", %{conn: conn} do
user = user_fixture()
{:ok, qr_token} = MobileSessions.create_qr_login_token(user.id)
@ -26,7 +26,10 @@ defmodule ToweropsWeb.Api.MobileAuthControllerTest do
response = json_response(conn, 200)
assert response["valid"] == true
assert response["user_email"] == user.email
# Email must not be returned by /verify (only by /complete, once the
# token is consumed). Otherwise anyone with a QR token can read the
# linked user's email.
refute Map.has_key?(response, "user_email")
end
end

View file

@ -8,7 +8,7 @@ defmodule ToweropsWeb.HealthControllerTest do
result = Jason.decode!(body)
assert result["status"] == "ok"
assert result["database"] == "connected"
# `version` deliberately omitted (L6): exposing the app version on a
# `version` deliberately omitted: exposing the app version on a
# public health endpoint helps attackers fingerprint vulnerable builds.
refute Map.has_key?(result, "version")
end