bugfixes
Some checks failed
Build and Push / Build and Push Docker Image (push) Failing after 2s

This commit is contained in:
Graham McIntire 2026-07-25 15:39:46 -05:00
parent edeb8402db
commit 101dcf4005
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
8 changed files with 72 additions and 247 deletions

View file

@ -61,9 +61,9 @@ config :aprsme, AprsmeWeb.Endpoint,
live_reload: [
web_console_logger: true,
patterns: [
~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
~r"priv/gettext/.*(po)$",
~r"lib/aprsme_web/(?:controllers|live|components|router)/?.*\.(ex|heex)$"
~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$"E,
~r"priv/gettext/.*(po)$"E,
~r"lib/aprsme_web/(?:controllers|live|components|router)/?.*\.(ex|heex)$"E
]
]

View file

@ -10,7 +10,7 @@ pool_size =
if System.get_env("MIX_TEST_COVERAGE") do
2
else
System.schedulers_online() * 16
min(System.schedulers_online() * 2, 20)
end
# In test we don't send emails.

View file

@ -1,103 +0,0 @@
# Findings: aprs.me Code Review
## Security Findings
### CRITICAL: Production secrets exposed on disk
`.envrc` contains all production secrets in plaintext: APRS credentials, `DATABASE_URL`, `SECRET_KEY_BASE`, `RESEND_API_KEY`, `RELEASE_COOKIE`. Protected by `.gitignore` but accessible to anyone with filesystem access.
### CRITICAL: APRS credentials in K8s manifest
`k8s/deployment.yaml:64-67,136-143` — APRS callsign and password hardcoded as plaintext env vars instead of Kubernetes Secrets.
### HIGH: Secret key base in git history
`config/dev.exs:48`, `config/test.exs:40` — Development and test `secret_key_base` values committed since initial commit.
### HIGH: SQL injection vector
`lib/aprsme/release.ex:162``SQL.query!` uses string interpolation with env var input:
```elixir
SQL.query!(repo, "SET statement_timeout = '#{timeout_seconds}s'", [])
```
### MEDIUM: Session signed but not encrypted
`lib/aprsme_web/endpoint.ex:7-13` — Session configured with `signing_salt` only, no `encryption_salt`. Session contents can be base64-decoded by anyone with cookie access.
### MEDIUM: `raw()` HTML rendering in LiveView
`lib/aprsme_web/live/info_live/show.ex:537,542,545` — APRS symbol rendering uses `raw()` to inject HTML. While content is escaped, `raw()` bypasses HEEx auto-escaping at template level.
### MEDIUM: `raw()` SVG rendering
`lib/aprsme_web/components/core_components.ex:50` — SVG content rendered via `raw()`. Risk is low (files are controlled vendored assets).
### MEDIUM: `check_origin: false` in dev
`config/dev.exs:45` — WebSocket origin checks disabled in development.
### MEDIUM: Anonymous WebSocket connections
`lib/aprsme_web/channels/mobile_user_socket.ex:21-29` — Mobile socket requires no authentication. Rate-limited to 30/min/IP but otherwise open.
### MEDIUM: No auth on API endpoints
`lib/aprsme_web/router.ex:131-136` — API v1 (`/api/v1/callsign/:cs`, `/api/v1/weather/nearby`) is anonymous. Rate-limited but no API keys.
### MEDIUM: APRS-IS connection uses plain TCP
`lib/aprsme/is/is.ex:215` — APRS passcode sent in cleartext over `:gen_tcp` (no SSL).
### LOW: No `secure` flag on session cookie
`lib/aprsme_web/endpoint.ex:8-13` — No `secure: true` in session options. Handled by Phoenix in practice but not explicit.
### LOW: Password policy too lax
`lib/aprsme/accounts/user.ex:73-75` — Character class requirements are commented out. Passwords only require 12+ characters.
### LOW: Rate limiting not shared across cluster
`config/runtime.exs:133``Hammer.Backend.ETS` is per-node. Multi-node deployments can exhaust per-node limits independently.
### INFO: CSP allows `unsafe-inline` and `unsafe-eval`
`lib/aprsme_web/router.ex:23-26` — Content Security Policy allows both, weakening XSS protection.
### INFO: 27+ Sobelow findings pre-skipped
`.sobelow-skips` — Reviewed and deemed acceptable (parameterized spatial queries, symbol rendering, upstream TLS).
---
## Code Quality & Refactoring Findings
### HIGH: `map_live/index.ex` is 2,104 lines
Largest module in the project. Combines socket management, event handling, rendering, map state, and overlay logic. Extract into focused sub-modules (map_state, map_events, map_render).
### HIGH: Duplicated `has_weather` logic
`lib/aprsme/packet.ex:159` and `lib/aprsme/packet_consumer.ex` both maintain parallel `set_has_weather/1` logic. If weather detection rules change, one path will be missed.
### HIGH: 17 modules missing `@moduledoc`
Modules lacking documentation include: `Packet`, `PacketConsumer`, `DataExtended`, `BadPacket`, `Accounts.User`, `Accounts.UserNotifier`, `Packets`, and others.
### MEDIUM: `require Logger` in function bodies (~75 occurrences)
Several modules duplicate `require Logger` at module level AND inside function bodies (e.g., `packets.ex:17,27`, `packet_consumer.ex`). Consolidate to module top-level.
### MEDIUM: `packets.ex` is 849 lines
Mix of query functions, store logic, and validation. Consider splitting query/concerns into separate modules.
### MEDIUM: Cache module has unnecessary GenServer overhead
`lib/aprsme/cache.ex` — Wraps ETS operations in GenServer calls. GenServer passes through `%{}` state with no meaningful state management. Direct ETS access would be faster for get/put.
### MEDIUM: Encoding vs EncodingUtils boundary unclear
`encoding_utils.ex` (404 lines) wraps Gleam-based `Encoding` module but also contains substantive logic (`sanitize_packet`, `normalize_data_type`). Clarify responsibility boundary.
### MEDIUM: Inline `import Ecto.Query` in function bodies
16 occurrences across `mobile_channel.ex`, `packet_utils.ex`, `info_live/show.ex`, `packets.ex`. Move to module top-level.
### MEDIUM: Large inline SQL in LiveViews
`info_live/show.ex:369,392,484,491` — Complex ST_Distance calculations embedded in LiveView. Extract to query modules.
### LOW: Geometry stored redundantly
`packet.ex` stores both scalar `lat`/`lon` AND PostGIS `location` geometry. `maybe_create_geometry_from_lat_lon/1` duplicates the data. Consider if both are needed.
### LOW: Behaviour only covers subset of functions
`packets_behaviour.ex` defines 8 callbacks but `packets.ex` has far more public functions. Only partial mockability.
### LOW: Test coverage at 91.13% (threshold 87%)
Only 4.13% above threshold. Focus on uncovered error/rescue paths and edge cases.
### LOW: Performance TODOs not tracked in code
`TODO.md` items (table partitioning, ETS write path) not marked as `# TODO` in source code. Won't appear in IDE task lists.
---
## Already Fixed
- **`packets.html.heex` (unstyled `<h1>`)**: Dead template with bare `<h1>Packets</h1>` removed. The `/packets` route uses `PacketsLive.Index` LiveView, not this controller template.

View file

@ -29,8 +29,6 @@ defmodule Aprsme.Application do
Aprsme.CircuitBreaker,
# Start regex cache for performance
Aprsme.RegexCache,
# Start cache manager
Aprsme.Cache,
# Start device cache manager
Aprsme.DeviceCache,
# Start broadcast task supervisor for async operations
@ -184,8 +182,7 @@ defmodule Aprsme.Application do
defp redis_children do
Logger.info("Starting ETS-based caching and rate limiting")
# Create ETS tables for caching — use :public so the Cache GenServer (a separate process)
# can write to these tables; write_concurrency improves throughput under concurrent writes.
# Create public ETS tables so callers can use concurrent reads and writes directly.
_ = :ets.new(:query_cache, [:set, :public, :named_table, read_concurrency: true, write_concurrency: true])
_ = :ets.new(:device_cache, [:set, :public, :named_table, read_concurrency: true, write_concurrency: true])
_ = :ets.new(:symbol_cache, [:set, :public, :named_table, read_concurrency: true, write_concurrency: true])

View file

@ -8,22 +8,30 @@ defmodule Aprsme.Cache do
Expired entries are lazily evicted on read.
"""
use GenServer
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@impl true
def init(_opts) do
{:ok, %{}}
end
@doc """
Get a value from cache. Returns `{:ok, nil}` for expired entries.
"""
def get(cache_name, key) do
GenServer.call(__MODULE__, {:get, cache_name, key})
case :ets.lookup(cache_name, key) do
[{^key, value, :infinity}] ->
{:ok, value}
[{^key, value, expires_at}] ->
if System.monotonic_time(:millisecond) < expires_at do
{:ok, value}
else
:ets.delete(cache_name, key)
{:ok, nil}
end
[{^key, value}] ->
{:ok, value}
[] ->
{:ok, nil}
end
rescue
ArgumentError -> {:error, :no_cache}
end
@doc """
@ -33,28 +41,47 @@ defmodule Aprsme.Cache do
* `:ttl` - Time to live in milliseconds. Use `Cache.to_timeout/1` for convenience.
"""
def put(cache_name, key, value, opts \\ []) do
GenServer.call(__MODULE__, {:put, cache_name, key, value, opts})
expires_at =
case Keyword.get(opts, :ttl) do
nil -> :infinity
ttl when is_integer(ttl) and ttl > 0 -> System.monotonic_time(:millisecond) + ttl
_ -> :infinity
end
:ets.insert(cache_name, {key, value, expires_at})
{:ok, true}
rescue
ArgumentError -> {:error, :no_cache}
end
@doc """
Delete a key from cache
"""
def del(cache_name, key) do
GenServer.call(__MODULE__, {:del, cache_name, key})
:ets.delete(cache_name, key)
{:ok, true}
rescue
ArgumentError -> {:error, :no_cache}
end
@doc """
Clear all keys from cache
"""
def clear(cache_name) do
GenServer.call(__MODULE__, {:clear, cache_name})
:ets.delete_all_objects(cache_name)
{:ok, true}
rescue
ArgumentError -> {:error, :no_cache}
end
@doc """
Get cache statistics (simplified for ETS)
"""
def stats(cache_name) do
GenServer.call(__MODULE__, {:stats, cache_name})
case :ets.info(cache_name) do
:undefined -> {:error, :no_cache}
info when is_list(info) -> {:ok, %{size: Keyword.get(info, :size, 0)}}
end
end
@doc """
@ -72,7 +99,14 @@ defmodule Aprsme.Cache do
Get TTL for a key. Returns remaining milliseconds or nil if no TTL.
"""
def ttl(cache_name, key) do
GenServer.call(__MODULE__, {:ttl, cache_name, key})
case :ets.lookup(cache_name, key) do
[{^key, _value, :infinity}] -> {:ok, nil}
[{^key, _value, expires_at}] -> {:ok, max(0, expires_at - System.monotonic_time(:millisecond))}
[{^key, _value}] -> {:ok, nil}
[] -> {:ok, nil}
end
rescue
ArgumentError -> {:ok, nil}
end
@doc """
@ -92,108 +126,4 @@ defmodule Aprsme.Cache do
{:milliseconds, n}, acc -> acc + n
end)
end
@impl true
def handle_call({:get, cache_name, key}, _from, state) do
result =
try do
case :ets.lookup(cache_name, key) do
[{^key, value, :infinity}] ->
{:ok, value}
[{^key, value, expires_at}] ->
if System.monotonic_time(:millisecond) < expires_at do
{:ok, value}
else
:ets.delete(cache_name, key)
{:ok, nil}
end
[{^key, value}] ->
{:ok, value}
[] ->
{:ok, nil}
end
rescue
ArgumentError ->
{:error, :no_cache}
end
{:reply, result, state}
end
@impl true
def handle_call({:put, cache_name, key, value, opts}, _from, state) do
result =
try do
expires_at =
case Keyword.get(opts, :ttl) do
nil -> :infinity
ttl when is_integer(ttl) and ttl > 0 -> System.monotonic_time(:millisecond) + ttl
_ -> :infinity
end
:ets.insert(cache_name, {key, value, expires_at})
{:ok, true}
rescue
ArgumentError -> {:error, :no_cache}
end
{:reply, result, state}
end
@impl true
def handle_call({:del, cache_name, key}, _from, state) do
result =
try do
:ets.delete(cache_name, key)
{:ok, true}
rescue
ArgumentError -> {:error, :no_cache}
end
{:reply, result, state}
end
@impl true
def handle_call({:clear, cache_name}, _from, state) do
result =
try do
:ets.delete_all_objects(cache_name)
{:ok, true}
rescue
ArgumentError -> {:error, :no_cache}
end
{:reply, result, state}
end
@impl true
def handle_call({:stats, cache_name}, _from, state) do
result =
case :ets.info(cache_name) do
:undefined -> {:error, :no_cache}
info when is_list(info) -> {:ok, %{size: Keyword.get(info, :size, 0)}}
end
{:reply, result, state}
end
@impl true
def handle_call({:ttl, cache_name, key}, _from, state) do
result =
try do
case :ets.lookup(cache_name, key) do
[{^key, _value, :infinity}] -> {:ok, nil}
[{^key, _value, expires_at}] -> {:ok, max(0, expires_at - System.monotonic_time(:millisecond))}
[{^key, _value}] -> {:ok, nil}
[] -> {:ok, nil}
end
rescue
ArgumentError -> {:ok, nil}
end
{:reply, result, state}
end
end

View file

@ -86,6 +86,8 @@ defmodule AprsmeWeb.InfoLive.Show do
|> assign(:callsign, normalized_callsign)
|> assign(:packet, packet)
|> assign(:neighbors, neighbors)
|> assign(:heard_by_stations, [])
|> assign(:stations_heard_by, [])
|> assign(:page_title, "APRS station #{normalized_callsign}")
|> assign(:has_weather_packets, has_weather_packets)
|> assign(:other_ssids, other_ssids)
@ -398,11 +400,11 @@ defmodule AprsmeWeb.InfoLive.Show do
MIN(pp.received_at) as first_heard,
MAX(pp.received_at) as last_heard,
COUNT(*) as packet_count,
MAX(lp.longest_path_packet_id) as longest_path_packet_id
lp.longest_path_packet_id
FROM parsed_paths pp
LEFT JOIN longest_paths lp ON lp.first_digipeater = pp.first_digipeater
WHERE pp.first_digipeater IS NOT NULL
GROUP BY pp.first_digipeater
GROUP BY pp.first_digipeater, lp.longest_path_packet_id
)
SELECT
ds.digipeater,

View file

@ -36,8 +36,6 @@ defmodule AprsmeWeb.MapLive.Index do
@impl true
def mount(params, session, socket) do
socket = setup_subscriptions(socket)
# Basic setup
deployed_at = Aprsme.Release.deployed_at()
one_day_ago = TimeUtils.hours_ago(24)
@ -86,6 +84,7 @@ defmodule AprsmeWeb.MapLive.Index do
# Calculate initial bounds
initial_bounds = BoundsUtils.calculate_bounds_from_center_and_zoom(final_map_center, final_map_zoom)
socket = setup_subscriptions(socket, initial_bounds)
# Final socket assignment
{:ok,
@ -100,11 +99,11 @@ defmodule AprsmeWeb.MapLive.Index do
})}
end
defp setup_subscriptions(socket) do
do_setup_subscriptions(socket, connected?(socket))
defp setup_subscriptions(socket, initial_bounds) do
do_setup_subscriptions(socket, initial_bounds, connected?(socket))
end
defp do_setup_subscriptions(socket, true) do
defp do_setup_subscriptions(socket, initial_bounds, true) do
# Check if we should accept new connections
if Application.get_env(:aprsme, :cluster_enabled, false) and
not Aprsme.ConnectionMonitor.accepting_connections?() do
@ -125,10 +124,10 @@ defmodule AprsmeWeb.MapLive.Index do
Phoenix.PubSub.subscribe(Aprsme.PubSub, "connection:drain:#{Node.self()}")
end
# Register with spatial PubSub (will get viewport later)
# Start with a default viewport that will be updated when we get actual bounds
default_bounds = %{north: 90.0, south: -90.0, east: 180.0, west: -180.0}
{:ok, spatial_topic} = Aprsme.SpatialPubSub.register_viewport(client_id, default_bounds)
# Register the actual initial viewport. Registering world bounds here used to
# allocate roughly 65,000 one-degree grid cells for every new connection,
# only to tear them down again after the client sent its real bounds.
{:ok, spatial_topic} = Aprsme.SpatialPubSub.register_viewport(client_id, initial_bounds)
# Subscribe to the spatial topic for this client
:ok = Phoenix.PubSub.subscribe(Aprsme.PubSub, spatial_topic)
@ -140,7 +139,7 @@ defmodule AprsmeWeb.MapLive.Index do
:ok = Phoenix.PubSub.subscribe(Aprsme.PubSub, "deployment_events")
# Subscribe to StreamingPacketsPubSub with initial bounds
_ = Aprsme.StreamingPacketsPubSub.subscribe_to_bounds(self(), default_bounds)
_ = Aprsme.StreamingPacketsPubSub.subscribe_to_bounds(self(), initial_bounds)
_ = Process.send_after(self(), :cleanup_old_packets, 60_000)
@ -150,7 +149,7 @@ defmodule AprsmeWeb.MapLive.Index do
end
end
defp do_setup_subscriptions(socket, false), do: socket
defp do_setup_subscriptions(socket, _initial_bounds, false), do: socket
defp setup_additional_subscriptions(socket) do
do_setup_additional_subscriptions(socket, connected?(socket))

View file

@ -19,13 +19,13 @@ defmodule Aprsme.ConnectionPreventionTest do
test "APRS configuration is safe for testing" do
# Verify server configuration is neutralized
server = Application.get_env(:aprsme, :aprsme_is_server)
server = Application.get_env(:aprsme, :aprs_is_server)
# credo:disable-for-next-line Jump.CredoChecks.ConditionalAssertion
assert server == nil or server == "mock.aprs.test"
# server config is legitimately nil or mock value in test
# Verify test credentials are used
login_id = Application.get_env(:aprsme, :aprsme_is_login_id)
login_id = Application.get_env(:aprsme, :aprs_is_login_id)
assert login_id == "TEST"
# Verify no real APRS servers in config