From e0552ac97dcb4638efc0ed6155972221405a9ba0 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 29 Jan 2026 10:33:24 -0600 Subject: [PATCH] add security headers --- lib/towerops_web/endpoint.ex | 1 + lib/towerops_web/plugs/security_headers.ex | 53 ++++++++++++++ .../plugs/security_headers_test.exs | 72 +++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 lib/towerops_web/plugs/security_headers.ex create mode 100644 test/towerops_web/plugs/security_headers_test.exs diff --git a/lib/towerops_web/endpoint.ex b/lib/towerops_web/endpoint.ex index 5d9733d0..3ac163cc 100644 --- a/lib/towerops_web/endpoint.ex +++ b/lib/towerops_web/endpoint.ex @@ -46,6 +46,7 @@ defmodule ToweropsWeb.Endpoint do plug Plug.RequestId plug ToweropsWeb.Plugs.RemoteIpLogger + plug ToweropsWeb.Plugs.SecurityHeaders plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint], diff --git a/lib/towerops_web/plugs/security_headers.ex b/lib/towerops_web/plugs/security_headers.ex new file mode 100644 index 00000000..d8aa4c9b --- /dev/null +++ b/lib/towerops_web/plugs/security_headers.ex @@ -0,0 +1,53 @@ +defmodule ToweropsWeb.Plugs.SecurityHeaders do + @moduledoc """ + Adds security headers to all responses in production. + + Headers added: + - Content-Security-Policy: Restricts resource loading to prevent XSS + - X-Frame-Options: Prevents clickjacking attacks + - X-Content-Type-Options: Prevents MIME type sniffing + - Referrer-Policy: Controls referrer information leakage + + These headers are only applied in production to avoid interfering with + development tools like LiveReload. + """ + + import Plug.Conn + + def init(opts), do: opts + + def call(conn, _opts) do + if Application.get_env(:towerops, :env) == :prod do + conn + |> put_resp_header("content-security-policy", csp_header()) + |> put_resp_header("x-frame-options", "SAMEORIGIN") + |> put_resp_header("x-content-type-options", "nosniff") + |> put_resp_header("referrer-policy", "strict-origin-when-cross-origin") + else + conn + end + end + + # Content Security Policy + # - default-src 'self': Only load resources from same origin by default + # - script-src: Allow inline scripts (needed for LiveView), eval, and self + # - style-src: Allow inline styles (needed for Tailwind), unsafe-hashes, and self + # - img-src: Allow images from self, data URIs, and https + # - font-src: Allow fonts from self and data URIs + # - connect-src: Allow WebSocket connections for LiveView + # - frame-ancestors: Same as X-Frame-Options (SAMEORIGIN) + defp csp_header do + Enum.join( + [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval'", + "style-src 'self' 'unsafe-inline' 'unsafe-hashes'", + "img-src 'self' data: https:", + "font-src 'self' data:", + "connect-src 'self' ws: wss:", + "frame-ancestors 'self'" + ], + "; " + ) + end +end diff --git a/test/towerops_web/plugs/security_headers_test.exs b/test/towerops_web/plugs/security_headers_test.exs new file mode 100644 index 00000000..39dbb648 --- /dev/null +++ b/test/towerops_web/plugs/security_headers_test.exs @@ -0,0 +1,72 @@ +defmodule ToweropsWeb.Plugs.SecurityHeadersTest do + use ToweropsWeb.ConnCase, async: true + + alias ToweropsWeb.Plugs.SecurityHeaders + + describe "call/2" do + test "adds security headers in production environment" do + # Save original env + original_env = Application.get_env(:towerops, :env) + + try do + # Set environment to production + Application.put_env(:towerops, :env, :prod) + + conn = SecurityHeaders.call(build_conn(), []) + + # Verify all security headers are present + assert ["default-src 'self'" <> _] = get_resp_header(conn, "content-security-policy") + assert ["SAMEORIGIN"] = get_resp_header(conn, "x-frame-options") + assert ["nosniff"] = get_resp_header(conn, "x-content-type-options") + + assert ["strict-origin-when-cross-origin"] = + get_resp_header(conn, "referrer-policy") + + # Verify CSP contains required directives + [csp] = get_resp_header(conn, "content-security-policy") + assert csp =~ "default-src 'self'" + assert csp =~ "script-src 'self' 'unsafe-inline' 'unsafe-eval'" + assert csp =~ "style-src 'self' 'unsafe-inline' 'unsafe-hashes'" + assert csp =~ "img-src 'self' data: https:" + assert csp =~ "font-src 'self' data:" + assert csp =~ "connect-src 'self' ws: wss:" + assert csp =~ "frame-ancestors 'self'" + after + # Restore original env + Application.put_env(:towerops, :env, original_env) + end + end + + test "does not add security headers in non-production environment" do + # Default test environment is :test, not :prod + conn = SecurityHeaders.call(build_conn(), []) + + # Verify no security headers are present + assert [] = get_resp_header(conn, "content-security-policy") + assert [] = get_resp_header(conn, "x-frame-options") + assert [] = get_resp_header(conn, "x-content-type-options") + assert [] = get_resp_header(conn, "referrer-policy") + end + + test "does not add security headers in development environment" do + # Save original env + original_env = Application.get_env(:towerops, :env) + + try do + # Set environment to development + Application.put_env(:towerops, :env, :dev) + + conn = SecurityHeaders.call(build_conn(), []) + + # Verify no security headers are present + assert [] = get_resp_header(conn, "content-security-policy") + assert [] = get_resp_header(conn, "x-frame-options") + assert [] = get_resp_header(conn, "x-content-type-options") + assert [] = get_resp_header(conn, "referrer-policy") + after + # Restore original env + Application.put_env(:towerops, :env, original_env) + end + end + end +end