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"]
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
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
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/
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
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
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.
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?
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?
@Hermanverschooten - Oh! WOW - that actually did it… there was then obviously sth. off…
Now changes are applied immediately correctly on file saving !!!
Thanks!!!
… did not know that there is a --all
parameter
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.
ah… now I know Thanks!