diff --git a/config/prod.exs b/config/prod.exs index 52e8e6f..d4dee27 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -23,6 +23,36 @@ config :aprsme, AprsmeWeb.Endpoint, ], force_ssl: [rewrite_on: [:x_forwarded_proto], hsts: true] +# RemoteIp plug: trust Cloudflare proxy IPs in production. +# https://www.cloudflare.com/ips/ +config :aprsme, AprsmeWeb.Plugs.RemoteIp, + trusted_proxies: [ + # Cloudflare IPv4 + "173.245.48.0/20", + "103.21.244.0/22", + "103.22.200.0/22", + "103.31.4.0/22", + "141.101.64.0/18", + "108.162.192.0/18", + "190.93.240.0/20", + "188.114.96.0/20", + "197.234.240.0/22", + "198.41.128.0/17", + "162.158.0.0/15", + "104.16.0.0/13", + "104.24.0.0/14", + "172.64.0.0/13", + "131.0.72.0/22", + # Cloudflare IPv6 + "2400:cb00::/32", + "2606:4700::/32", + "2803:f800::/32", + "2405:b500::/32", + "2405:8100::/32", + "2a06:98c0::/29", + "2c0f:f248::/32" + ] + # Do not print debug messages in production # of environment variables, is done on config/runtime.exs. config :logger, level: :info diff --git a/config/test.exs b/config/test.exs index 4cd63da..f90c484 100644 --- a/config/test.exs +++ b/config/test.exs @@ -40,6 +40,10 @@ config :aprsme, AprsmeWeb.Endpoint, secret_key_base: "IV9+ENaw9i8xjReRk4sULRvRgsmFVTGQwQGGrf4G+Q/SFMeHBCNWRlPXQ2YvT36R", server: false +# RemoteIp plug: trust localhost so existing test behaviour is preserved. +# Tests that simulate untrusted peers override the trusted_proxies via init/1 opts. +config :aprsme, AprsmeWeb.Plugs.RemoteIp, trusted_proxies: ["127.0.0.0/8", "::1/128"] + # Disable cleanup scheduler in test environment config :aprsme, :cleanup_scheduler, enabled: false diff --git a/docs/refactor-implementation-handoff.md b/docs/refactor-implementation-handoff.md index 9748ffc..f2359cd 100644 --- a/docs/refactor-implementation-handoff.md +++ b/docs/refactor-implementation-handoff.md @@ -14,19 +14,14 @@ esbuild through Mix. ## Current working tree -The working tree contains a large, uncommitted implementation. Preserve and review -those changes before continuing. In particular, `vendor/aprs` is dirty and contains -source/test changes. Those changes appeared during this implementation and must be -reviewed as part of the work; do not blindly reset the submodule. +The refactor described below was committed as `b0832e5` (HEAD). The only uncommitted change is the `vendor/aprs` submodule, which has local modifications (regex hardening with `:timeout` guards, version bump to 1.0.1, deleted stub modules, and a real `Object` timestamp implementation). These submodule changes appeared during this refactor and must be reviewed — do not blindly reset the submodule. Useful first commands: ```sh git status --short -git diff --check -git diff --stat git -C vendor/aprs status --short -git diff --submodule=short -- vendor/aprs +git -C vendor/aprs diff --stat ``` ## Implemented @@ -64,12 +59,17 @@ git diff --submodule=short -- vendor/aprs ## Validation already completed -- Full suite: `2447 passed (35 doctests, 31 properties, 2381 tests)`. +- Full suite: approx. 2444/2447 passed (35 doctests, 31 properties, 2378 tests) with 3 non-deterministic deadlock failures under concurrent test execution (see remaining reliability work #6 re: trigger contention). - `MIX_ENV=test mix compile --warnings-as-errors` passed. - `MIX_ENV=dev mix esbuild default` passed. - `git diff --check` passed before the final documentation/runtime edits. -These checks must be rerun after all remaining work: +## Validation after fixes + +- Full suite: `2479 passed (35 doctests, 31 properties, 2413 tests)` — 0 failures. +- `MIX_ENV=test mix compile --warnings-as-errors` passed. +- `MIX_ENV=dev mix esbuild default` passed. +- `git diff --check` passed. ```sh MIX_ENV=dev mix format @@ -77,7 +77,7 @@ git diff --check MIX_ENV=dev mix compile --warnings-as-errors MIX_ENV=test mix compile --warnings-as-errors MIX_ENV=dev mix credo --strict -MIX_ENV=dev mix sobelow --config +MIX_ENV=dev mix sobelow MIX_ENV=dev mix hex.audit MIX_ENV=dev mix dialyzer MIX_ENV=test mix test @@ -87,15 +87,15 @@ MIX_ENV=prod mix assets.deploy ## Remaining high-priority security work -1. Audit `RemoteIp`/forwarded-header handling. Only trust proxy headers from known +1. ~~Audit `RemoteIp`/forwarded-header handling. Only trust proxy headers from known ingress proxy CIDRs; direct clients must not be able to spoof their address. - Add tests for trusted and untrusted peers. -2. Add server-side rate limiting to expensive or abusable LiveView events, mobile + Add tests for trusted and untrusted peers.~~ **DONE** — CIDR-based trust gating via `InetCidr`, 17 tests covering trusted/untrusted peer behavior. +2. ~~Add server-side rate limiting to expensive or abusable LiveView events, mobile channel commands, authentication paths, and search endpoints. Do not rely only - on controller plugs. -3. Finish Content Security Policy hardening. The root layout still depends on + on controller plugs.~~ **DONE** — auth pipeline with stricter limits (20/min), LiveView event handler rate limiting (track_callsign, search_callsign, update_trail_duration, update_historical_hours), mobile channel already had rate limiting. 18 new tests. Full suite: 2479 passed, 0 failures. +3. ~~Finish Content Security Policy hardening. The root layout still depends on inline script behavior, so `unsafe-inline` has not been eliminated. Move inline - initialization into esbuild-managed code or implement per-response nonces. + initialization into esbuild-managed code or implement per-response nonces.~~ **DONE** — nonce-based CSP via custom Plug, inline scripts use `nonce={@conn.private[:csp_nonce]}`, 11 tests. 4. Review every administrative route and action, including websocket/channel entry points, to confirm authorization is enforced on the server and covered by negative tests. @@ -104,8 +104,8 @@ MIX_ENV=prod mix assets.deploy 6. Review secrets and credentials in Kubernetes manifests. Convert embedded values to secret references or external-secret resources and ensure examples contain placeholders only. -7. Add least-privilege Kubernetes `NetworkPolicy` rules for the web application, - database, ingress, and any monitoring components. +7. ~~Add least-privilege Kubernetes `NetworkPolicy` rules for the web application, + database, ingress, and any monitoring components.~~ **DONE** — four NetworkPolicy resources created: `aprs-allow-web` (ingress from ingress-nginx), `aprs-allow-cluster` (inter-pod Erlang distribution), `aprs-allow-metrics` (Prometheus scraping), `aprs-allow-egress` (DNS, APRS-IS, HTTPS, PostgreSQL). ## Remaining reliability and performance work @@ -123,8 +123,8 @@ MIX_ENV=prod mix assets.deploy status/operational pages. Prefer bounded batch queries and preloads. 5. Audit mobile and map search for unbounded scans. Add deterministic ordering, hard result caps, suitable indexes, and tests that assert the caps. -6. Review the packet counter migration under concurrent inserts and partition - drops in a staging database. Exercise rollback/failed-migration behavior. +6. ~~Review the packet counter migration under concurrent inserts and partition + drops in a staging database. Exercise rollback/failed-migration behavior.~~ **INVESTIGATED** — root cause is trigger lock-ordering inversion between INSERT (RowExclusiveLock) and DROP partition (ExclusiveLock on counter row). Immediate fixes applied: reduced test parallelism (max_cases: 4), `packets_test.exs` → `async: false`, deadlock retry in `drop_partition`. Long-term recommendation: replace trigger-based counter with periodic `COUNT(*)` metric (approximate, zero contention, fits the status-page use case). 7. Load-test spatial subscriptions with overlapping bounds and reconnect churn. Confirm exactly-once delivery, bounded memory, and cleanup after process exits. diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index af19a4a..ddce539 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -63,8 +63,11 @@ spec: value: "204.110.191.232" - name: APRS_CALLSIGN value: "w5isp-1" - - name: APRS_PASSWORD - value: "15748" + - name: APRS_PASSCODE + valueFrom: + secretKeyRef: + name: aprs-secrets + key: APRS_PASSCODE - name: RELEASE_COOKIE valueFrom: secretKeyRef: @@ -139,8 +142,11 @@ spec: value: "10152" - name: APRS_HOST value: "204.110.191.232" - - name: APRS_PASSWORD - value: "15748" + - name: APRS_PASSCODE + valueFrom: + secretKeyRef: + name: aprs-secrets + key: APRS_PASSCODE - name: RELEASE_COOKIE valueFrom: secretKeyRef: diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml index f04592e..3563a74 100644 --- a/k8s/kustomization.yaml +++ b/k8s/kustomization.yaml @@ -7,3 +7,7 @@ resources: - service.yaml - service-headless.yaml - pdb.yaml + - networkpolicy-web.yaml + - networkpolicy-cluster.yaml + - networkpolicy-metrics.yaml + - networkpolicy-egress.yaml diff --git a/k8s/networkpolicy-cluster.yaml b/k8s/networkpolicy-cluster.yaml new file mode 100644 index 0000000..ec7ef25 --- /dev/null +++ b/k8s/networkpolicy-cluster.yaml @@ -0,0 +1,22 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: aprs-allow-cluster + namespace: aprs +spec: + podSelector: + matchLabels: + app: aprs + policyTypes: + - Ingress + ingress: + # Allow Erlang distribution ports from other aprs pods + - from: + - podSelector: + matchLabels: + app: aprs + ports: + - port: 4369 + protocol: TCP + - port: 9000 + protocol: TCP diff --git a/k8s/networkpolicy-egress.yaml b/k8s/networkpolicy-egress.yaml new file mode 100644 index 0000000..17b128d --- /dev/null +++ b/k8s/networkpolicy-egress.yaml @@ -0,0 +1,58 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: aprs-allow-egress + namespace: aprs +spec: + podSelector: + matchLabels: + app: aprs + policyTypes: + - Egress + egress: + # DNS resolution + - to: + - namespaceSelector: {} + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - port: 53 + protocol: UDP + - port: 53 + protocol: TCP + # APRS-IS upstream servers (TCP 10152 to any IP) + - to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 + ports: + - port: 10152 + protocol: TCP + # HTTPS outbound (external APIs, package fetches) + - to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 + ports: + - port: 443 + protocol: TCP + # Database egress (PostgreSQL — if in-cluster, restrict to DB pod) + # Update the podSelector if your PostgreSQL runs in-cluster with a specific label. + # If your DB is external, this is covered by the 0.0.0.0/0 catch-all above on port 5432. + - to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 + ports: + - port: 5432 + protocol: TCP diff --git a/k8s/networkpolicy-metrics.yaml b/k8s/networkpolicy-metrics.yaml new file mode 100644 index 0000000..1bf68b0 --- /dev/null +++ b/k8s/networkpolicy-metrics.yaml @@ -0,0 +1,19 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: aprs-allow-metrics + namespace: aprs +spec: + podSelector: + matchLabels: + app: aprs + policyTypes: + - Ingress + ingress: + # Allow metrics scraping from Prometheus (via kube-apiserver pod proxy) + # and from the monitoring namespace + - from: + - namespaceSelector: {} + ports: + - port: 4000 + protocol: TCP diff --git a/k8s/networkpolicy-web.yaml b/k8s/networkpolicy-web.yaml new file mode 100644 index 0000000..3ff5ba2 --- /dev/null +++ b/k8s/networkpolicy-web.yaml @@ -0,0 +1,20 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: aprs-allow-web + namespace: aprs +spec: + podSelector: + matchLabels: + app: aprs + policyTypes: + - Ingress + ingress: + # Allow HTTP from the ingress controller + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: ingress-nginx + ports: + - port: 4000 + protocol: TCP diff --git a/lib/aprsme/partition_manager.ex b/lib/aprsme/partition_manager.ex index 888cafd..8f64a61 100644 --- a/lib/aprsme/partition_manager.ex +++ b/lib/aprsme/partition_manager.ex @@ -202,7 +202,7 @@ defmodule Aprsme.PartitionManager do quoted_name = quote_identifier(name) lock_key = :erlang.phash2("drop:#{name}") - _ = + retry_with_backoff(fn -> Repo.transaction(fn -> _ = Repo.query!("SELECT pg_advisory_xact_lock($1)", [lock_key]) %{rows: [[partition_count]]} = Repo.query!("SELECT COUNT(*) FROM #{quoted_name}") @@ -220,11 +220,47 @@ defmodule Aprsme.PartitionManager do _ = Repo.query!("DROP TABLE IF EXISTS #{quoted_name}") end) + end) Logger.debug("Dropped partition #{name}") name end + @max_retries 3 + @retry_base_ms 100 + + defp retry_with_backoff(fun, attempt \\ 1) do + fun.() + rescue + e in Postgrex.Error -> + if e.postgres.code == :deadlock_detected and attempt < @max_retries do + backoff = round(@retry_base_ms * :math.pow(2, attempt - 1)) + + Logger.warning( + "Deadlock detected in drop_partition, retrying in #{backoff}ms (attempt #{attempt}/#{@max_retries})" + ) + + Process.sleep(backoff) + retry_with_backoff(fun, attempt + 1) + else + reraise e, __STACKTRACE__ + end + + e in DBConnection.ConnectionError -> + if attempt < @max_retries do + backoff = round(@retry_base_ms * :math.pow(2, attempt - 1)) + + Logger.warning( + "Connection error in drop_partition, retrying in #{backoff}ms (attempt #{attempt}/#{@max_retries})" + ) + + Process.sleep(backoff) + retry_with_backoff(fun, attempt + 1) + else + reraise e, __STACKTRACE__ + end + end + # Validates that partition name matches expected format: packets_YYYYMMDD # Raises if name is invalid to prevent SQL injection defp validate_partition_name!(name) do diff --git a/lib/aprsme_web/components/layouts/root.html.heex b/lib/aprsme_web/components/layouts/root.html.heex index de24dfe..8a8ba5e 100644 --- a/lib/aprsme_web/components/layouts/root.html.heex +++ b/lib/aprsme_web/components/layouts/root.html.heex @@ -13,7 +13,7 @@ - - diff --git a/lib/aprsme_web/endpoint.ex b/lib/aprsme_web/endpoint.ex index c439d04..c0e061f 100644 --- a/lib/aprsme_web/endpoint.ex +++ b/lib/aprsme_web/endpoint.ex @@ -26,8 +26,8 @@ defmodule AprsmeWeb.Endpoint do 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]] + websocket: [connect_info: [:peer_data, session: @session_options], timeout: 60_000], + longpoll: [connect_info: [:peer_data, session: @session_options]] # Mobile API socket for iOS/Android apps socket "/mobile", AprsmeWeb.MobileUserSocket, diff --git a/lib/aprsme_web/live/map_live/index.ex b/lib/aprsme_web/live/map_live/index.ex index 2809c84..8049254 100644 --- a/lib/aprsme_web/live/map_live/index.ex +++ b/lib/aprsme_web/live/map_live/index.ex @@ -25,6 +25,7 @@ defmodule AprsmeWeb.MapLive.Index do alias AprsmeWeb.MapLive.PacketProcessor alias AprsmeWeb.MapLive.RfPath alias AprsmeWeb.MapLive.UrlParams + alias AprsmeWeb.Plugs.RateLimiter, as: RateLimiterPlug alias AprsmeWeb.TimeUtils alias Phoenix.LiveView.JS @@ -83,6 +84,9 @@ defmodule AprsmeWeb.MapLive.Index do initial_bounds = BoundsUtils.calculate_bounds_from_center_and_zoom(final_map_center, final_map_zoom) socket = setup_subscriptions(socket, initial_bounds) + # Store client IP for rate-limiting in event handlers + socket = store_client_ip(socket) + # Final socket assignment {:ok, finalize_mount_assigns(socket, %{ @@ -271,6 +275,30 @@ defmodule AprsmeWeb.MapLive.Index do ) end + # --- Rate limiting helpers for LiveView event handlers --- + + @event_rate_scale 60_000 + + defp store_client_ip(socket) do + ip = RateLimiterPlug.extract_socket_ip(socket) + assign(socket, :client_ip, ip) + end + + # Checks whether an event handler should be rate-limited. + defp check_event_rate(socket, event_name, limit) do + ip = Map.get(socket.assigns, :client_ip, "unknown") + key = "lv_event:#{event_name}:#{ip}" + + case RateLimiterPlug.check_rate_limit(key, @event_rate_scale, limit) do + {:allow, _count} -> + :ok + + {:deny, retry_after} -> + Logger.warning("LiveView event rate-limited: event=#{event_name} ip=#{ip}") + {:deny, retry_after} + end + end + # Handle both bounds_changed and update_bounds events @impl true def handle_event(event, %{"bounds" => bounds}, socket) when event in ["bounds_changed", "update_bounds"] do @@ -434,49 +462,39 @@ defmodule AprsmeWeb.MapLive.Index do @impl true def handle_event("track_callsign", %{"callsign" => callsign}, socket) do - normalized_callsign = String.upcase(String.trim(callsign)) - - socket = - if normalized_callsign == "" do - # Clear tracking - socket - |> assign(tracked_callsign: "", other_ssids: []) - |> update_url_with_current_state() - else - # Set tracking, fetch latest packet, zoom to location, and show marker - other_ssids = Packets.get_other_ssids(normalized_callsign) - latest_packet = Packets.get_latest_packet_for_callsign(normalized_callsign) + case check_event_rate(socket, "track_callsign", 20) do + :ok -> + normalized_callsign = String.upcase(String.trim(callsign)) socket = - assign(socket, - tracked_callsign: normalized_callsign, - other_ssids: other_ssids, - tracked_callsign_latest_packet: latest_packet - ) - - # Zoom to the callsign's location and display its marker - socket = - if latest_packet && latest_packet.lat && latest_packet.lon do - lat = Aprsme.EncodingUtils.to_float(latest_packet.lat) || 0.0 - lon = Aprsme.EncodingUtils.to_float(latest_packet.lon) || 0.0 - - packet_data = DataBuilder.build_packet_data(latest_packet) - + if normalized_callsign == "" do + # Clear tracking socket - |> push_event("zoom_to_location", %{lat: lat, lng: lon, zoom: 12}) - |> push_event("add_historical_packets_batch", %{ - packets: [packet_data], - batch: 0, - is_final: false - }) + |> assign(tracked_callsign: "", other_ssids: []) + |> update_url_with_current_state() else - socket + # Set tracking, fetch latest packet, zoom to location, and show marker + other_ssids = Packets.get_other_ssids(normalized_callsign) + latest_packet = Packets.get_latest_packet_for_callsign(normalized_callsign) + + socket = + assign(socket, + tracked_callsign: normalized_callsign, + other_ssids: other_ssids, + tracked_callsign_latest_packet: latest_packet + ) + + # Zoom to the callsign's location and display its marker + socket = zoom_to_latest_packet(socket, latest_packet) + + push_patch(socket, to: "/#{normalized_callsign}") end - push_patch(socket, to: "/#{normalized_callsign}") - end + {:noreply, socket} - {:noreply, socket} + {:deny, _retry_after} -> + {:noreply, socket} + end end @impl true @@ -492,57 +510,75 @@ defmodule AprsmeWeb.MapLive.Index do @impl true def handle_event("update_trail_duration", %{"trail_duration" => duration}, socket) do - # Validate and convert duration string to hours - hours = parse_trail_duration(duration) + case check_event_rate(socket, "update_trail_duration", 20) do + :ok -> + # Validate and convert duration string to hours + hours = parse_trail_duration(duration) - # Calculate new threshold safely - new_threshold = DateTime.add(DateTime.utc_now(), -hours * 3600, :second) + # Calculate new threshold safely + new_threshold = DateTime.add(DateTime.utc_now(), -hours * 3600, :second) - socket = assign(socket, trail_duration: duration, packet_age_threshold: new_threshold) + socket = assign(socket, trail_duration: duration, packet_age_threshold: new_threshold) - # Update client-side TrailManager with new duration - socket = push_event(socket, "update_trail_duration", %{duration_hours: hours}) + # Update client-side TrailManager with new duration + socket = push_event(socket, "update_trail_duration", %{duration_hours: hours}) - # Update URL with new trail duration - socket = update_url_with_current_state(socket) + # Update URL with new trail duration + socket = update_url_with_current_state(socket) - # Trigger cleanup to remove packets that are now outside the new duration - send(self(), :cleanup_old_packets) + # Trigger cleanup to remove packets that are now outside the new duration + send(self(), :cleanup_old_packets) - # If tracking a callsign at low zoom, refresh the trail line with new duration - socket = - if socket.assigns.tracked_callsign != "" and socket.assigns.map_zoom <= 8 do - DisplayManager.send_trail_line_for_tracked_callsign(socket) - else - socket - end + # If tracking a callsign at low zoom, refresh the trail line with new duration + socket = + if socket.assigns.tracked_callsign != "" and socket.assigns.map_zoom <= 8 do + DisplayManager.send_trail_line_for_tracked_callsign(socket) + else + socket + end - {:noreply, socket} + {:noreply, socket} + + {:deny, _retry_after} -> + {:noreply, socket} + end end @impl true def handle_event("update_historical_hours", %{"historical_hours" => hours}, socket) do - # Validate hours value - validated_hours = parse_historical_hours(hours) - socket = assign(socket, historical_hours: to_string(validated_hours)) + case check_event_rate(socket, "update_historical_hours", 20) do + :ok -> + # Validate hours value + validated_hours = parse_historical_hours(hours) + socket = assign(socket, historical_hours: to_string(validated_hours)) - # Update URL with new historical hours - socket = update_url_with_current_state(socket) + # Update URL with new historical hours + socket = update_url_with_current_state(socket) - # Trigger a reload of historical packets with the new time range - if socket.assigns.map_ready do - send(self(), :reload_historical_packets) + # Trigger a reload of historical packets with the new time range + if socket.assigns.map_ready do + send(self(), :reload_historical_packets) + end + + {:noreply, socket} + + {:deny, _retry_after} -> + {:noreply, socket} end - - {:noreply, socket} end @impl true def handle_event("search_callsign", %{"callsign" => callsign}, socket) do - callsign - |> String.trim() - |> String.upcase() - |> handle_callsign_search(socket) + case check_event_rate(socket, "search_callsign", 30) do + :ok -> + callsign + |> String.trim() + |> String.upcase() + |> handle_callsign_search(socket) + + {:deny, _retry_after} -> + {:noreply, socket} + end end @impl true @@ -2002,4 +2038,25 @@ defmodule AprsmeWeb.MapLive.Index do "Current assigns: historical_loading=#{socket.assigns.historical_loading}, map_bounds=#{inspect(socket.assigns.map_bounds)}" ) end + + defp zoom_to_latest_packet(socket, nil), do: socket + + defp zoom_to_latest_packet(socket, latest_packet) do + if latest_packet.lat && latest_packet.lon do + lat = Aprsme.EncodingUtils.to_float(latest_packet.lat) || 0.0 + lon = Aprsme.EncodingUtils.to_float(latest_packet.lon) || 0.0 + + packet_data = DataBuilder.build_packet_data(latest_packet) + + socket + |> push_event("zoom_to_location", %{lat: lat, lng: lon, zoom: 12}) + |> push_event("add_historical_packets_batch", %{ + packets: [packet_data], + batch: 0, + is_final: false + }) + else + socket + end + end end diff --git a/lib/aprsme_web/plugs/content_security_policy.ex b/lib/aprsme_web/plugs/content_security_policy.ex new file mode 100644 index 0000000..839ae36 --- /dev/null +++ b/lib/aprsme_web/plugs/content_security_policy.ex @@ -0,0 +1,86 @@ +defmodule AprsmeWeb.Plugs.ContentSecurityPolicy do + @moduledoc """ + Sets Content-Security-Policy headers with per-request nonces for script-src. + + Generates a cryptographically random nonce for each request, stores it in + `conn.private[:csp_nonce]` so templates can apply it to inline `