aprs.me/test/aprsme_web/plugs/content_security_policy_test.exs
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

123 lines
4.4 KiB
Elixir

defmodule AprsmeWeb.Plugs.ContentSecurityPolicyTest do
use AprsmeWeb.ConnCase
alias AprsmeWeb.Plugs.ContentSecurityPolicy
describe "call/2" do
test "sets Content-Security-Policy header", %{conn: conn} do
conn = ContentSecurityPolicy.call(conn, [])
csp = conn |> get_resp_header("content-security-policy") |> List.first()
assert is_binary(csp) and byte_size(csp) > 0
# Verify required directives are present
assert csp =~ "default-src 'self'"
assert csp =~ "object-src 'none'"
assert csp =~ "frame-ancestors 'none'"
assert csp =~ "base-uri 'self'"
assert csp =~ "form-action 'self'"
end
test "includes nonce in script-src", %{conn: conn} do
conn = ContentSecurityPolicy.call(conn, [])
csp = conn |> get_resp_header("content-security-policy") |> List.first()
# Verify nonce-based script-src is used instead of unsafe-inline
assert csp =~ ~r/script-src[^;]*'nonce-[A-Za-z0-9+\/=]+'/
# Extract just the script-src directive and ensure it has no unsafe-inline
[script_src] = Regex.run(~r/script-src\s+([^;]+)/, csp, capture: :all_but_first)
refute script_src =~ "'unsafe-inline'"
end
test "stores nonce in conn.private", %{conn: conn} do
conn = ContentSecurityPolicy.call(conn, [])
nonce = conn.private[:csp_nonce]
assert is_binary(nonce) and byte_size(nonce) > 0
# Verify the private nonce matches the one in the header
csp = conn |> get_resp_header("content-security-policy") |> List.first()
assert csp =~ "'nonce-#{nonce}'"
end
test "generates a unique nonce per request", %{conn: conn} do
conn1 = ContentSecurityPolicy.call(conn, [])
conn2 = ContentSecurityPolicy.call(build_conn(), [])
nonce1 = conn1.private[:csp_nonce]
nonce2 = conn2.private[:csp_nonce]
assert nonce1 != nonce2
end
test "allows external script sources", %{conn: conn} do
conn = ContentSecurityPolicy.call(conn, [])
csp = conn |> get_resp_header("content-security-policy") |> List.first()
# Plausible analytics (self-hosted)
assert csp =~ "script-src" and csp =~ "https://a.w5isp.com"
# Sentry error tracking
assert csp =~ "script-src" and csp =~ "https://js.sentry-cdn.com"
# Leaflet/map CDN
assert csp =~ "script-src" and csp =~ "https://unpkg.com"
end
test "allows map tile connect sources", %{conn: conn} do
conn = ContentSecurityPolicy.call(conn, [])
csp = conn |> get_resp_header("content-security-policy") |> List.first()
assert csp =~ "connect-src" and csp =~ "https://tile.openstreetmap.org"
assert csp =~ "connect-src" and csp =~ "https://*.tile.openstreetmap.org"
assert csp =~ "connect-src" and csp =~ "https://*.basemaps.cartocdn.com"
assert csp =~ "connect-src" and csp =~ "wss:"
end
test "allows sentry connect sources", %{conn: conn} do
conn = ContentSecurityPolicy.call(conn, [])
csp = conn |> get_resp_header("content-security-policy") |> List.first()
assert csp =~ "connect-src" and csp =~ "https://*.ingest.sentry.io"
assert csp =~ "connect-src" and csp =~ "https://*.sentry.io"
end
test "allows image sources from any HTTPS origin for map tiles", %{conn: conn} do
conn = ContentSecurityPolicy.call(conn, [])
csp = conn |> get_resp_header("content-security-policy") |> List.first()
assert csp =~ "img-src" and csp =~ "https:"
assert csp =~ "img-src" and csp =~ "data:"
assert csp =~ "img-src" and csp =~ "blob:"
end
test "allows unsafe-inline for style-src (required by Tailwind and Phoenix)", %{conn: conn} do
conn = ContentSecurityPolicy.call(conn, [])
csp = conn |> get_resp_header("content-security-policy") |> List.first()
# style-src still needs unsafe-inline for Tailwind/Phoenix inline styles
assert csp =~ "style-src" and csp =~ "'unsafe-inline'"
end
test "sets worker-src to allow web workers and blob workers", %{conn: conn} do
conn = ContentSecurityPolicy.call(conn, [])
csp = conn |> get_resp_header("content-security-policy") |> List.first()
assert csp =~ "worker-src 'self' blob:"
end
test "blocks framing via frame-ancestors 'none'", %{conn: conn} do
conn = ContentSecurityPolicy.call(conn, [])
csp = conn |> get_resp_header("content-security-policy") |> List.first()
assert csp =~ "frame-ancestors 'none'"
end
end
end