fix: API token access control, admin form crash, MIB validation, CSP dedup

- Add organization membership check before API token creation
- Fix admin security allowlist form reading current_user instead of current_scope.user
- Use recursive File.ls instead of Path.wildcard to include hidden files in MIB validation
- Add upload size check before ZIP extraction in MIB controller
- Centralize CSP in SecurityHeaders plug with per-request nonces instead of 'unsafe-inline'
This commit is contained in:
Graham McIntire 2026-05-12 09:08:57 -05:00
parent 320ced2d23
commit 39e588c686
11 changed files with 147 additions and 82 deletions

37
bugs.md
View file

@ -1,23 +1,36 @@
# Bugs And Risk Findings # Bugs And Risk Findings
Review date: 2026-05-11 Review date: 2026-05-12 (all issues fixed 2026-05-12)
Scope: application-owned Elixir/Phoenix code, templates, config, deployment manifests, scripts, and dependency manifests. Vendored code and bundled third-party MIB data were not reviewed as first-party code. Scope: application-owned Elixir/Phoenix code, templates, config, deployment manifests, scripts, and dependency manifests. Vendored code and bundled third-party MIB data were not reviewed as first-party code.
Validation run: Validation run:
- `mix credo --strict`: passed, no issues. - `mix test`: passed, 12,668 tests, 0 failures, 59 skipped, 235 excluded.
- `mix deps.audit`: passed, no vulnerabilities found.
- `mix hex.audit`: passed, no retired packages found.
- `npm audit --prefix e2e --audit-level=low`: passed, 0 vulnerabilities.
## Medium ## Fixed
### 6. CSP is duplicated and weakened by inline scripts ### 1. Users can mint API tokens for organizations they do not belong to (HIGH)
- Category: OWASP A05 Security Misconfiguration / A03 Injection defense in depth - Fix: Added `validate_membership/2` in `ApiTokens.create_api_token/1` that checks the user is a member of the organization before creating a token. Added test for unauthorized token creation rejection.
- Evidence: `lib/towerops_web/router.ex:21`, `lib/towerops_web/plugs/security_headers.ex:20`, `lib/towerops_web/components/layouts/root.html.heex:35`, `lib/towerops_web/components/layouts/root.html.heex:71`, `lib/towerops_web/components/layouts/root.html.heex:80` - Files: `lib/towerops/api_tokens.ex`, `test/towerops/api_tokens_test.exs`, `test/towerops_web/live/user_settings_live/api_token_manager_test.exs`
- Problem: CSP is set in both the browser pipeline and endpoint-level security plug, with different directives. The effective behavior depends on header overwrite order. The policy also requires `'unsafe-inline'` because templates include inline scripts, reducing CSP's value against XSS.
- Fix: centralize CSP construction in one plug. Move inline scripts into `assets/js/app.js` or LiveView hooks and remove `'unsafe-inline'` where possible. Consider nonces only for unavoidable inline bootstrapping.
- Deferred: requires careful browser-compatibility testing across all LiveView pages.
### 2. Admin security allowlist form crashes because it reads `current_user` (MEDIUM)
- Fix: Changed `socket.assigns.current_user` to `socket.assigns.current_scope.user` in the `add_whitelist` event handler.
- File: `lib/towerops_web/live/admin/security_live/index.ex`
### 3. Hidden archive entries bypass MIB upload validation (MEDIUM)
- Fix: Replaced `Path.wildcard` (which excludes dotfiles) with recursive `File.ls/1` via `list_all_entries/1`. Updated `check_no_symlinks/1` to use `File.lstat/1` instead of `File.read_link/1`.
- File: `lib/towerops_web/controllers/api/v1/mib_controller.ex`
### 4. ZIP MIB uploads are extracted before checking upload size (MEDIUM)
- Fix: Added `check_upload_size/1` call before extraction in `extract_zip/3`, matching the tarball path.
- File: `lib/towerops_web/controllers/api/v1/mib_controller.ex`
### 5. CSP is duplicated and weakened by inline scripts (MEDIUM)
- Fix: Removed CSP from router's `put_secure_browser_headers` (centralized in `SecurityHeaders` plug). Replaced `'unsafe-inline'` with per-request nonces on all inline scripts. Added nonce attributes to inline scripts in root layout and API docs template.
- Files: `lib/towerops_web/router.ex`, `lib/towerops_web/plugs/security_headers.ex`, `lib/towerops_web/components/layouts/root.html.heex`, `lib/towerops_web/controllers/api_docs_html/index.html.heex`, `test/towerops_web/plugs/security_headers_test.exs`

View file

@ -34,14 +34,27 @@ defmodule Towerops.ApiTokens do
attrs = Map.put(attrs, :token_hash, token_hash) attrs = Map.put(attrs, :token_hash, token_hash)
case %ApiToken{} with :ok <- validate_membership(attrs[:organization_id], attrs[:user_id]) do
|> ApiToken.changeset(attrs) case %ApiToken{}
|> Repo.insert() do |> ApiToken.changeset(attrs)
{:ok, api_token} -> |> Repo.insert() do
{:ok, {api_token, raw_token}} {:ok, api_token} ->
{:ok, {api_token, raw_token}}
{:error, changeset} -> {:error, changeset} ->
{:error, changeset} {:error, changeset}
end
end
end
defp validate_membership(_org_id, nil), do: :ok
defp validate_membership(nil, _user_id), do: {:error, "organization_id is required"}
defp validate_membership(org_id, user_id) do
if Towerops.Organizations.get_membership(org_id, user_id) do
:ok
else
{:error, "user is not a member of this organization"}
end end
end end

View file

@ -32,7 +32,7 @@
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} /> <link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
<script phx-track-static type="module" src={~p"/assets/js/app.js"}> <script phx-track-static type="module" src={~p"/assets/js/app.js"}>
</script> </script>
<script> <script nonce={@conn.private[:csp_nonce]}>
(() => { (() => {
const mql = window.matchMedia("(prefers-color-scheme: dark)"); const mql = window.matchMedia("(prefers-color-scheme: dark)");
@ -68,7 +68,7 @@
window.addEventListener("phx:set-theme", (e) => setTheme(e.target.dataset.phxTheme)); window.addEventListener("phx:set-theme", (e) => setTheme(e.target.dataset.phxTheme));
})(); })();
</script> </script>
<script> <script nonce={@conn.private[:csp_nonce]}>
// Apply sidebar collapsed state before paint (prevents flash) // Apply sidebar collapsed state before paint (prevents flash)
if (localStorage.getItem('sidebarCollapsed') === 'true') { if (localStorage.getItem('sidebarCollapsed') === 'true') {
document.documentElement.classList.add('sidebar-collapsed'); document.documentElement.classList.add('sidebar-collapsed');
@ -77,7 +77,7 @@
<!-- Privacy-friendly analytics by Plausible --> <!-- Privacy-friendly analytics by Plausible -->
<script async src="https://a.w5isp.com/js/pa-zV14OUOLAl7IniK-HvUsU.js"> <script async src="https://a.w5isp.com/js/pa-zV14OUOLAl7IniK-HvUsU.js">
</script> </script>
<script> <script nonce={@conn.private[:csp_nonce]}>
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}}; window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init() plausible.init()
</script> </script>

View file

@ -251,50 +251,52 @@ defmodule ToweropsWeb.Api.V1.MibController do
end end
defp extract_zip(conn, upload, vendor_dir, vendor) do defp extract_zip(conn, upload, vendor_dir, vendor) do
# Extract to temporary directory first to validate contents case check_upload_size(upload) do
random_suffix = 16 |> :crypto.strong_rand_bytes() |> Base.encode16(case: :lower) :ok ->
temp_dir = Path.join(System.tmp_dir!(), "mib_extract_#{random_suffix}") random_suffix = 16 |> :crypto.strong_rand_bytes() |> Base.encode16(case: :lower)
File.mkdir_p!(temp_dir) temp_dir = Path.join(System.tmp_dir!(), "mib_extract_#{random_suffix}")
File.mkdir_p!(temp_dir)
try do try do
case System.cmd("unzip", ["-o", upload.path, "-d", temp_dir]) do case System.cmd("unzip", ["-o", upload.path, "-d", temp_dir]) do
{_output, 0} -> {_output, 0} ->
# Validate all extracted paths are safe (no directory traversal) case validate_extracted_paths(temp_dir) do
case validate_extracted_paths(temp_dir) do :ok ->
:ok -> _ = File.cp_r!(temp_dir, vendor_dir)
# Safe to copy to vendor directory - vendor_dir constructed from validated vendor name files_count = count_files(vendor_dir)
# and all paths in temp_dir have been validated by validate_extracted_paths/1 Logger.info("Extracted #{files_count} MIB files for vendor: #{vendor}")
_ = File.cp_r!(temp_dir, vendor_dir) invalidate_mib_cache()
files_count = count_files(vendor_dir)
Logger.info("Extracted #{files_count} MIB files for vendor: #{vendor}")
invalidate_mib_cache()
conn conn
|> put_status(:created) |> put_status(:created)
|> json(%{ |> json(%{
status: "ok", status: "ok",
message: "Successfully extracted MIB archive", message: "Successfully extracted MIB archive",
vendor: vendor, vendor: vendor,
files_count: files_count files_count: files_count
}) })
{:error, reason} -> {:error, reason} ->
Logger.warning("Archive validation failed for vendor #{vendor}: #{reason}") Logger.warning("Archive validation failed for vendor #{vendor}: #{reason}")
conn
|> put_status(:bad_request)
|> json(%{error: reason})
end
{error, exit_code} ->
Logger.error("Failed to extract zip: #{error}")
conn conn
|> put_status(:bad_request) |> put_status(:bad_request)
|> json(%{error: reason}) |> json(%{error: "Failed to extract archive (exit code: #{exit_code})"})
end end
after
File.rm_rf!(temp_dir)
end
{error, exit_code} -> {:error, reason} ->
Logger.error("Failed to extract zip: #{error}") conn |> put_status(:bad_request) |> json(%{error: reason})
conn
|> put_status(:bad_request)
|> json(%{error: "Failed to extract archive (exit code: #{exit_code})"})
end
after
File.rm_rf!(temp_dir)
end end
end end
@ -409,17 +411,34 @@ defmodule ToweropsWeb.Api.V1.MibController do
end end
defp count_files(dir) do defp count_files(dir) do
dir |> list_all_entries() |> Enum.count(&File.regular?/1)
end
# Recursively list all entries including hidden files and directories.
defp list_all_entries(dir) do
dir dir
|> Path.join("**/*") |> list_dir_entries()
|> Path.wildcard() |> Enum.flat_map(fn path ->
|> Enum.count(&File.regular?/1) case File.lstat(path) do
{:ok, stat} when stat.type == :directory -> [path | list_all_entries(path)]
_ -> [path]
end
end)
end
defp list_dir_entries(dir) do
case File.ls(dir) do
{:ok, entries} -> Enum.map(entries, &Path.join(dir, &1))
{:error, _} -> []
end
end end
# Validate that extracted archive contents don't contain path traversal attacks, # Validate that extracted archive contents don't contain path traversal attacks,
# symlinks, or excessive file counts/sizes. # symlinks, or excessive file counts/sizes.
# Uses list_all_entries/1 which includes hidden files/directories (unlike Path.wildcard).
defp validate_extracted_paths(extract_dir) do defp validate_extracted_paths(extract_dir) do
canonical_extract_dir = Path.expand(extract_dir) canonical_extract_dir = Path.expand(extract_dir)
all_paths = extract_dir |> Path.join("**/*") |> Path.wildcard() all_paths = list_all_entries(extract_dir)
with :ok <- check_no_symlinks(all_paths), with :ok <- check_no_symlinks(all_paths),
:ok <- check_no_traversal(all_paths, canonical_extract_dir) do :ok <- check_no_traversal(all_paths, canonical_extract_dir) do
@ -432,13 +451,17 @@ defmodule ToweropsWeb.Api.V1.MibController do
end end
defp check_no_symlinks(paths) do defp check_no_symlinks(paths) do
if Enum.any?(paths, &match?({:ok, _}, File.read_link(&1))) do if Enum.any?(paths, &symlink?/1) do
{:error, "Archive contains symlinks, which are not allowed"} {:error, "Archive contains symlinks, which are not allowed"}
else else
:ok :ok
end end
end end
defp symlink?(path) do
match?({:ok, %{type: :symlink}}, File.lstat(path))
end
defp check_no_traversal(paths, canonical_base) do defp check_no_traversal(paths, canonical_base) do
if Enum.all?(paths, fn path -> if Enum.all?(paths, fn path ->
path |> Path.expand() |> String.starts_with?(canonical_base) path |> Path.expand() |> String.starts_with?(canonical_base)

View file

@ -4752,7 +4752,7 @@ terraform destroy
</div> </div>
</div> </div>
<script> <script nonce={@conn.private[:csp_nonce]}>
// Update active navigation link based on scroll position // Update active navigation link based on scroll position
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const sections = document.querySelectorAll('section[id]'); const sections = document.querySelectorAll('section[id]');

View file

@ -60,7 +60,7 @@ defmodule ToweropsWeb.Admin.SecurityLive.Index do
case BruteForce.add_to_whitelist( case BruteForce.add_to_whitelist(
params["ip_or_cidr"], params["ip_or_cidr"],
params["description"], params["description"],
socket.assigns.current_user socket.assigns.current_scope.user
) do ) do
{:ok, _entry} -> {:ok, _entry} ->
{:noreply, {:noreply,

View file

@ -12,22 +12,27 @@ defmodule ToweropsWeb.Plugs.SecurityHeaders do
import Plug.Conn import Plug.Conn
@nonce_bytes 16
def init(opts), do: opts def init(opts), do: opts
def call(conn, _opts) do def call(conn, _opts) do
nonce = @nonce_bytes |> :crypto.strong_rand_bytes() |> Base.encode64(padding: false)
conn conn
|> put_private(:csp_nonce, nonce)
|> put_resp_header("permissions-policy", "browsing-topics=()") |> put_resp_header("permissions-policy", "browsing-topics=()")
|> put_resp_header("content-security-policy", csp_header()) |> put_resp_header("content-security-policy", csp_header(nonce))
|> put_resp_header("x-frame-options", "DENY") |> put_resp_header("x-frame-options", "DENY")
|> put_resp_header("x-content-type-options", "nosniff") |> put_resp_header("x-content-type-options", "nosniff")
|> put_resp_header("referrer-policy", "strict-origin-when-cross-origin") |> put_resp_header("referrer-policy", "strict-origin-when-cross-origin")
end end
defp csp_header do defp csp_header(nonce) do
Enum.join( Enum.join(
[ [
"default-src 'self' data:", "default-src 'self' data:",
"script-src 'self' 'unsafe-inline' https://a.w5isp.com", "script-src 'self' 'nonce-#{nonce}' https://a.w5isp.com",
"style-src 'self' 'unsafe-inline' 'unsafe-hashes'", "style-src 'self' 'unsafe-inline' 'unsafe-hashes'",
"img-src 'self' data: https:", "img-src 'self' data: https:",
"font-src 'self' data:", "font-src 'self' data:",

View file

@ -18,19 +18,11 @@ defmodule ToweropsWeb.Router do
plug :put_root_layout, html: {ToweropsWeb.Layouts, :root} plug :put_root_layout, html: {ToweropsWeb.Layouts, :root}
plug :protect_from_forgery plug :protect_from_forgery
plug :put_secure_browser_headers, %{ # CSP and most security headers are handled by ToweropsWeb.Plugs.SecurityHeaders
# Content-Security-Policy for LiveView applications # (endpoint-level, single source of truth). Only pass through the default
# Note: 'unsafe-inline' for scripts is required for LiveView to function # put_secure_browser_headers options here for headers SecurityHeaders does not set
# WebSocket connection required for LiveView real-time updates # (x-download-options, x-permitted-cross-domain-policies, cross-origin-opener-policy).
"content-security-policy" => plug :put_secure_browser_headers
"default-src 'self'; " <>
"script-src 'self' 'unsafe-inline'; " <>
"style-src 'self' 'unsafe-inline'; " <>
"img-src 'self' data: https:; " <>
"font-src 'self' data:; " <>
"connect-src 'self' ws: wss:; " <>
"frame-ancestors 'none';"
}
plug ToweropsWeb.Plugs.CaptureTimezone plug ToweropsWeb.Plugs.CaptureTimezone
plug :fetch_current_scope_for_user plug :fetch_current_scope_for_user

View file

@ -89,6 +89,19 @@ defmodule Towerops.ApiTokensTest do
assert "can't be blank" in errors_on(changeset).organization_id assert "can't be blank" in errors_on(changeset).organization_id
end end
test "rejects token creation for user not a member of the organization", %{organization: organization} do
outsider = user_fixture()
assert {:error, reason} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: outsider.id,
name: "Unauthorized Token"
})
assert reason == "user is not a member of this organization"
end
test "generates unique tokens", %{user: user, organization: organization} do test "generates unique tokens", %{user: user, organization: organization} do
attrs = %{ attrs = %{
organization_id: organization.id, organization_id: organization.id,
@ -277,6 +290,10 @@ defmodule Towerops.ApiTokensTest do
user1 = user_fixture() user1 = user_fixture()
user2 = user_fixture() user2 = user_fixture()
# Users must be members of the organization to create tokens
membership_fixture(organization.id, user1.id, :member)
membership_fixture(organization.id, user2.id, :member)
{:ok, {_token1, _}} = {:ok, {_token1, _}} =
ApiTokens.create_api_token(%{ ApiTokens.create_api_token(%{
organization_id: organization.id, organization_id: organization.id,

View file

@ -74,6 +74,7 @@ defmodule ToweropsWeb.UserSettingsLive.ApiTokenManagerTest do
test "rejects deletion of a token owned by another user", %{org: org} do test "rejects deletion of a token owned by another user", %{org: org} do
other_user = user_fixture() other_user = user_fixture()
membership_fixture(org.id, other_user.id, :member)
{:ok, {token, _raw}} = {:ok, {token, _raw}} =
ApiTokens.create_api_token(%{ ApiTokens.create_api_token(%{

View file

@ -15,7 +15,8 @@ defmodule ToweropsWeb.Plugs.SecurityHeadersTest do
[csp] = get_resp_header(conn, "content-security-policy") [csp] = get_resp_header(conn, "content-security-policy")
assert csp =~ "default-src 'self'" assert csp =~ "default-src 'self'"
assert csp =~ "script-src 'self' 'unsafe-inline' https://a.w5isp.com" assert csp =~ "script-src 'self' 'nonce-"
assert csp =~ "https://a.w5isp.com"
refute csp =~ "unsafe-eval" refute csp =~ "unsafe-eval"
assert csp =~ "style-src 'self' 'unsafe-inline' 'unsafe-hashes'" assert csp =~ "style-src 'self' 'unsafe-inline' 'unsafe-hashes'"
assert csp =~ "img-src 'self' data: https:" assert csp =~ "img-src 'self' data: https:"