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 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