From 9ecd01dc8fdfaac87a301aacc6a5ac1a6b22387b Mon Sep 17 00:00:00 2001 From: Graham McIntie Date: Sun, 15 Feb 2026 12:54:30 -0600 Subject: [PATCH] Security hardening: remaining audit fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Encrypt session cookies (add encryption_salt to endpoint) - X-Frame-Options: SAMEORIGIN → DENY (match CSP frame-ancestors 'none') - Remove unsafe-eval from CSP script-src - Apply security headers in all environments (not just prod) - Add GraphQL query complexity limit (max 500) via Absinthe.Plug - GraphQL introspection already blocked in prod via plug Closes audit items #6, #7, #12, #13, #14, #16 --- .pre-commit-config.yaml | 2 +- lib/towerops_web/endpoint.ex | 1 + lib/towerops_web/plugs/security_headers.ex | 20 ++--- lib/towerops_web/router.ex | 5 +- .../plugs/security_headers_test.exs | 75 ++++--------------- 5 files changed, 29 insertions(+), 74 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 173daa95..3498c83d 120000 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1 +1 @@ -/nix/store/zx636v118rzzqzwafgrw2aybh7l0szrl-pre-commit-config.json \ No newline at end of file +/nix/store/4xlkmdd947h5qlhj2rmbyavq08grkz27-pre-commit-config.json \ No newline at end of file diff --git a/lib/towerops_web/endpoint.ex b/lib/towerops_web/endpoint.ex index 860880a5..8f4111a3 100644 --- a/lib/towerops_web/endpoint.ex +++ b/lib/towerops_web/endpoint.ex @@ -8,6 +8,7 @@ defmodule ToweropsWeb.Endpoint do store: :cookie, key: "_towerops_key", signing_salt: "hrDZxLhd", + encryption_salt: "vK3p8mNx", same_site: "Lax" ] diff --git a/lib/towerops_web/plugs/security_headers.ex b/lib/towerops_web/plugs/security_headers.ex index ccbfc146..84dc758b 100644 --- a/lib/towerops_web/plugs/security_headers.ex +++ b/lib/towerops_web/plugs/security_headers.ex @@ -15,28 +15,24 @@ defmodule ToweropsWeb.Plugs.SecurityHeaders do def init(opts), do: opts def call(conn, _opts) do - if Application.get_env(:towerops, :env) == :prod do - conn - |> put_resp_header("permissions-policy", "browsing-topics=()") - |> 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 + conn + |> put_resp_header("permissions-policy", "browsing-topics=()") + |> put_resp_header("content-security-policy", csp_header()) + |> put_resp_header("x-frame-options", "DENY") + |> put_resp_header("x-content-type-options", "nosniff") + |> put_resp_header("referrer-policy", "strict-origin-when-cross-origin") end defp csp_header do Enum.join( [ "default-src 'self'", - "script-src 'self' 'unsafe-inline' 'unsafe-eval'", + "script-src 'self' 'unsafe-inline'", "style-src 'self' 'unsafe-inline' 'unsafe-hashes'", "img-src 'self' data: https:", "font-src 'self' data:", "connect-src 'self' ws: wss:", - "frame-ancestors 'self'" + "frame-ancestors 'none'" ], "; " ) diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index a657461d..554df95a 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -127,7 +127,10 @@ defmodule ToweropsWeb.Router do scope "/api/graphql" do pipe_through [:api_v1, :rate_limit_api, :graphql_context] - forward "/", Absinthe.Plug, schema: ToweropsWeb.GraphQL.Schema + forward "/", Absinthe.Plug, + schema: ToweropsWeb.GraphQL.Schema, + analyze_complexity: true, + max_complexity: 500 end # API v1 routes (requires API token authentication) diff --git a/test/towerops_web/plugs/security_headers_test.exs b/test/towerops_web/plugs/security_headers_test.exs index 39dbb648..27843a74 100644 --- a/test/towerops_web/plugs/security_headers_test.exs +++ b/test/towerops_web/plugs/security_headers_test.exs @@ -4,69 +4,24 @@ defmodule ToweropsWeb.Plugs.SecurityHeadersTest do 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 + test "adds security headers to all responses" do 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 + assert ["default-src 'self'" <> _] = get_resp_header(conn, "content-security-policy") + assert ["DENY"] = 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") + assert ["browsing-topics=()"] = get_resp_header(conn, "permissions-policy") - 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 + [csp] = get_resp_header(conn, "content-security-policy") + assert csp =~ "default-src 'self'" + assert csp =~ "script-src 'self' 'unsafe-inline'" + refute csp =~ "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 'none'" end end end