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:
parent
b252cb81b9
commit
6a9b471d1f
9 changed files with 95 additions and 63 deletions
|
|
@ -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
48
bugs.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
28
lib/towerops/workers/sync_errors.ex
Normal file
28
lib/towerops/workers/sync_errors.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
%{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue