fix: security hardening and query performance improvements

- Session cookie: add encryption_salt and secure flag to endpoint
- CSP: remove unsafe-eval from script-src directive
- /metrics: wrap PromEx endpoint behind rate-limited pipeline
- LiveView signing_salt: move to env var in production config
- LiveView performance: defer heavy aggregation queries past initial mount
- SQL: replace correlated subquery with DISTINCT ON in get_heard_by_stations
This commit is contained in:
Graham McInitre 2026-07-21 10:13:30 -05:00
parent b88eb9e584
commit 243a98df77
6 changed files with 95 additions and 44 deletions

View file

@ -1,2 +1,2 @@
erlang 29.0.1
erlang 29.0.3
elixir 1.20.2-otp-29

View file

@ -31,7 +31,7 @@ config :aprsme, AprsmeWeb.Endpoint,
layout: false
],
pubsub_server: Aprsme.PubSub,
live_view: [signing_salt: "ees098qG"]
live_view: [signing_salt: "dev-signing-salt-local-only"]
# Configure Gettext with supported locales from AprsmeWeb.Gettext module
config :aprsme, AprsmeWeb.Gettext,

View file

@ -43,6 +43,20 @@ if config_env() == :prod do
You can generate one by calling: mix phx.gen.secret
"""
live_view_signing_salt =
System.get_env("LIVE_VIEW_SIGNING_SALT") ||
raise """
environment variable LIVE_VIEW_SIGNING_SALT is missing.
You can generate one by calling: mix phx.gen.secret 32
"""
encryption_salt =
System.get_env("ENCRYPTION_SALT") ||
raise """
environment variable ENCRYPTION_SALT is missing.
You can generate one by calling: mix phx.gen.secret 32
"""
host = System.get_env("PHX_HOST") || "example.com"
port = String.to_integer(System.get_env("PORT") || "4000")
@ -99,6 +113,8 @@ if config_env() == :prod do
port: port
],
secret_key_base: secret_key_base,
live_view: [signing_salt: live_view_signing_salt],
session_options: [encryption_salt: encryption_salt, secure: true],
server: true,
check_origin: [
"https://#{host}",

View file

@ -5,13 +5,25 @@ defmodule AprsmeWeb.Endpoint do
# The session will be stored in the cookie and signed,
# this means its contents can be read but not tampered with.
# Set :encryption_salt if you would also like to encrypt it.
@session_options [
@default_session_options [
store: :cookie,
key: "_aprs_key",
signing_salt: "0toQ/Ejk",
encryption_salt: "local-dev-only-encryption-salt",
secure: false,
same_site: "Lax"
]
# Computed at compile-time by merging defaults with any endpoint config
@session_options Keyword.merge(
@default_session_options,
:aprsme
|> Application.compile_env(AprsmeWeb.Endpoint, [])
|> Keyword.get(:session_options, [])
)
def session_options, do: @session_options
socket "/live", Phoenix.LiveView.Socket,
websocket: [connect_info: [session: @session_options], timeout: 60_000],
longpoll: [connect_info: [session: @session_options]]

View file

@ -81,9 +81,6 @@ defmodule AprsmeWeb.InfoLive.Show do
has_weather_packets = PacketUtils.has_weather_packets?(normalized_callsign)
other_ssids = Packets.get_other_ssids(normalized_callsign)
heard_by_stations = get_heard_by_stations(normalized_callsign, locale)
stations_heard_by = get_stations_heard_by(normalized_callsign, locale)
socket =
socket
|> assign(:callsign, normalized_callsign)
@ -92,8 +89,8 @@ defmodule AprsmeWeb.InfoLive.Show do
|> assign(:page_title, "APRS station #{normalized_callsign}")
|> assign(:has_weather_packets, has_weather_packets)
|> assign(:other_ssids, other_ssids)
|> assign(:heard_by_stations, heard_by_stations)
|> assign(:stations_heard_by, stations_heard_by)
send(self(), :load_aggregations)
{:ok, socket}
end
@ -105,6 +102,15 @@ defmodule AprsmeWeb.InfoLive.Show do
process_packet_update(packet, socket)
end
@impl true
def handle_info(:load_aggregations, socket) do
locale = Map.get(socket.assigns, :locale, "en")
heard_by_stations = get_heard_by_stations(socket.assigns.callsign, locale)
stations_heard_by = get_stations_heard_by(socket.assigns.callsign, locale)
{:noreply, assign(socket, heard_by_stations: heard_by_stations, stations_heard_by: stations_heard_by)}
end
def handle_info(_message, socket), do: {:noreply, socket}
defp process_packet_update(incoming_packet, socket) do
@ -326,7 +332,27 @@ defmodule AprsmeWeb.InfoLive.Show do
# Query to find stations that heard this callsign directly
# In APRS, the first station with an asterisk (*) in the path is the one that heard the packet directly
query = """
WITH parsed_paths AS (
WITH digipeater_location AS (
SELECT DISTINCT ON (sender)
sender as digipeater,
location::geography as dig_loc
FROM packets
WHERE sender IN (
SELECT DISTINCT
substring(path from '([A-Z0-9]+-?[0-9]*)\\*')
FROM packets
WHERE sender = $1
AND received_at >= $2
AND path IS NOT NULL
AND path != ''
AND path !~ '^TCPIP'
AND path !~ ',TCPIP'
AND path ~ '[A-Z0-9]+-?[0-9]*\\*'
)
AND location IS NOT NULL
ORDER BY sender, received_at DESC
),
parsed_paths AS (
SELECT
id,
sender,
@ -349,33 +375,34 @@ defmodule AprsmeWeb.InfoLive.Show do
AND path !~ '^TCPIP'
AND path !~ ',TCPIP'
),
longest_paths AS (
SELECT DISTINCT ON (pp.first_digipeater)
pp.first_digipeater,
pp.id as longest_path_packet_id
FROM parsed_paths pp
WHERE pp.first_digipeater IS NOT NULL
AND pp.lat IS NOT NULL
AND pp.lon IS NOT NULL
ORDER BY pp.first_digipeater,
ST_Distance(
pp.location::geography,
COALESCE(
(SELECT dl.dig_loc FROM digipeater_location dl WHERE dl.digipeater = pp.first_digipeater),
pp.location::geography
)
) DESC NULLS LAST
),
digipeater_stats AS (
SELECT
first_digipeater as digipeater,
MIN(received_at) as first_heard,
MAX(received_at) as last_heard,
pp.first_digipeater as digipeater,
MIN(pp.received_at) as first_heard,
MAX(pp.received_at) as last_heard,
COUNT(*) as packet_count,
-- Find the packet with maximum distance for this digipeater
(SELECT pp2.id
FROM parsed_paths pp2
WHERE pp2.first_digipeater = pp.first_digipeater
AND pp2.lat IS NOT NULL
AND pp2.lon IS NOT NULL
ORDER BY
ST_Distance(
pp2.location::geography,
(SELECT location::geography
FROM packets
WHERE sender = pp2.first_digipeater
AND location IS NOT NULL
ORDER BY received_at DESC
LIMIT 1)
) DESC NULLS LAST
LIMIT 1
) as longest_path_packet_id
MAX(lp.longest_path_packet_id) as longest_path_packet_id
FROM parsed_paths pp
WHERE first_digipeater IS NOT NULL
GROUP BY first_digipeater
LEFT JOIN longest_paths lp ON lp.first_digipeater = pp.first_digipeater
WHERE pp.first_digipeater IS NOT NULL
GROUP BY pp.first_digipeater
)
SELECT
ds.digipeater,
@ -384,20 +411,13 @@ defmodule AprsmeWeb.InfoLive.Show do
ds.packet_count,
p.received_at as longest_path_time,
CASE
WHEN p.location IS NOT NULL AND dig_loc.location IS NOT NULL THEN
ST_Distance(p.location::geography, dig_loc.location::geography) / 1000.0
WHEN p.location IS NOT NULL AND dl.dig_loc IS NOT NULL THEN
ST_Distance(p.location::geography, dl.dig_loc) / 1000.0
ELSE NULL
END as longest_distance_km
FROM digipeater_stats ds
LEFT JOIN packets p ON p.id = ds.longest_path_packet_id
LEFT JOIN LATERAL (
SELECT location
FROM packets
WHERE sender = ds.digipeater
AND location IS NOT NULL
ORDER BY received_at DESC
LIMIT 1
) dig_loc ON true
LEFT JOIN digipeater_location dl ON dl.digipeater = ds.digipeater
ORDER BY ds.last_heard DESC
LIMIT 50
"""

View file

@ -22,7 +22,7 @@ defmodule AprsmeWeb.Router do
plug :put_secure_browser_headers, %{
"content-security-policy" =>
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.sentry-cdn.com https://unpkg.com https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://a.w5isp.com; style-src 'self' 'unsafe-inline' https://unpkg.com; img-src 'self' data: https: http: blob:; font-src 'self' data:; connect-src 'self' wss: https://*.ingest.sentry.io https://*.sentry.io https://nominatim.openstreetmap.org https://tile.openstreetmap.org https://*.tile.openstreetmap.org https://*.tile.openstreetmap.de https://*.basemaps.cartocdn.com https://a.w5isp.com; media-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; frame-src 'self'; manifest-src 'self'; worker-src 'self' blob:"
"default-src 'self'; script-src 'self' 'unsafe-inline' https://js.sentry-cdn.com https://unpkg.com https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://a.w5isp.com; style-src 'self' 'unsafe-inline' https://unpkg.com; img-src 'self' data: https: http: blob:; font-src 'self' data:; connect-src 'self' wss: https://*.ingest.sentry.io https://*.sentry.io https://nominatim.openstreetmap.org https://tile.openstreetmap.org https://*.tile.openstreetmap.org https://*.tile.openstreetmap.de https://*.basemaps.cartocdn.com https://a.w5isp.com; media-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; frame-src 'self'; manifest-src 'self'; worker-src 'self' blob:"
}
plug :fetch_current_user
@ -74,7 +74,10 @@ defmodule AprsmeWeb.Router do
# Prometheus metrics endpoint — internal-only, scraped by the cluster's
# prometheus via the kube-apiserver pod proxy. Not exposed via Ingress.
forward "/metrics", PromEx.Plug, prom_ex_module: Aprsme.PromEx
scope "/metrics" do
pipe_through [:public_api]
forward "/", PromEx.Plug, prom_ex_module: Aprsme.PromEx
end
# Agent/crawler discovery endpoints
scope "/", AprsmeWeb do