Heroicon defined in core_components 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

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

1 Like

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

I had some trouble with Heroicons not showing upon upgrading to Phoenix 1.7, and this was the last missing piece to be put into tailwind.config.js>

plugin(function ({ matchComponents, theme }) {
        let iconsDir = path.join(__dirname, "../deps/heroicons/optimized");
        let values = {};
        let icons = [
            ["", "/24/outline"],
            ["-solid", "/24/solid"],
            ["-mini", "/20/solid"],
            ["-micro", "/16/solid"],
        ];
        icons.forEach(([suffix, dir]) => {
            fs.readdirSync(path.join(iconsDir, dir)).forEach((file) => {
                let name = path.basename(file, ".svg") + suffix;
                values[name] = {
                    name,
                    fullPath: path.join(iconsDir, dir, file),
                };
            });
        });
        matchComponents(
            {
                hero: ({ name, fullPath }) => {
                    let content = fs
                        .readFileSync(fullPath)
                        .toString()
                        .replace(/\r?\n|\r/g, "");
                    let size = theme("spacing.6");
                    if (name.endsWith("-mini")) {
                        size = theme("spacing.5");
                    } else if (name.endsWith("-micro")) {
                        size = theme("spacing.4");
                    }
                    return {
                        [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
                        "-webkit-mask": `var(--hero-${name})`,
                        mask: `var(--hero-${name})`,
                        "mask-repeat": "no-repeat",
                        "background-color": "currentColor",
                        "vertical-align": "middle",
                        display: "inline-block",
                        width: size,
                        height: size,
                    };
                },
            },
            { values }
        );
    })

I had a similar problem with latest greatest setup

  • {:phoenix, “~> 1.7.14”}
  • {:heroicons,
    github: “tailwindlabs/heroicons”,
    tag: “v2.1.5”,
    sparse: “optimized”,
    app: false,
    compile: false,
    depth: 1}

I had the strange behaviors that only some icons worked, others not.
The icons I used for longer in the project, where working fine… but creating an <.icon /> with a new name value did not work. The icon was just not rendered…

What solved this issue for me:

Whenever I add a new icon I have to manually run

mix phx.digest

Then restart the server and the icon appears…

Maybe that helps someone’s - don’t know, if that can be tackled in another way - but its okay to run the mix command from time to time…

I anyways start now my server with this command:

source .env && mix phx.digest && iex -S mix phx.server

Are your regular tailwind classes working?
I mean if you use one you hadn’t before?

@Hermanverschooten - Oh! Interesting hint - yes, indeed!
I just checked it and took a color class that I for sure never had before in this project - and yes… that’s also not working without mix phx.digest and restart …

… and that might also be the reason why I constantly think I suck in css :confused:

Can you check this in your config files?

in config/config.exs there should be a block like this:

config :tailwind,
  version: "3.3.5",
  default: [
    args: ~w(
      --config=tailwind.config.js
      --input=css/app.css
      --output=../priv/static/assets/app.css
    ),
    cd: Path.expand("../assets", __DIR__)
  ]

and config/dev/exs

config :your_app, YourAppWeb.Endpoint,
  # Binding to loopback ipv4 address prevents access from other machines.
  # Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
  http: [
    ip: {0, 0, 0, 0},
    port: 4000,
    http_options: [
      log_protocol_errors: false
    ]
  ],
  check_origin: false,
  code_reloader: true,
  debug_errors: true,
  secret_key_base: "...secret...hidden",
  watchers: [
    # Start the esbuild watcher by calling Esbuild.install_and_run(:default, args)
    esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]},
    tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]}
  ]

Especially the :watchers part ,that’s what lets tailwind do its work.

1 Like

Yes, pretty much like that:

# --------------------------
# TAILWIND CONFIG
# --------------------------
config :tailwind,
  version: "3.3.2",
  default: [
    args: ~w(
      --config=tailwind.config.js
      --input=css/app.css
      --output=../priv/static/assets/app.css
    ),
    cd: Path.expand("../assets", __DIR__)
  ]

and…

config :my_app, MyAppWeb.Endpoint,
  # Binding to loopback ipv4 address prevents access from other machines.
  # Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
  http: [ip: {127, 0, 0, 1}, port: 4000],
  check_origin: false,
  code_reloader: true,
  debug_errors: true,
  secret_key_base: "...secret...",
  watchers: [
    esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]},
    tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]}
  ]

Its watching - when saving files, its immediately logging…

Done in 74ms.

Okay that seems to be doing what it should.

Could you show me the line in your HTML header where you link to your app.css?

1 Like

These are the relevant lines…

...
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
</script>
...

That also seems correct.

could you stop your app and do mix phx.digest.clean --all, then restart your app.
Do you have your assets now? Or are they missing?

1 Like

@Hermanverschooten - Oh! WOW :smiley: - that actually did it… there was then obviously sth. off…
Now changes are applied immediately correctly on file saving !!!

Thanks!!! :pray:

… did not know that there is a --all parameter :slight_smile:

I think Phoenix uses the fingerprinted files if they are there.
You normally never do a phx.digest in development, that is pure for production.

2 Likes

ah… now I know :slight_smile: Thanks!