peterhartman

peterhartman

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
Post #21
tairosonloa

tairosonloa

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

0
Post #22
slouchpie

slouchpie

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
Post #23
tairosonloa

tairosonloa

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
Post #24
peterhartman

peterhartman

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
Post #25
slouchpie

slouchpie

This is awesome! Thanks for sharing the code.

0
Post #26
tj0

tj0

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
Post #27
josefrichter

josefrichter

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 }
        );
    })
0
Post #28
marschro

marschro

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
0
Post #29
Hermanverschooten

Hermanverschooten

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

0
Post #30