Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions config/dev.exs
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ config :philomena, PhilomenaWeb.Endpoint,
# Disable Pwned Passwords API check in development
config :philomena, pwned_passwords: false

# Relax CSP rules in development
config :philomena, csp_relaxed: true
# Relax CSP on development error pages so Plug.Debugger can render
config :philomena, csp_relax_on_error: true

# Enable Vite HMR
config :philomena, vite_reload: true
Expand Down
28 changes: 28 additions & 0 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,34 @@ if config_env() != :test do
queue_interval: 20_000
end

sentry_dsn = System.get_env("SENTRY_DSN")

if not is_nil(sentry_dsn) do
config :sentry,
dsn: sentry_dsn,
environment_name: config_env(),
enable_source_code_context: true,
root_source_code_paths: [app_dir]

loader_script_url = System.fetch_env!("SENTRY_LOADER_SCRIPT_URL")

loader_script_src =
loader_script_url
|> URI.parse()
|> then(&%URI{scheme: &1.scheme, host: &1.host, port: &1.port})

loader_connect_src =
sentry_dsn
|> URI.parse()
|> then(&%URI{scheme: &1.scheme, host: &1.host, port: &1.port})

config :philomena,
sentry_enabled: true,
sentry_loader_script_url: loader_script_url,
sentry_loader_script_src: to_string(loader_script_src),
sentry_loader_connect_src: to_string(loader_connect_src)
end

if config_env() == :prod do
# Production mailer config
config :philomena, Philomena.Mailer,
Expand Down
7 changes: 7 additions & 0 deletions lib/philomena/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ defmodule Philomena.Application do

def start(_type, _args) do
configure_logging()
configure_tracing()

# List all child processes to be supervised
children = [
Expand Down Expand Up @@ -67,6 +68,12 @@ defmodule Philomena.Application do

defp valid_node_name(node), do: node

defp configure_tracing do
OpentelemetryBandit.setup()
OpentelemetryEcto.setup([:philomena, :repo])
OpentelemetryPhoenix.setup(adapter: :bandit)
end

defp configure_logging() do
# Log filtering design is borrowed from the Rust's `tracing` observability framework.
# Specifically from the `EnvFilter` syntax:
Expand Down
19 changes: 6 additions & 13 deletions lib/philomena_web/config.ex
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
defmodule PhilomenaWeb.Config do
# Dialyzer only analyzes beam files directly and cannot see the compile-time variance in
# the associated values, so it flags a false positive here.
@dialyzer [:no_match]
@moduledoc """
Runtime accessors for web configuration.
"""

@reload_enabled Application.compile_env(:philomena, :vite_reload, false)
@csp_relaxed Application.compile_env(:philomena, :csp_relaxed, false)

defmacro vite_hmr?(do: do_clause, else: else_clause) do
if(@reload_enabled, do: do_clause, else: else_clause)
end

defmacro csp_relaxed?(do: do_clause, else: else_clause) do
if(@csp_relaxed, do: do_clause, else: else_clause)
end
def vite_hmr?, do: Application.get_env(:philomena, :vite_reload, false)
def csp_relax_on_error?, do: Application.get_env(:philomena, :csp_relax_on_error, false)
def sentry_enabled?, do: Application.get_env(:philomena, :sentry_enabled, false)
end
196 changes: 196 additions & 0 deletions lib/philomena_web/content_security_policy.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
defmodule PhilomenaWeb.ContentSecurityPolicy do
@moduledoc """
Builds and serializes the application's Content Security Policy (CSP).

Policies are represented as maps whose keys are directive names (for example,
`:script_src`) and whose values are lists of CSP source expressions. The
policy produced by `conn_policy/2` starts with the application's restrictive
baseline and then incorporates request-specific and environment-specific
sources:

* same-origin sources are allowed for document, script, connection, form,
manifest, image, and media requests;
* objects and frame ancestors are denied, while `frame-src` is denied until
a source is explicitly added;
* `:cdn_host` and `:camo_host` configuration values are added as HTTPS image
and media origins; and
* Vite development origins are added when Vite hot-module reloading is
enabled.

Use `merge_policy/2` to append source expressions to an existing policy, and
`serialize/1` to turn the policy into the value of a
`content-security-policy` response header.
"""

alias PhilomenaWeb.Config
alias PhilomenaWeb.FrontendAssets

@directives [
default_src: "default-src",
script_src: "script-src",
connect_src: "connect-src",
style_src: "style-src",
object_src: "object-src",
frame_ancestors: "frame-ancestors",
frame_src: "frame-src",
form_action: "form-action",
manifest_src: "manifest-src",
img_src: "img-src",
media_src: "media-src"
]

# An entry of 'none' is not permitted to be extended by any request.
# Other entries can be extended.
@base_policy %{
default_src: ["'self'"],
script_src: ["'self'"],
connect_src: ["'self'"],
style_src: ["'self'"],
object_src: ["'none'"],
frame_ancestors: ["'none'"],
frame_src: [],
form_action: ["'self'"],
manifest_src: ["'self'"],
img_src: ["'self'", "blob:", "data:"],
media_src: ["'self'", "blob:", "data:"]
}

@type policy :: %{optional(atom()) => [String.t()]}

@doc """
Builds the CSP policy applicable to a `m:Plug.Conn`.

The returned policy begins with the module's baseline policy, adds the
configured CDN and Camo hosts to `img-src` and `media-src`, and adds the Vite
development and websocket origins when Vite hot-module reloading is enabled.

`additions` contains request-specific directive sources and is merged last,
so callers can extend the baseline for a particular response. It defaults to
an empty policy.

Hosts configured through `:cdn_host` and `:camo_host` are interpreted as host
names and serialized as HTTPS origins. `conn` is used to derive the Vite
origins, so it should contain the request host when hot reloading is in use.

## Examples

iex> conn = Plug.Test.conn(:get, "/")
iex> policy = PhilomenaWeb.ContentSecurityPolicy.conn_policy(conn)
iex> policy.default_src
["'self'"]

"""
@spec conn_policy(Plug.Conn.t(), policy()) :: policy()
def conn_policy(conn, additions \\ %{}) do
@base_policy
|> maybe_media_origin(Application.get_env(:philomena, :cdn_host))
|> maybe_media_origin(Application.get_env(:philomena, :camo_host))
|> maybe_sentry(Config.sentry_enabled?())
|> maybe_vite_hmr(conn, Config.vite_hmr?())
|> merge_policy(additions)
end

@doc """
Appends source expressions from `additions` to a CSP `policy`.

Entries with the same directive are concatenated in their existing order;
directives present only in `additions` are added to the map. This function
can be used repeatedly while a request accumulates permissions.

Unknown directive keys are preserved in the returned map, although
`serialize/1` emits only the directives supported by this module.

## Examples

iex> policy = %{script_src: ["'self'"]}
iex> PhilomenaWeb.ContentSecurityPolicy.merge_policy(policy, %{script_src: ["https://cdn.example"]})
%{script_src: ["'self'", "https://cdn.example"]}

"""
@spec merge_policy(policy(), policy()) :: policy()
def merge_policy(policy, additions) do
Map.merge(policy, additions, fn _key, old_sources, new_sources ->
old_sources ++ new_sources
end)
end

@doc """
Serializes a policy map as a `Content-Security-Policy` header value.

Directives are emitted in a stable order. Source expressions for each
directive are de-duplicated while preserving their first-seen order; a
directive with no sources is emitted as `<directive> 'none'`. Only the
directives supported by this module are emitted, even when `policy` contains
additional keys.

## Examples

iex> policy = %{default_src: ["'self'"], frame_src: ["https://frame.example"]}
iex> serialized = PhilomenaWeb.ContentSecurityPolicy.serialize(policy)
iex> String.split(serialized, "; ") |> Enum.take(2)
["default-src 'self'", "script-src 'none'"]

"""
@spec serialize(policy()) :: String.t()
def serialize(policy) do
@directives
|> Enum.map(fn {directive, name} ->
policy
|> effective_values(directive)
|> then(&Enum.join([name | &1], " "))
end)
|> Enum.join("; ")
end

defp effective_values(policy, directive) do
policy
|> Map.get(directive, [])
|> Enum.uniq()
|> case do
[] ->
["'none'"]

values ->
values
end
end

defp maybe_media_origin(policy, origin) when origin in [nil, ""],
do: policy

defp maybe_media_origin(policy, origin) do
origin = URI.to_string(%URI{scheme: "https", host: origin})

merge_policy(policy, %{
img_src: [origin],
media_src: [origin]
})
end

defp maybe_sentry(policy, false),
do: policy

defp maybe_sentry(policy, true) do
script_src = Application.fetch_env!(:philomena, :sentry_loader_script_src)
connect_src = Application.fetch_env!(:philomena, :sentry_loader_connect_src)

merge_policy(policy, %{
script_src: [script_src],
connect_src: [connect_src]
})
end

defp maybe_vite_hmr(policy, _conn, false),
do: policy

defp maybe_vite_hmr(policy, conn, true) do
origin = FrontendAssets.vite_origin(conn)
websocket_origin = FrontendAssets.vite_websocket_origin(conn)

merge_policy(policy, %{
script_src: [origin],
connect_src: [origin, websocket_origin],
style_sources: ["'unsafe-inline'"]
})
end
end
2 changes: 2 additions & 0 deletions lib/philomena_web/endpoint.ex
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ defmodule PhilomenaWeb.Endpoint do
pass: ["*/*"],
json_decoder: Phoenix.json_library()

plug Sentry.PlugContext

plug Plug.MethodOverride
plug Plug.Head

Expand Down
25 changes: 25 additions & 0 deletions lib/philomena_web/frontend_assets.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
defmodule PhilomenaWeb.FrontendAssets do
@moduledoc "Helpers for development and compiled frontend asset URLs."

@vite_port 5173

@spec vite_origin(Plug.Conn.t()) :: String.t()
def vite_origin(%Plug.Conn{host: host}) do
URI.to_string(%URI{scheme: "http", host: host, port: @vite_port})
end

@spec vite_asset_url(Plug.Conn.t(), String.t()) :: String.t()
def vite_asset_url(conn, path) when is_binary(path) do
vite_origin(conn) <> "/" <> String.trim_leading(path, "/")
end

@spec vite_websocket_origin(Plug.Conn.t()) :: String.t()
def vite_websocket_origin(%Plug.Conn{host: host}) do
URI.to_string(%URI{scheme: "ws", host: host, port: @vite_port})
end

@spec sentry_loader_script_url() :: String.t()
def sentry_loader_script_url do
Application.fetch_env!(:philomena, :sentry_loader_script_url)
end
end
9 changes: 5 additions & 4 deletions lib/philomena_web/plugs/captcha_plug.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ defmodule PhilomenaWeb.CaptchaPlug do
end

defp maybe_assign_csp_headers(conn, nil) do
conn
|> ContentSecurityPolicyPlug.permit_source(:script_src, @hcaptcha_url)
|> ContentSecurityPolicyPlug.permit_source(:frame_src, @hcaptcha_url)
|> ContentSecurityPolicyPlug.permit_source(:style_src, @hcaptcha_url)
ContentSecurityPolicyPlug.permit_sources(conn, %{
script_src: @hcaptcha_url,
frame_src: @hcaptcha_url,
style_src: @hcaptcha_url
})
end

defp maybe_assign_csp_headers(conn, _user) do
Expand Down
Loading
Loading