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
|
elixir 1.20.2-otp-29
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ config :aprsme, AprsmeWeb.Endpoint,
|
||||||
layout: false
|
layout: false
|
||||||
],
|
],
|
||||||
pubsub_server: Aprsme.PubSub,
|
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
|
# Configure Gettext with supported locales from AprsmeWeb.Gettext module
|
||||||
config :aprsme, AprsmeWeb.Gettext,
|
config :aprsme, AprsmeWeb.Gettext,
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,20 @@ if config_env() == :prod do
|
||||||
You can generate one by calling: mix phx.gen.secret
|
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"
|
host = System.get_env("PHX_HOST") || "example.com"
|
||||||
port = String.to_integer(System.get_env("PORT") || "4000")
|
port = String.to_integer(System.get_env("PORT") || "4000")
|
||||||
|
|
||||||
|
|
@ -99,6 +113,8 @@ if config_env() == :prod do
|
||||||
port: port
|
port: port
|
||||||
],
|
],
|
||||||
secret_key_base: secret_key_base,
|
secret_key_base: secret_key_base,
|
||||||
|
live_view: [signing_salt: live_view_signing_salt],
|
||||||
|
session_options: [encryption_salt: encryption_salt, secure: true],
|
||||||
server: true,
|
server: true,
|
||||||
check_origin: [
|
check_origin: [
|
||||||
"https://#{host}",
|
"https://#{host}",
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,25 @@ defmodule AprsmeWeb.Endpoint do
|
||||||
# The session will be stored in the cookie and signed,
|
# The session will be stored in the cookie and signed,
|
||||||
# this means its contents can be read but not tampered with.
|
# this means its contents can be read but not tampered with.
|
||||||
# Set :encryption_salt if you would also like to encrypt it.
|
# Set :encryption_salt if you would also like to encrypt it.
|
||||||
@session_options [
|
@default_session_options [
|
||||||
store: :cookie,
|
store: :cookie,
|
||||||
key: "_aprs_key",
|
key: "_aprs_key",
|
||||||
signing_salt: "0toQ/Ejk",
|
signing_salt: "0toQ/Ejk",
|
||||||
|
encryption_salt: "local-dev-only-encryption-salt",
|
||||||
|
secure: false,
|
||||||
same_site: "Lax"
|
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,
|
socket "/live", Phoenix.LiveView.Socket,
|
||||||
websocket: [connect_info: [session: @session_options], timeout: 60_000],
|
websocket: [connect_info: [session: @session_options], timeout: 60_000],
|
||||||
longpoll: [connect_info: [session: @session_options]]
|
longpoll: [connect_info: [session: @session_options]]
|
||||||
|
|
|
||||||
|
|
@ -81,9 +81,6 @@ defmodule AprsmeWeb.InfoLive.Show do
|
||||||
has_weather_packets = PacketUtils.has_weather_packets?(normalized_callsign)
|
has_weather_packets = PacketUtils.has_weather_packets?(normalized_callsign)
|
||||||
other_ssids = Packets.get_other_ssids(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 =
|
||||||
socket
|
socket
|
||||||
|> assign(:callsign, normalized_callsign)
|
|> assign(:callsign, normalized_callsign)
|
||||||
|
|
@ -92,8 +89,8 @@ defmodule AprsmeWeb.InfoLive.Show do
|
||||||
|> assign(:page_title, "APRS station #{normalized_callsign}")
|
|> assign(:page_title, "APRS station #{normalized_callsign}")
|
||||||
|> assign(:has_weather_packets, has_weather_packets)
|
|> assign(:has_weather_packets, has_weather_packets)
|
||||||
|> assign(:other_ssids, other_ssids)
|
|> 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}
|
{:ok, socket}
|
||||||
end
|
end
|
||||||
|
|
@ -105,6 +102,15 @@ defmodule AprsmeWeb.InfoLive.Show do
|
||||||
process_packet_update(packet, socket)
|
process_packet_update(packet, socket)
|
||||||
end
|
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}
|
def handle_info(_message, socket), do: {:noreply, socket}
|
||||||
|
|
||||||
defp process_packet_update(incoming_packet, socket) do
|
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
|
# 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
|
# In APRS, the first station with an asterisk (*) in the path is the one that heard the packet directly
|
||||||
query = """
|
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
|
SELECT
|
||||||
id,
|
id,
|
||||||
sender,
|
sender,
|
||||||
|
|
@ -349,33 +375,34 @@ defmodule AprsmeWeb.InfoLive.Show do
|
||||||
AND path !~ '^TCPIP'
|
AND path !~ '^TCPIP'
|
||||||
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 (
|
digipeater_stats AS (
|
||||||
SELECT
|
SELECT
|
||||||
first_digipeater as digipeater,
|
pp.first_digipeater as digipeater,
|
||||||
MIN(received_at) as first_heard,
|
MIN(pp.received_at) as first_heard,
|
||||||
MAX(received_at) as last_heard,
|
MAX(pp.received_at) as last_heard,
|
||||||
COUNT(*) as packet_count,
|
COUNT(*) as packet_count,
|
||||||
-- Find the packet with maximum distance for this digipeater
|
MAX(lp.longest_path_packet_id) as longest_path_packet_id
|
||||||
(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
|
|
||||||
FROM parsed_paths pp
|
FROM parsed_paths pp
|
||||||
WHERE first_digipeater IS NOT NULL
|
LEFT JOIN longest_paths lp ON lp.first_digipeater = pp.first_digipeater
|
||||||
GROUP BY first_digipeater
|
WHERE pp.first_digipeater IS NOT NULL
|
||||||
|
GROUP BY pp.first_digipeater
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
ds.digipeater,
|
ds.digipeater,
|
||||||
|
|
@ -384,20 +411,13 @@ defmodule AprsmeWeb.InfoLive.Show do
|
||||||
ds.packet_count,
|
ds.packet_count,
|
||||||
p.received_at as longest_path_time,
|
p.received_at as longest_path_time,
|
||||||
CASE
|
CASE
|
||||||
WHEN p.location IS NOT NULL AND dig_loc.location IS NOT NULL THEN
|
WHEN p.location IS NOT NULL AND dl.dig_loc IS NOT NULL THEN
|
||||||
ST_Distance(p.location::geography, dig_loc.location::geography) / 1000.0
|
ST_Distance(p.location::geography, dl.dig_loc) / 1000.0
|
||||||
ELSE NULL
|
ELSE NULL
|
||||||
END as longest_distance_km
|
END as longest_distance_km
|
||||||
FROM digipeater_stats ds
|
FROM digipeater_stats ds
|
||||||
LEFT JOIN packets p ON p.id = ds.longest_path_packet_id
|
LEFT JOIN packets p ON p.id = ds.longest_path_packet_id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN digipeater_location dl ON dl.digipeater = ds.digipeater
|
||||||
SELECT location
|
|
||||||
FROM packets
|
|
||||||
WHERE sender = ds.digipeater
|
|
||||||
AND location IS NOT NULL
|
|
||||||
ORDER BY received_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
) dig_loc ON true
|
|
||||||
ORDER BY ds.last_heard DESC
|
ORDER BY ds.last_heard DESC
|
||||||
LIMIT 50
|
LIMIT 50
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ defmodule AprsmeWeb.Router do
|
||||||
|
|
||||||
plug :put_secure_browser_headers, %{
|
plug :put_secure_browser_headers, %{
|
||||||
"content-security-policy" =>
|
"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
|
plug :fetch_current_user
|
||||||
|
|
@ -74,7 +74,10 @@ defmodule AprsmeWeb.Router do
|
||||||
|
|
||||||
# Prometheus metrics endpoint — internal-only, scraped by the cluster's
|
# Prometheus metrics endpoint — internal-only, scraped by the cluster's
|
||||||
# prometheus via the kube-apiserver pod proxy. Not exposed via Ingress.
|
# 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
|
# Agent/crawler discovery endpoints
|
||||||
scope "/", AprsmeWeb do
|
scope "/", AprsmeWeb do
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue