Heroicon not working

Look at your assets/tailwind.config.js and look at the content key. Explanation is here.

Another related issue I discovered recently that people landing here after a search might find is that if you introduce CORS protection by adding eg:

plug(:put_secure_browser_headers, %{"content-security-policy" => "default-src 'self'"})

Then the heroicons are blocked if you are running on localhost

My solution was to add Corsica to my project then swap the :put_secure_browser_headers plug for eg:

plug Corsica, origins: ["https://foo.com", "http://localhost"]
1 Like

I had this exactly issue. Te problems was the

plug(:put_secure_browser_headers, %{"content-security-policy" => "default-src 'self'"})

Is there a way to correctly set CSP and make the heroicons work? Tried a lot of things already

Make your own CSP headers.

For example:

    plug :put_secure_browser_headers, %{"content-security-policy" => MyAppWeb.CSP.header_value()}

with csp module like this:

defmodule MyAppWeb.CSP do
  @moduledoc """
  Content-Security-Policy

  https://www.w3.org/TR/CSP2/#directives
  https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
  """

  @hcaptcha ["https://hcaptcha.com", "https://*.hcaptcha.com"]
  @stripe ["https://js.stripe.com"]
  @google_fonts ["https://fonts.googleapis.com"]
  @env_specific_csp if Mix.env() == :dev,
                      do: ["'unsafe-inline'", "'unsafe-eval'", "blob:", "http://localhost:4007"],
                      else: []

  @header_kvp_list [
    default_src: ["'self'"],
    img_src: ["'self'", "data:"] ++ @env_specific_csp,
    script_src: ["'self'"] ++ @hcaptcha ++ @stripe ++ @env_specific_csp,
    object_src: ["'self'"],
    frame_src: ["'self'"] ++ @hcaptcha ++ @stripe,
    style_src: ["'self'", "'unsafe-inline'"] ++ @hcaptcha ++ @stripe ++ @google_fonts ++ @env_specific_csp,
    connect_src: ["'self'"] ++ @hcaptcha ++ @stripe
  ]

  @spec header_value() :: String.t()
  def header_value do
    configured_header_kvp_list = Application.get_env(:my_app, :csp_headers, [])

    @header_kvp_list
    |> Keyword.merge(configured_header_kvp_list)
    |> Enum.map_join("; ", &map_header_kvp_to_string/1)
  end

  defp map_header_kvp_to_string({atom_key, list_value}) do
    string_key = atom_key |> to_string() |> String.replace("_", "-")
    string_value = Enum.join(list_value, " ")
    "#{string_key} #{string_value}"
  end
end

That is lifted straight from one of my hobby projects. localhost:4007 is an imgproxy server.

Anyway, I think the thing that is needed is the google fonts bit? Not sure, but this solved heroicons for me in terms of CSP.


Edit 26 March 2024: read this instead: Blog Post: Content Security Policy header with Phoenix LiveView

2 Likes

What I actually needed was to update my plug like

plug(:put_secure_browser_headers, %{"content-security-policy" => "default-src 'self' data:"})

What I was not being able to find the correct value (it turned out to be data:). I finally got it by looking at @slouchpie solution. That solution is currently a lot more of what I need, but it could be helpful for bigger projects or other people :+1:

Am feeling embarrassed I confused CORS with CSP! Building on @slouchpie’s solution I created a plug that sets the CSP for me and adds a nonce to the assigns so it can be used in various places.

defmodule TvpNgWeb.CSP do
  @moduledoc """
  Content-Security-Policy

  With thanks to the authors of: 
  https://www.w3.org/TR/CSP2/#directives
  https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
  https://csp-evaluator.withgoogle.com/
  https://furlough.merecomplexities.com/elixir/phoenix/security/2021/02/26/content-security-policy-configuration-in-phoenix.html
  https://francis.chabouis.fr/posts/csp-nonce-with-phoenix/
  """
  def init(options), do: options

  defp generate_nonce(size \\ 10),
    do: size |> :crypto.strong_rand_bytes() |> Base.url_encode64(padding: false)

  def call(conn, _opts) do
    # a random string is generated
    nonce = generate_nonce()
    csp_headers = header_value(nonce)

    conn
    # the nonce is saved in the connection assigns
    |> Plug.Conn.assign(:csp_nonce_value, nonce)
    |> Phoenix.Controller.put_secure_browser_headers(%{"content-security-policy" => csp_headers})
  end

  @env_specific_csp (case Mix.env() do
                       :prod -> []
                       _ -> ["'unsafe-inline'"]
                     end)

  @header_kvp_list [
    img_src: ["'self'", "data:"] ++ @env_specific_csp,
    script_src: ["'strict-dynamic'", "NONCE"],
    object_src: ["'none'"],
    base_uri: ["'none'"],
    # require_trusted_types_for: ["'script'"] - See https://github.com/phoenixframework/phoenix_live_view/issues/3166
  ]

  defp header_value(nonce) do
    configured_header_kvp_list = Application.get_env(:tvp_ng, :csp_headers, [])

    @header_kvp_list
    |> Keyword.merge(configured_header_kvp_list)
    |> Enum.map_join("; ", &map_header_kvp_to_string(nonce, &1))
  end

  defp map_header_kvp_to_string(nonce, {atom_key, list_value}) do
    string_key = atom_key |> to_string() |> String.replace("_", "-")
    string_value = list_value |> Enum.join(" ") |> String.replace("NONCE", "'nonce-#{nonce}'")
    "#{string_key} #{string_value}"
  end
end

eg. phoenix_live_dashboard:

live_dashboard("/dashboard", metrics: TvpNgWeb.Telemetry, csp_nonce_assign_key: :csp_nonce_value)

and root.html.heex

<script defer phx-track-static nonce={@csp_nonce_value} type="text/javascript" src={~p"/assets/app.js"}>

I found Google’s CSP Chrome plugin from to be super helpful with this - https://csp-evaluator.withgoogle.com/

2 Likes

This is awesome! Thanks for sharing the code.

Just fyi, there are also items that should be added for backward compatibility. By default, for javascript, it should be using “strict-dynamic”. Any browser supporting that will use it, but if not, there should be a fallback.

This is from playing around with https://csp-evaluator.withgoogle.com/

      script-src 'self' 'unsafe-inline' https: 'nonce-#{nonce}' 'strict-dynamic';

I’ve also added HSTS headers and enabled feature-policy. There are some adjustments for safari. Note that feature-policy could break people using extensions on your website. I’m still running prod in report-only mode, but the following is the code.

  defp generate_nonce(size \\ 10), do: size |> :crypto.strong_rand_bytes() |> Base.url_encode64(padding: false)
  @doc """

  Sets CSP, HSTS, and Feature/Permissions policies

  https://csp-evaluator.withgoogle.com/
  Consider adding 'unsafe-inline' (ignored by browsers supporting nonces/hashes) to be backward compatible with older browsers.
  https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src

  Need to add more than self for safari for connect-src
  https://github.com/w3c/webappsec-csp/issues/7
  https://bugs.webkit.org/show_bug.cgi?id=235873
  """
  def put_extra_secure_browser_headers(conn, _) do

    default_headers = %{
      "strict-transport-security" => "max-age=31536000; includeSubDomains",
      #"Permissions-Policy" => "geolocation=(self), microphone=(), camera=()",
      "feature-policy" => "geolocation 'self'; microphone 'none'; camera 'none';"
    }
    nonce = generate_nonce()

    # script-src 'self' 'nonce-#{nonce}' https://sandbox-checkout.paddle.com/ https://sandbox-cdn.paddle.com/ https://plausible.io/;
    csp_content = case Application.get_env(:your_app, :environment) do
                    :prod ->
                      """
      report-uri /csp/report/;
      base-uri 'self';
      default-src 'self';
      script-src 'self' 'unsafe-inline' https: 'nonce-#{nonce}' 'strict-dynamic';
      frame-src 'self' https://subscription-management.paddle.com https://buy.paddle.com;
      style-src 'self' 'unsafe-inline' https://cdn.paddle.com/;
      img-src 'self' https://cdn.paddle.com/ blob: data:;
      object-src 'none';
      connect-src 'self' https://plausible.io/ wss://your.app ws://your.app;
      """
                      _ ->
                      """
      report-uri /csp/report/;
      base-uri 'self';
      default-src 'self';
      script-src 'self' 'unsafe-inline' https: 'nonce-#{nonce}' 'strict-dynamic';
      frame-src 'self' https://sandbox-subscription-management.paddle.com https://sandbox-buy.paddle.com;
      style-src 'self' 'unsafe-inline' https://sandbox-cdn.paddle.com/;
      img-src 'self' https://cdn.paddle.com/ blob: data:;
      object-src 'none';
      connect-src 'self' https://plausible.io/ wss://test.your.app ws://test.your.app wss://localhost:* ws://localhost:*;
      """
    end

    csp_header = case Application.get_env(:hora, :environment)  do
      :prod -> %{"content-security-policy-report-only" => csp_content |> String.replace("\n", "")}
      _ -> %{"content-security-policy" => csp_content |> String.replace("\n", "")}
    end

    headers = Map.merge(default_headers, csp_header )

    Plug.Conn.merge_resp_headers(conn, headers)
    |> assign(:csp_nonce, nonce)
    |> Plug.Conn.put_session(:csp_nonce, nonce)
  end

2 Likes