Security hardening: remaining audit fixes

- 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
This commit is contained in:
Graham McIntire 2026-02-15 12:54:30 -06:00
parent f9df9d5d97
commit 9ecd01dc8f
5 changed files with 29 additions and 74 deletions

View file

@ -1 +1 @@
/nix/store/zx636v118rzzqzwafgrw2aybh7l0szrl-pre-commit-config.json
/nix/store/4xlkmdd947h5qlhj2rmbyavq08grkz27-pre-commit-config.json

View file

@ -8,6 +8,7 @@ defmodule ToweropsWeb.Endpoint do
store: :cookie,
key: "_towerops_key",
signing_salt: "hrDZxLhd",
encryption_salt: "vK3p8mNx",
same_site: "Lax"
]

View file

@ -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'"
],
"; "
)

View file

@ -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)

View file

@ -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