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
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return
|
||||||
this.map.setView([lat, lon], 16)
|
this.map.setView([lat, lon], 16)
|
||||||
if (this.searchMarker) this.map.removeLayer(this.searchMarker)
|
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) {
|
} catch (err) {
|
||||||
console.error("Coverage search failed:", 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
|
### H24. WeatherSyncWorker Never Starts
|
||||||
|
|
||||||
**File:** `lib/towerops/workers/weather_sync_worker.ex`
|
**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
|
## MEDIUM
|
||||||
|
|
||||||
### M1. Resource Scoping Reveals Org Membership
|
### M1. Resource Scoping Reveals Org Membership
|
||||||
|
|
|
||||||
|
|
@ -646,4 +646,16 @@ defmodule Towerops.Monitoring do
|
||||||
|
|
||||||
@doc false
|
@doc false
|
||||||
def get_latency_data(_device_or_site_id, _opts), do: []
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -784,8 +784,22 @@ defmodule Towerops.Profiles.YamlProfiles do
|
||||||
end
|
end
|
||||||
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
|
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
|
condition_match = block.has_condition == match_conditional
|
||||||
descr_match = block_matches_descr?(block, sys_descr)
|
descr_match = block_matches_descr?(block, sys_descr)
|
||||||
snmpget_match = block_matches_snmpget?(block, match_conditional, client_opts)
|
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.CnMaestro.Sync
|
||||||
alias Towerops.Integrations
|
alias Towerops.Integrations
|
||||||
alias Towerops.Workers.PollingOffset
|
alias Towerops.Workers.PollingOffset
|
||||||
|
alias Towerops.Workers.SyncErrors
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
|
@ -50,19 +51,27 @@ defmodule Towerops.Workers.CnMaestroSyncWorker do
|
||||||
def perform(%Oban.Job{args: %{"integration_id" => id}}) do
|
def perform(%Oban.Job{args: %{"integration_id" => id}}) do
|
||||||
case Integrations.get_integration_by_id(id) do
|
case Integrations.get_integration_by_id(id) do
|
||||||
{:ok, integration} ->
|
{:ok, integration} ->
|
||||||
case Sync.sync_organization(integration) do
|
sync_integration(integration)
|
||||||
{:ok, result} ->
|
|
||||||
Logger.info("cnMaestro sync completed: #{inspect(result)}")
|
|
||||||
:ok
|
|
||||||
|
|
||||||
{:error, reason} ->
|
|
||||||
Logger.error("cnMaestro sync failed: #{inspect(reason)}")
|
|
||||||
:ok
|
|
||||||
end
|
|
||||||
|
|
||||||
{:error, :not_found} ->
|
{:error, :not_found} ->
|
||||||
Logger.warning("cnMaestro sync: integration #{id} not found")
|
Logger.warning("cnMaestro sync: integration #{id} not found")
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ defmodule Towerops.Workers.PreseemSyncWorker do
|
||||||
alias Towerops.Integrations
|
alias Towerops.Integrations
|
||||||
alias Towerops.Preseem.Sync
|
alias Towerops.Preseem.Sync
|
||||||
alias Towerops.Workers.PollingOffset
|
alias Towerops.Workers.PollingOffset
|
||||||
|
alias Towerops.Workers.SyncErrors
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
|
@ -71,7 +72,11 @@ defmodule Towerops.Workers.PreseemSyncWorker do
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
Logger.error("Preseem sync failed for org #{integration.organization_id}: #{inspect(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
|
||||||
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.Integrations
|
||||||
alias Towerops.Uisp.Sync
|
alias Towerops.Uisp.Sync
|
||||||
alias Towerops.Workers.PollingOffset
|
alias Towerops.Workers.PollingOffset
|
||||||
|
alias Towerops.Workers.SyncErrors
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
|
@ -69,7 +70,10 @@ defmodule Towerops.Workers.UispSyncWorker do
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
Logger.error("UISP sync failed for org #{integration.organization_id}: #{inspect(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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -103,13 +103,17 @@ defmodule ToweropsWeb.SiteLive.Show do
|
||||||
nil
|
nil
|
||||||
else
|
else
|
||||||
twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour)
|
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 =
|
datasets =
|
||||||
devices
|
devices
|
||||||
|> Enum.map(fn device ->
|
|> Enum.map(fn device ->
|
||||||
checks =
|
checks =
|
||||||
device.id
|
latency_by_device
|
||||||
|> Monitoring.get_latency_data(since: twenty_four_hours_ago, limit: 1000)
|
|> Map.get(device.id, [])
|
||||||
|> Enum.reverse()
|
|> Enum.reverse()
|
||||||
|
|
||||||
%{
|
%{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue