fix: 4 more high-severity bugs (H23, H26, H28, H29)

- H23: introduce Towerops.Workers.SyncErrors.transient?/1 classifier;
  uisp/preseem/cn_maestro sync workers now retry transient failures (5xx,
  timeouts, rate limits) via Oban while still discarding permanent errors
  (401/403/404) as :ok. Stale monitoring data is the bigger risk than an
  extra retry on a known-bad credential.
- H26: add Monitoring.get_latency_data_for_devices/2 batch counterpart
  (currently a stub map but called from SiteLive.Show so any future ping
  implementation lands in a single query, not one-per-device).
- H28: Nominatim search popups in the coverage map now bind via a text
  node instead of an HTML string, so a compromised/poisoned response
  can't execute as JS in the popup.
- H29: yaml_profiles dot-boundary OID prefix match (handles optional
  trailing dot from YAML profile keys). Stops 1.3.6.1.4.1.9 from
  incorrectly matching 1.3.6.1.4.1.99.
This commit is contained in:
Graham McIntire 2026-05-12 11:14:03 -05:00
parent b252cb81b9
commit 6a9b471d1f
9 changed files with 95 additions and 63 deletions

View file

@ -642,7 +642,11 @@ export const MultiCoverageMap = {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return
this.map.setView([lat, lon], 16)
if (this.searchMarker) this.map.removeLayer(this.searchMarker)
this.searchMarker = L.marker([lat, lon]).addTo(this.map).bindPopup(data[0].display_name).openPopup()
// Bind via a text node, not an HTML string — Nominatim's display_name
// is third-party data and would otherwise execute as HTML/JS in the popup.
const popupEl = document.createElement("div")
popupEl.textContent = data[0].display_name
this.searchMarker = L.marker([lat, lon]).addTo(this.map).bindPopup(popupEl).openPopup()
} catch (err) {
console.error("Coverage search failed:", err)
}

48
bugs.md
View file

@ -121,18 +121,6 @@
---
### H23. Sync Workers Silently Swallow Errors
**Files:** All sync workers (uisp, preseem, cn_maestro, sonar, splynx, visp, netbox, gaiia)
**Severity:** HIGH — Transient failures NEVER retried; data silently stale
**Description:** All sync workers log errors but return `:ok`. Network timeouts, API rate limits, DNS failures are all silently accepted. Data goes stale until next cron cycle.
**Fix:** Distinguish permanent (return `:ok`/`:discard`) from transient (return `{:error, reason}` for Oban retry).
---
### H24. WeatherSyncWorker Never Starts
**File:** `lib/towerops/workers/weather_sync_worker.ex`
@ -157,42 +145,6 @@
---
### H26. N+1 Latency Queries per Site Device
**File:** `lib/towerops_web/live/site_live/show.ex:101-128`
**Severity:** HIGH — 50+ queries for large sites
**Description:** Loops through devices calling `Monitoring.get_latency_data` per device. For 50+ devices at a site, 50+ individual queries.
**Fix:** Batch the latency query or add pagination/limit.
---
### H28. Nominatim API Response Injected via innerHTML
**File:** `assets/js/hooks/coverage_hooks.ts:635-645`
**Severity:** HIGH — Third-party API response flows into innerHTML
**Description:** Nominatim search API `display_name` is passed directly to `marker.bindPopup()` (innerHTML). If Nominatim is compromised or DNS poisoned, arbitrary JS executes.
**Fix:** Sanitize `display_name` with `escapeHtml()` before `bindPopup()`.
---
### H29. OID Injection via Substring Match in Profiles
**File:** `lib/towerops/profiles/yaml_profiles.ex:788`
**Severity:** HIGH — Incorrect profile matching due to partial OID match
**Description:** `String.contains?(sys_object_id, block.oid)` matches partial octets. OID `1.3.6.1.4.1.9` would incorrectly match `1.3.6.1.4.1.99` because "9" is a substring of "99".
**Fix:** Use `String.starts_with?(sys_object_id <> ".", block.oid <> ".")` for dot-boundary matching.
---
## MEDIUM
### M1. Resource Scoping Reveals Org Membership

View file

@ -646,4 +646,16 @@ defmodule Towerops.Monitoring do
@doc false
def get_latency_data(_device_or_site_id, _opts), do: []
@doc """
Batch counterpart to `get_latency_data/2`. Returns a map of
`%{device_id => [data_points]}` for the given device IDs. Currently a
stub returning an empty map for every device wired through the
callers so a future ping-based implementation can replace it without
N+1 queries (one call per site vs one call per device).
"""
@spec get_latency_data_for_devices([term()], keyword()) :: %{term() => list()}
def get_latency_data_for_devices(device_ids, _opts) when is_list(device_ids) do
Map.new(device_ids, &{&1, []})
end
end

View file

@ -784,8 +784,22 @@ defmodule Towerops.Profiles.YamlProfiles do
end
end
# Dot-boundary match: `1.3.6.1.4.1.9` must match `1.3.6.1.4.1.9` and
# `1.3.6.1.4.1.9.X` but NOT `1.3.6.1.4.1.99`. Plain `String.contains?` is
# unsafe for OID prefix checks because "9" is a substring of "99".
# Some profile YAMLs end the prefix with a trailing dot ("1.3.6.1.4.1.25506.")
# — normalize that off before comparing.
defp oid_prefix_match?(sys_object_id, block_oid) when is_binary(sys_object_id) and is_binary(block_oid) do
prefix = String.trim_trailing(block_oid, ".")
sys_object_id == prefix or
String.starts_with?(sys_object_id, prefix <> ".")
end
defp oid_prefix_match?(_, _), do: false
defp block_matches?(block, sys_object_id, sys_descr, match_conditional, client_opts) do
oid_match = block.oid && String.contains?(sys_object_id, block.oid)
oid_match = block.oid && oid_prefix_match?(sys_object_id, block.oid)
condition_match = block.has_condition == match_conditional
descr_match = block_matches_descr?(block, sys_descr)
snmpget_match = block_matches_snmpget?(block, match_conditional, client_opts)

View file

@ -9,6 +9,7 @@ defmodule Towerops.Workers.CnMaestroSyncWorker do
alias Towerops.CnMaestro.Sync
alias Towerops.Integrations
alias Towerops.Workers.PollingOffset
alias Towerops.Workers.SyncErrors
require Logger
@ -50,19 +51,27 @@ defmodule Towerops.Workers.CnMaestroSyncWorker do
def perform(%Oban.Job{args: %{"integration_id" => id}}) do
case Integrations.get_integration_by_id(id) do
{:ok, integration} ->
case Sync.sync_organization(integration) do
{:ok, result} ->
Logger.info("cnMaestro sync completed: #{inspect(result)}")
:ok
{:error, reason} ->
Logger.error("cnMaestro sync failed: #{inspect(reason)}")
:ok
end
sync_integration(integration)
{:error, :not_found} ->
Logger.warning("cnMaestro sync: integration #{id} not found")
:ok
end
end
defp sync_integration(integration) do
case Sync.sync_organization(integration) do
{:ok, result} ->
Logger.info("cnMaestro sync completed: #{inspect(result)}")
:ok
{:error, reason} ->
Logger.error("cnMaestro sync failed: #{inspect(reason)}")
# Distinguish permanent failures (auth, bad config) from transient
# ones (network, rate limits, 5xx) so Oban retries the latter —
# stale monitoring data is worse than a redundant retry on a
# permanent failure.
if SyncErrors.transient?(reason), do: {:error, reason}, else: :ok
end
end
end

View file

@ -15,6 +15,7 @@ defmodule Towerops.Workers.PreseemSyncWorker do
alias Towerops.Integrations
alias Towerops.Preseem.Sync
alias Towerops.Workers.PollingOffset
alias Towerops.Workers.SyncErrors
require Logger
@ -71,7 +72,11 @@ defmodule Towerops.Workers.PreseemSyncWorker do
{:error, reason} ->
Logger.error("Preseem sync failed for org #{integration.organization_id}: #{inspect(reason)}")
:ok
# Distinguish permanent failures (auth, bad config) from transient
# ones (network, rate limits, 5xx) so Oban retries the latter —
# stale monitoring data is worse than a redundant retry on a
# permanent failure.
if SyncErrors.transient?(reason), do: {:error, reason}, else: :ok
end
end

View file

@ -0,0 +1,28 @@
defmodule Towerops.Workers.SyncErrors do
@moduledoc """
Classifier for vendor-sync error reasons.
Sync workers (UISP, Preseem, cnMaestro, etc.) call third-party APIs that
can fail for permanent reasons (bad credentials, missing resources) or
transient ones (network blips, rate limits, 5xx). Permanent failures
should not be retried they will keep failing until config is fixed.
Transient failures should retry so data doesn't go stale waiting on the
next cron cycle.
"""
@doc """
Returns `true` if the error reason is likely transient and worth retrying.
Unknown error shapes default to transient: stale monitoring data is the
bigger risk than an extra retry on a permanent failure.
"""
@spec transient?(term()) :: boolean()
def transient?(:unauthorized), do: false
def transient?(:forbidden), do: false
def transient?(:not_found), do: false
def transient?(:invalid_credentials), do: false
def transient?(:bad_request), do: false
def transient?({:http_error, status, _}) when status in 400..499, do: false
def transient?({:http_error, status}) when status in 400..499, do: false
def transient?(_), do: true
end

View file

@ -15,6 +15,7 @@ defmodule Towerops.Workers.UispSyncWorker do
alias Towerops.Integrations
alias Towerops.Uisp.Sync
alias Towerops.Workers.PollingOffset
alias Towerops.Workers.SyncErrors
require Logger
@ -69,7 +70,10 @@ defmodule Towerops.Workers.UispSyncWorker do
{:error, reason} ->
Logger.error("UISP sync failed for org #{integration.organization_id}: #{inspect(reason)}")
:ok
# Distinguish permanent failures (auth, bad config) from transient
# ones (network, rate limits, 5xx) so Oban retries the latter and
# data doesn't stay stale until the next cron tick.
if SyncErrors.transient?(reason), do: {:error, reason}, else: :ok
end
end

View file

@ -103,13 +103,17 @@ defmodule ToweropsWeb.SiteLive.Show do
nil
else
twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour)
device_ids = Enum.map(devices, & &1.id)
latency_by_device =
Monitoring.get_latency_data_for_devices(device_ids, since: twenty_four_hours_ago, limit: 1000)
datasets =
devices
|> Enum.map(fn device ->
checks =
device.id
|> Monitoring.get_latency_data(since: twenty_four_hours_ago, limit: 1000)
latency_by_device
|> Map.get(device.id, [])
|> Enum.reverse()
%{