diff --git a/config/config.exs b/config/config.exs index 23f3cb4ee..413108fb8 100644 --- a/config/config.exs +++ b/config/config.exs @@ -215,6 +215,24 @@ config :mobilizon, :maps, type: :openstreetmap ] +config :mobilizon, :http_security, + enabled: true, + sts: false, + sts_max_age: 31_536_000, + csp_policy: [ + script_src: [], + style_src: [], + connect_src: [], + font_src: [], + img_src: ["*.tile.openstreetmap.org"], + manifest_src: [], + media_src: [], + object_src: [], + frame_src: [], + frame_ancestors: [] + ], + referrer_policy: "same-origin" + config :mobilizon, :anonymous, participation: [ allowed: true, diff --git a/lib/web/endpoint.ex b/lib/web/endpoint.ex index 8a0601be7..24ba70034 100644 --- a/lib/web/endpoint.ex +++ b/lib/web/endpoint.ex @@ -6,6 +6,7 @@ defmodule Mobilizon.Web.Endpoint do use Absinthe.Phoenix.Endpoint plug(Mobilizon.Web.Plugs.SetLocalePlug) + plug(Mobilizon.Web.Plugs.HTTPSecurityPlug) # For e2e tests if Application.get_env(:mobilizon, :sql_sandbox) do @@ -75,4 +76,9 @@ defmodule Mobilizon.Web.Endpoint do ) plug(Mobilizon.Web.Router) + + @spec websocket_url :: String.t() + def websocket_url do + String.replace_leading(url(), "http", "ws") + end end diff --git a/lib/web/plugs/http_security_plug.ex b/lib/web/plugs/http_security_plug.ex new file mode 100644 index 000000000..8a5f2161d --- /dev/null +++ b/lib/web/plugs/http_security_plug.ex @@ -0,0 +1,116 @@ +# Portions of this file are derived from Pleroma: +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mobilizon.Web.Plugs.HTTPSecurityPlug do + @moduledoc """ + A plug to setup some HTTP security-related headers, like CSP + """ + + alias Mobilizon.Config + import Plug.Conn + + require Logger + + def init(opts), do: opts + + def call(conn, _options) do + if Config.get([:http_security, :enabled]) do + conn + |> merge_resp_headers(headers()) + |> maybe_send_sts_header(Config.get([:http_security, :sts])) + else + conn + end + end + + defp headers do + referrer_policy = Config.get([:http_security, :referrer_policy]) + + [ + {"referrer-policy", referrer_policy}, + {"content-security-policy", csp_string()} + ] + end + + @static_csp_rules [ + "default-src 'none'", + "base-uri 'self'", + "manifest-src 'self'" + ] + + @csp_start [Enum.join(@static_csp_rules, ";") <> ";"] + + defp csp_string do + scheme = Config.get([Pleroma.Web.Endpoint, :url])[:scheme] + static_url = Mobilizon.Web.Endpoint.static_url() + websocket_url = Mobilizon.Web.Endpoint.websocket_url() + + img_src = + ["img-src 'self' data: blob: "] ++ Config.get([:http_security, :csp_policy, :img_src]) + + media_src = ["media-src 'self' "] ++ Config.get([:http_security, :csp_policy, :media_src]) + + connect_src = + ["connect-src 'self' blob: ", static_url, ?\s, websocket_url] ++ + Config.get([:http_security, :csp_policy, :connect_src]) + + script_src = + if Config.get(:env) == :dev do + "script-src 'self' 'unsafe-eval' 'unsafe-inline' " + else + "script-src 'self' " + end + + script_src = [script_src] ++ Config.get([:http_security, :csp_policy, :script_src]) + + style_src = + if Config.get(:env) == :dev do + "style-src 'self' 'unsafe-inline' " + else + "style-src 'self' " + end + + style_src = [style_src] ++ Config.get([:http_security, :csp_policy, :style_src]) + + font_src = ["font-src 'self' "] ++ Config.get([:http_security, :csp_policy, :font_src]) + + frame_src = if Config.get(:env) == :dev, do: "frame-src 'self' ", else: "frame-src 'none' " + frame_src = [frame_src] ++ Config.get([:http_security, :csp_policy, :frame_src]) + + frame_ancestors = + if Config.get(:env) == :dev, do: "frame-ancestors 'self' ", else: "frame-ancestors 'none' " + + frame_ancestors = + [frame_ancestors] ++ Config.get([:http_security, :csp_policy, :frame_ancestors]) + + insecure = if scheme == "https", do: "upgrade-insecure-requests" + + @csp_start + |> add_csp_param(script_src) + |> add_csp_param(style_src) + |> add_csp_param(connect_src) + |> add_csp_param(img_src) + |> add_csp_param(media_src) + |> add_csp_param(font_src) + |> add_csp_param(frame_src) + |> add_csp_param(frame_ancestors) + |> add_csp_param(insecure) + |> :erlang.iolist_to_binary() + end + + defp add_csp_param(csp_iodata, nil), do: csp_iodata + + defp add_csp_param(csp_iodata, param), do: [[param, ?;] | csp_iodata] + + defp maybe_send_sts_header(conn, true) do + max_age_sts = Config.get([:http_security, :sts_max_age]) + + merge_resp_headers(conn, [ + {"strict-transport-security", "max-age=#{max_age_sts}; includeSubDomains"} + ]) + end + + defp maybe_send_sts_header(conn, _), do: conn +end diff --git a/lib/web/router.ex b/lib/web/router.ex index 75aaa638d..96fafe635 100644 --- a/lib/web/router.ex +++ b/lib/web/router.ex @@ -4,12 +4,6 @@ defmodule Mobilizon.Web.Router do """ use Mobilizon.Web, :router - @csp if Application.fetch_env!(:mobilizon, :env) != :dev, - do: "default-src 'self';", - else: - "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'" - @headers %{"content-security-policy" => @csp} - pipeline :graphql do # plug(:accepts, ["json"]) plug(Mobilizon.Web.Auth.Pipeline) @@ -36,7 +30,7 @@ defmodule Mobilizon.Web.Router do pipeline :activity_pub_and_html do plug(:accepts, ["html", "activity-json"]) - plug(:put_secure_browser_headers, @headers) + plug(:put_secure_browser_headers) plug(Cldr.Plug.AcceptLanguage, cldr_backend: Mobilizon.Cldr @@ -44,7 +38,7 @@ defmodule Mobilizon.Web.Router do end pipeline :atom_and_ical do - plug(:put_secure_browser_headers, @headers) + plug(:put_secure_browser_headers) plug(:accepts, ["atom", "ics", "html"]) end @@ -56,7 +50,7 @@ defmodule Mobilizon.Web.Router do ) plug(:accepts, ["html"]) - plug(:put_secure_browser_headers, @headers) + plug(:put_secure_browser_headers) end pipeline :remote_media do