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:
parent
b88eb9e584
commit
243a98df77
6 changed files with 95 additions and 44 deletions
|
|
@ -1,2 +1,2 @@
|
|||
erlang 29.0.1
|
||||
erlang 29.0.3
|
||||
elixir 1.20.2-otp-29
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue