Some checks failed
Build and Push / Build and Push Docker Image (push) Failing after 2s
- 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
86 lines
2.3 KiB
Elixir
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
|