`
+ const button = popupEl.appendChild(document.createElement('button'))
+ button.setAttribute('data-site-id', site.id)
+ button.setAttribute('data-action', 'view-site')
+ button.className = 'text-xs bg-blue-600 text-white px-2 py-1 rounded hover:bg-blue-700'
+ button.textContent = 'View Site →'
- marker.bindPopup(popupContent)
+ marker.bindPopup(popupEl)
marker.on('popupopen', () => {
const button = marker.getPopup()?.getElement()?.querySelector('[data-action="view-site"]') as HTMLElement
if (button) {
diff --git a/bugs.md b/bugs.md
index 79979a65..d86543fd 100644
--- a/bugs.md
+++ b/bugs.md
@@ -1,36 +1,1084 @@
-# Bugs And Risk Findings
+# Code Review Findings
-Review date: 2026-05-12 (all issues fixed 2026-05-12)
+> Full codebase audit conducted 2026-05-12. Covers OWASP Top 10, performance, reliability, and correctness issues across the entire TowerOps Web application.
-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:
+## CRITICAL
-- `mix test`: passed, 12,668 tests, 0 failures, 59 skipped, 235 excluded.
+### C1. ✓ FIXED — Stored XSS via Sites Map Popup (innerHTML)
-## Fixed
+**Files:**
+- `assets/js/hooks/sites_map.ts:75-101` — popup construction with template literals
+- `map_live/index.html.heex:51` — `data-sites={Jason.encode!(@sites)}`
-### 1. Users can mint API tokens for organizations they do not belong to (HIGH)
+**Severity:** CRITICAL — Stored XSS affecting all users viewing the map
-- 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.
-- Files: `lib/towerops/api_tokens.ex`, `test/towerops/api_tokens_test.exs`, `test/towerops_web/live/user_settings_live/api_token_manager_test.exs`
+**Description:** Site name, description, and address from the database are interpolated into template literals and injected via `marker.bindPopup()` (which uses `innerHTML`). HEEx escapes the JSON attribute in the template, but `JSON.parse` in the hook restores the original unescaped strings, then template literals inject them directly into HTML without sanitization. Any user who can create/edit a site can execute arbitrary JS for every map viewer.
-### 2. Admin security allowlist form crashes because it reads `current_user` (MEDIUM)
+```typescript
+// vulnerable code
+let popupContent = `
+
${site.name}
`
+```
-- 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`
+**Fix:** Use `textContent`-based popup rendering or sanitize via DOMPurify. Create a DOM element and set `textContent` for user data:
-### 3. Hidden archive entries bypass MIB upload validation (MEDIUM)
+```typescript
+const popupEl = document.createElement('div')
+popupEl.className = 'p-2'
+const h3 = popupEl.appendChild(document.createElement('h3'))
+h3.className = 'font-semibold text-lg mb-1'
+h3.textContent = site.name
+marker.bindPopup(popupEl)
+```
-- 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)
+### C2. ✓ FIXED — IDOR in Policy Consent Handler — Client-Controlled User ID
-- 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`
+**File:** `lib/towerops_web/user_auth.ex:693`
-### 5. CSP is duplicated and weakened by inline scripts (MEDIUM)
+**Severity:** CRITICAL — Any authenticated user can grant/revoke consent for any other user
-- 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`
+**Description:** The `accept_updated_policies` on_mount handler reads `user-id` directly from LiveView event params with zero validation:
+
+```elixir
+"accept_updated_policies", %{"user-id" => user_id}, socket ->
+ ...
+ Accounts.grant_consent(user_id, policy_type)
+```
+
+Attached to ALL authenticated `live_session` scopes. An attacker can send arbitrary `user_id` values.
+
+**Fix:** Derive user ID from `socket.assigns.current_scope.user.id` instead of client params.
+
+---
+
+### C3. ✓ FIXED — Broken `halt()` in GDPR Data Export
+
+**File:** `lib/towerops_web/controllers/api/account_data_controller.ex:20-26`
+
+**Severity:** CRITICAL — Unconfirmed users can download full GDPR data export
+
+**Description:** The halting check for unconfirmed users discards the result:
+
+```elixir
+_ = if !user.confirmed_at do
+ conn |> put_status(:forbidden) |> json(...) |> halt()
+end
+```
+
+`halt()` returns a modified conn, but it's bound to `_`. The original `conn` is used, so the function proceeds to gather ALL user data.
+
+**Fix:** Restructure to early-return pattern, not orphan `halt()`.
+
+---
+
+### C4. ✓ FIXED — Missing Authorization on Member Role Changes
+
+**File:** `lib/towerops_web/controllers/api/v1/members_controller.ex:20-51`
+
+**Severity:** CRITICAL — Any API token holder can change roles or remove any member
+
+**Description:** `update` and `delete` actions call into `Organizations` context functions that do NOT check whether the requesting user has permission. The context functions look up membership by organization_id + user_id and perform the action — no role/authority check for the caller.
+
+**Fix:** Add authorization at the controller level — verify `current_user` is owner/admin before allowing role changes or removals.
+
+---
+
+### C5. ✓ FIXED — Agent Release Webhook — Optional Signature Means No Auth
+
+**File:** `lib/towerops_web/controllers/api/v1/agent_release_webhook_controller.ex:37-42`
+
+**Severity:** CRITICAL — Anyone who discovers the endpoint can trigger mass agent updates
+
+**Description:** If signature header is absent, the webhook accepts the request unconditionally:
+
+```elixir
+defp verify_optional_signature(conn) do
+ sig_headers = Plug.Conn.get_req_header(conn, "x-agent-webhook-signature")
+ case sig_headers do
+ [] -> :ok # passes without any auth
+ ...
+```
+
+**Fix:** Remove the "optional" behavior. Require HMAC signature on every request.
+
+---
+
+### C6. ✓ FIXED — Repo.all_by/2 Does Not Exist — Runtime Crash
+
+**File:** `lib/towerops/accounts.ex:386-387`
+
+**Severity:** CRITICAL — Runtime FunctionClauseError when updating passwords/changing email
+
+**Description:** `Repo.all_by/2` is NOT a standard Ecto function. It will raise `FunctionClauseError` at runtime when `update_user_and_delete_all_tokens/1` is called (password change, email change). The DB transaction crashes with a generic error.
+
+```elixir
+tokens_to_expire = Repo.all_by(UserToken, user_id: user.id)
+```
+
+**Fix:** Replace with `Repo.all(from t in UserToken, where: t.user_id == ^user.id)`.
+
+---
+
+### C7. ✓ FIXED — Brute Force Protection Runs Before Authentication
+
+**File:** `lib/towerops_web/endpoint.ex:68` → plug runs here
+**File:** `lib/towerops_web/plugs/brute_force_protection.ex:47` — dead auth exemption check
+
+**Severity:** CRITICAL — Authenticated user exemption path is unreachable; every authenticated 404 gets tracked
+
+**Description:** `BruteForceProtection` runs in the endpoint pipeline LONG BEFORE auth happens. The check `conn.assigns[:current_user] != nil` will ALWAYS be `nil`. The authenticated-user exemption path is completely dead code.
+
+**Fix:** Move auth exemption check to `register_before_send` callback, or restructure pipeline ordering.
+
+---
+
+### C8. ✓ FIXED — Status Page `custom_css` Injection
+
+**File:** `lib/towerops_web/live/status_page_live.html.heex:26-30`
+
+**Severity:** CRITICAL — Any org admin with status page access can inject arbitrary HTML/JS
+
+**Description:** `custom_css` is inserted raw into a `` injection:
+
+```heex
+<%= if @config.custom_css do %>
+
+<% end %>
+```
+
+**Fix:** Sanitize to remove ``, ``, `
+ add_error(changeset, :custom_css, "HTML tags are not allowed in custom CSS")
+
+ String.contains?(downcased, "