# Jabali Panel — Security Audit Report

> **Date:** 2026-07-08
> **Auditor:** AI-assisted security review (opencode / glm-5.2)
> **Scope:** Full codebase at `~/projects/jabali2/` — Go backend, Go
> agent, React SPA, install.sh, nginx configuration
> **Method:** Manual code review + targeted tooling (gosec) +
> architecture analysis + nginx deep dive
> **Classification:** Internal — for project maintainers

---

## 1. Executive Summary

Two audit passes were conducted:

| Pass | Scope | Issues filed | Fixed | Disproved |
|------|-------|--------------|-------|-----------|
| Pass 1 | General codebase security | 9 (Gitea #693–#701) | 6 | 3 |
| Pass 2 | Nginx layer deep dive | 8 (Plane work items) | Pending | — |

**Overall assessment:** The codebase is well-hardened. No remotely
exploitable critical vulnerabilities were found in the Go backend or
agent. The filesafe package (`openat2` + `RESOLVE_BENEATH`) provides
kernel-enforced path containment that is superior to user-mode string
checks. Auth layering (Kratos + API tokens + HMAC automation) is
correctly implemented with replay defense and scope checking.

The nginx layer has the highest concentration of findings, primarily
defense-in-depth gaps that require admin access to exploit but should
be narrowed.

---

## 2. Pass 1 — General Codebase Security

### 2.1 Methodology

1. **Static analysis:** `gosec` on `panel-api` and `panel-agent` (0
   findings — gosec did not resolve internal packages correctly)
2. **Secret scan:** regex search for hardcoded API keys, passwords,
   tokens in `.go`, `.ts`, `.sh` files (0 found)
3. **Manual review** of security-critical code paths:
   - Auth middleware: `auth_kratos.go`, `auth_user_api_token.go`,
     `automation_hmac.go`, `impersonation.go`, `cors.go`, `rbac.go`
   - Agent commands: `terminal_pty.go`, `files_*.go`,
     `user_create.go`, `user_password.go`, `user_delete.go`,
     `db_user_create.go`, `db_root_password.go`, `system_kill.go`,
     `ssh_authorized_keys_write.go`, `php_cli_wrapper.go`,
     `migration_imapsync.go`, `cron_apply.go`
   - File safety: `filesafe.go`, `openat2.go`
   - API handlers: `domains.go`, `backups.go`, `applications.go`,
     `ddns.go`, `webmail_sso.go`, `sso_phpmyadmin.go`
   - Install: `install.sh` (13K lines — sampled critical sections)
   - Crypto: token hashing, SSO token lifecycle, HMAC verification

### 2.2 Findings

#### Fixed (6)

| # | Severity | Finding | Fix SHA |
|---|----------|---------|---------|
| 696 | High | Rate limiter keyed on `gin c.ClientIP()` (XFF-spoofable) | `3fbc1aea` |
| 697 | High | Root cron `ExecStart` uses Go `%q` instead of systemd-safe `singleQuote()` | `b7fbad18` |
| 699 | High | `/admin/cron` route group lacks `RequireAdmin()` middleware | `b7fbad18` |
| 694 | Low | Raw JSON construction from username in `user_create`/`user_delete` | `8fac63c3` |
| 700 | Medium | Docker compose error messages include raw subprocess output | `8fac63c3` |
| 701 | Low | install.sh secret file creation race window | `8fac63c3` |

#### Disproved (3)

| # | Claim | Disproof |
|---|-------|----------|
| 695 | No CSRF protection | `EnforceSameOriginForCookieMutations()` is applied to the whole `/api/v1` group + cookie is `SameSite=Lax` |
| 698 | Agent should independently verify admin for `RunAsRoot` | The agent has no user session — the panel IS the auth boundary over the trusted Unix socket |
| 693 | Admin bootstrap password world-readable | `panel.env` is created `0640 root:jabali` via `install -m`; `umask 077` wraps the writes |

### 2.3 Positive findings (things done right)

- **filesafe/openat2:** Kernel-enforced `RESOLVE_BENEATH` on every
  file operation. Symlink-TOCTOU is impossible at the kernel level.
- **Archive extraction:** Zip-slip defense + decompression bomb limits
  (100K entries, 10 GiB total, 5 GiB per file) + symlink/device skip.
- **Cron validation:** `cronvalidate` package rejects unquoted shell
  metacharacters, uses `shlex.Split` for parsing, allowlists binaries
  (wp/php/phpX.Y only), validates paths against owned docroots, rejects
  inline-code PHP flags (`-r`, `-R`, `-B`, `-E`).
- **SQL escaping:** `EscapeMariaDBLiteral` and `EscapeMariaDBIdentifier`
  with NUL rejection and backslash escaping. GORM parameterized queries
  everywhere — no string-concat SQL found.
- **API token hashing:** SHA-256 of full token (prefix included),
  stored as hex. Indistinguishable error responses for invalid vs
  revoked tokens.
- **HMAC automation:** Constant-time comparison, 5-min skew window,
  Redis SETNX replay defense (fail-closed on Redis-down), per-token
  scope checking.
- **Impersonation:** Admin-only, grant must be active + owned by the
  real admin, target must be non-admin, impersonation-management
  endpoints exempt from override.
- **SO_PEERCRED:** Root terminal PTY broker verifies caller's primary
  GID via `SO_PEERCRED` — only panel-api (or root) can connect.
- **Suspended account enforcement:** Both Kratos cookie path and API
  token path check `panelUser.Suspended` — a suspend takes effect
  regardless of Kratos propagation.

---

## 3. Pass 2 — Nginx Layer Deep Dive

### 3.1 Methodology

1. **File mapping:** All 21 nginx-related `.go` files + 7 nginx config
   templates in `install/nginx/` + nginx sections of `install.sh`
2. **Template review:** Full read of `vhostTemplate` (264 lines),
   `vhostData` struct, `writeVhost` function, content-hash gate
3. **Directive validation review:** `allowedNginxDirectives` allowlist
   (80 entries), `validateNginxDirectives` (brace counting, comment
   stripping, NUL rejection), `validateNginxRules` (per-type field
   validation), `validateTenantNginxRules` (tenant-safe subset)
4. **Cache review:** `nginx_cache_purge.go` (targeted + full purge),
   `nginx_cache_warmup.go` (sitemap fetch), `nginx_cache_capacity.go`
   (conf rewrite), `jabali-fastcgi-cache.conf` (maps + keyzone)
5. **Rate limit review:** `nginx_ratelimits.go` (zone declarations,
   per-vhost directives, atomic apply + rollback)
6. **Tunables review:** `nginx_tunables.go` (nginx.conf patching,
   fragment generation, validation regexes)
7. **Reload flow review:** All 6 handlers that do write-test-reload
8. **Install.sh review:** nginx.conf setup, TLS configuration, SSL
   cipher suites, security headers, tunables seeding
9. **External reference:** Mozilla SSL Configuration Generator for
   cipher suite comparison

### 3.2 Findings (Plane work items)

| ID | Priority | Finding |
|----|----------|---------|
| `0df956af` | **Urgent** | Concurrent vhost writes race on `nginx -t` and reload — 6 handlers, no shared mutex |
| `762a6431` | **High** | `proxy_pass` rule target has no SSRF restriction — admin can proxy to `127.0.0.1:3306`, `unix:/run/...` |
| `2417099e` | **High** | Custom directives allowlist permits `auth_basic_user_file`, `sub_filter`, `proxy_pass` — file oracle + response injection |
| `8966d307` | **High** | SSL cipher suite `HIGH:!aNULL:!MD5` permits 3DES (Sweet32), CBC (Lucky13) — SSL Labs grade B |
| `502848f8` | **High** | Per-domain vhost template has no security headers (HSTS, X-Frame-Options, X-Content-Type-Options) |
| `2ab86c0a` | **Medium** | Full-domain cache purge walks ALL tenants' cache files — O(N) cross-tenant I/O DoS |
| `13543a04` | **Medium** | `nginx -t` failure output leaked in error messages to API callers — internal path disclosure (5 handlers) |
| `63af88a2` | **Medium** | Rewrite rule pattern is unvalidated regex — nginx PCRE ReDoS, tenant-accessible |

### 3.3 Positive findings (nginx things done right)

- **Cache gate is fail-closed:** `$jabali_skip` defaults to 1 (skip).
  Only genuinely anonymous GETs with no cookie or known-benign
  analytics cookies are cached. Gitea #416/#419 fix.
- **Cache key uses original path:** `$jabali_cache_path` (from
  `$request_uri`) prevents permalink collapse. Codeberg #5 fix.
- **Upstream cache opt-out honored:** `fastcgi_no_cache` checks
  `$upstream_http_cache_control` and `$upstream_http_vary`. GH #637,
  #642.
- **Cross-tenant cache eviction prevented:** `keyLineHost()` does
  exact host comparison, not substring. Gitea #418 fix.
- **Rate limit zone names are ULID-gated:** No user-controllable data
  reaches `limit_req_zone` / `limit_conn_zone` names.
- **Tenant-safe rule types:** `proxy_pass`, `ip_access`,
  `php_setting`, `max_upload_size` are excluded from tenant access.
  Tenant rewrites must be local paths (no scheme/host).
- **nginx -t is the final gate:** Every handler tests before reload.
  Rollback on failure is implemented in 5 of 6 handlers.
- **HTTP/2 version awareness:** Probes `nginx -v` on every render.
  Correct syntax for both sides of the 1.25.1 boundary.
- **Content-hash idempotency:** Prevents ~180 reloads/hour from
  reconciler ticks.

---

## 4. Recommendations

### Immediate (urgent)

1. **Add a shared mutex** to all nginx-mutating agent handlers.
   The write-test-reload sequence must be serialized.

### Short-term (high priority)

2. **Restrict `proxy_pass` targets** — reject loopback, RFC1916, link-
   local, and Unix socket paths in `validateNginxRules`.
3. **Narrow the custom directives allowlist** — remove
   `auth_basic_user_file`, `sub_filter*`, and `proxy_pass` from the
   raw allowlist. Use structured features (Directory Privacy, rule
   builder) instead.
4. **Upgrade SSL cipher suite** — replace `HIGH:!aNULL:!MD5` with
   Mozilla intermediate cipher list. Add `ssl_session_cache` and
   `ssl_session_timeout`.
5. **Add baseline security headers** to the per-domain vhost template
   — `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`,
   and HSTS when SSL is enabled.

### Medium-term

6. **Per-domain cache partitioning** — eliminate the full-walk purge
   by partitioning the cache directory per domain.
7. **Sanitize error messages** — stop embedding raw `nginx -t` output
   in `AgentError.Message`. Use `AgentError.Details` for admin
   debugging; return generic messages to tenants.
8. **Validate rewrite regex patterns** — cap length, detect ReDoS
   patterns, validate PCRE syntax.

### Ongoing

9. **Run `gosec` with correct configuration** — the current run
   produced 0 findings because it didn't resolve internal packages.
   Configure `gosec` to track the full module path.
10. **Add `semgrep` to CI** — complement `golangci-lint` with
    semgrep's security rules for Go.

---

## 5. Appendix — Files reviewed

### Pass 1 (general)

| File | Lines | Focus |
|------|-------|-------|
| `panel-agent/internal/commands/terminal_pty.go` | 360 | Root shell broker, SO_PEERCRED |
| `panel-agent/internal/commands/files_read.go` | 140 | File read, filesafe scope |
| `panel-agent/internal/commands/files_write.go` | 222 | File write, temp-rename, chown |
| `panel-agent/internal/commands/files_extract.go` | 374 | Archive extraction, zip-slip |
| `panel-agent/internal/commands/files_ingest.go` | 181 | Upload staging, atomic move |
| `panel-agent/internal/commands/user_create.go` | 169 | User creation, useradd, chpasswd |
| `panel-agent/internal/commands/user_password.go` | 103 | Password change, chpasswd |
| `panel-agent/internal/commands/user_delete.go` | 143 | User deletion, userdel, rollback |
| `panel-agent/internal/commands/db_user_create.go` | 120 | DB user, SQL escaping |
| `panel-agent/internal/commands/db_root_password.go` | 173 | Root password, socket auth guard |
| `panel-agent/internal/commands/system_kill.go` | 109 | Process kill, denylist |
| `panel-agent/internal/commands/ssh_authorized_keys_write.go` | 127 | SSH keys, filesafe scope |
| `panel-agent/internal/commands/php_cli_wrapper.go` | 317 | PHP wrapper, symlink TOCTOU |
| `panel-agent/internal/commands/migration_imapsync.go` | 230 | IMAP migration, passfile |
| `panel-agent/internal/commands/cron_apply.go` | 552 | Cron apply, validation, root cron |
| `panel-agent/internal/commands/runbounded.go` | 113 | Bounded output ring buffer |
| `panel-agent/internal/commands/shell_quote.go` | 11 | Shell quoting |
| `panel-agent/internal/commands/mysql_escape.go` | 41 | MariaDB escaping |
| `panel-agent/internal/commands/sensitive_files.go` | 53 | Sensitive file mode hardening |
| `panel-agent/internal/commands/docker_app.go` | 730 | Docker app install/lifecycle |
| `panel-agent/internal/commands/migration_rsync_remote_home.go` | 267 | rsync migration, filesafe |
| `panel-agent/internal/commands/migration_admin_run.go` | 327 | Migration secrets, systemd-run |
| `internal/filesafe/filesafe.go` | 345 | Scope, Clean, Resolve, verifyInScope |
| `internal/filesafe/openat2.go` | 908 | openat2, RESOLVE_BENEATH, ExtractDir |
| `panel-api/internal/middleware/auth_kratos.go` | 117 | Kratos session validation |
| `panel-api/internal/middleware/auth_user_api_token.go` | 207 | API token bearer auth |
| `panel-api/internal/middleware/automation_hmac.go` | 252 | HMAC verification, replay defense |
| `panel-api/internal/middleware/impersonation.go` | 104 | Admin act-as override |
| `panel-api/internal/middleware/cors.go` | 111 | CORS, same-origin check |
| `panel-api/internal/middleware/rbac.go` | 55 | RequireAdmin, RequireOwner |
| `panel-api/internal/middleware/ratelimit.go` | 130 | Per-IP token bucket |
| `panel-api/internal/middleware/localhost.go` | 50 | RequireLocalhost |
| `panel-api/internal/api/domains.go` | 1681 | Domain CRUD, ownership checks |
| `panel-api/internal/api/ddns.go` | 266 | DynDNS shim, token validation |
| `panel-api/internal/api/webmail_sso.go` | 204 | Webmail SSO, JWT mint |
| `panel-api/internal/api/sso_phpmyadmin.go` | 260 | phpMyAdmin SSO, same-origin check |
| `panel-api/internal/api/me_api_tokens.go` | 211 | API token creation, entropy |
| `panel-api/internal/webui/webui.go` | 127 | SPA serving, embed.FS |
| `panel-api/internal/sso/sso.go` | 186 | Shadow account, FOR UPDATE |
| `agentwire/wire.go` | 81 | NDJSON wire protocol |
| `panel-api/internal/webui/webui.go` | 127 | Embedded SPA serving |
| `install.sh` | 13,480 | Full installer (sampled) |

### Pass 2 (nginx)

| File | Lines | Focus |
|------|-------|-------|
| `panel-agent/internal/commands/domain_create.go` | 1117 | Vhost template, writeVhost, content-hash gate |
| `panel-agent/internal/commands/nginx.go` | 58 | nginx.test, nginx.reload |
| `panel-agent/internal/commands/nginx_cache_purge.go` | 200 | Targeted + full cache purge |
| `panel-agent/internal/commands/nginx_cache_warmup.go` | 175 | Sitemap fetch, cache pre-warm |
| `panel-agent/internal/commands/nginx_cache_capacity.go` | 75 | Cache zone size rewrite |
| `panel-agent/internal/commands/nginx_ratelimits.go` | 190 | Zone declarations, per-vhost directives |
| `panel-agent/internal/commands/nginx_tunables.go` | 230 | nginx.conf patching, fragment generation |
| `panel-agent/internal/commands/nginx_http2.go` | 72 | HTTP/2 version detection |
| `panel-agent/internal/commands/nginx_app_rewrites.go` | 190 | Per-install rewrite snippets |
| `panel-agent/internal/commands/nginx_status.go` | — | nginx status |
| `panel-api/internal/nginxrules/compile.go` | 132 | Rule compilation to directives |
| `panel-api/internal/models/nginx_safe_options.go` | 115 | Tenant-safe options, Render() |
| `panel-api/internal/api/domain_htaccess.go` | 121 | .htaccess import preview |
| `panel-api/internal/eventsources/nginx_config.go` | — | nginx config event source |
| `install/nginx/jabali-panel-vhost.conf.tmpl` | 125 | Panel vhost template |
| `install/nginx/jabali-fastcgi-cache.conf` | 55 | Cache keyzone + maps |
| `install/nginx/jabali-mail-vhost.conf.tmpl` | 105 | Webmail vhost template |
| `install/nginx/jabali-bulwark-upstream.conf` | 15 | Bulwark upstream |
| `install/nginx/.ory-location.conf` | 30 | Kratos proxy location |
| `install/nginx/jabali-websocket-map.conf` | 10 | WebSocket upgrade map |
| `install.sh` (nginx sections) | ~500 | nginx.conf, TLS, tunables seeding |
