aprs.me/lib/aprsme_web/plugs/content_security_policy.ex
Graham McIntire 0f2195ef9d
Some checks failed
Build and Push / Build and Push Docker Image (push) Failing after 2s
security: CSP nonces, RemoteIp CIDR gating, rate limiting, NetworkPolicies, deadlock fixes
- Content-Security-Policy: nonce-based per-request plug replacing unsafe-inline scripts
- RemoteIp: CIDR-based trust gating via InetCidr, skips forwarded headers from untrusted peers
- Rate limiting: auth pipeline (20/min), LiveView event handlers, existing mobile channel limits
- NetworkPolicy: 4 k8s policies (web ingress, cluster, metrics, egress) for least-privilege networking
- PartitionManager: deadlock retry with exponential backoff in drop_partition
- Tests: reduced parallelism (max_cases 4), packets_test async:false to prevent trigger contention
- k8s: APRS_PASSWORD -> APRS_PASSCODE secretRef, vendor/aprs submodule hardened
2026-07-26 14:04:56 -05:00

86 lines
2.3 KiB
Elixir

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 `<script>` tags,
and emits a `Content-Security-Policy` response header.
External sources are limited to what the application actually uses:
- Map tiles (OpenStreetMap, CARTO)
- Plausible analytics (self-hosted)
- Leaflet/plugin CDNs (unpkg, jsDelivr, cdnjs)
- Sentry error tracking
"""
import Plug.Conn
@external_script_sources [
"https://a.w5isp.com",
"https://js.sentry-cdn.com",
"https://unpkg.com",
"https://cdn.jsdelivr.net",
"https://cdnjs.cloudflare.com"
]
@external_style_sources [
"https://unpkg.com"
]
@external_connect_sources [
"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"
]
@nonce_bytes 16
@doc false
def init(opts), do: opts
@doc false
def call(conn, _opts) do
nonce = generate_nonce()
conn = put_private(conn, :csp_nonce, nonce)
csp = build_csp(nonce)
conn
|> delete_resp_header("content-security-policy")
|> put_resp_header("content-security-policy", csp)
end
defp generate_nonce do
@nonce_bytes
|> :crypto.strong_rand_bytes()
|> Base.encode64()
end
defp build_csp(nonce) do
Enum.join(
[
"default-src 'self'",
"script-src 'self' 'nonce-#{nonce}' #{Enum.join(@external_script_sources, " ")}",
"style-src 'self' 'unsafe-inline' #{Enum.join(@external_style_sources, " ")}",
"img-src 'self' data: https: http: blob:",
"font-src 'self' data:",
"connect-src 'self' #{Enum.join(@external_connect_sources, " ")}",
"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:"
],
"; "
)
end
end