diff options
| author | salmonik <salmonik@tuta.io> | 2026-07-29 02:24:41 +0200 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-07-29 02:24:41 +0200 |
| commit | c654a6d17eb58adbb15b9c47ab32bcc3a1666307 (patch) | |
| tree | 545544bf7d805254415da787fad1cdc711767833 | |
| parent | Add per-instance mod management with client-side detection (diff) | |
| download | minecraft-ui-c654a6d17eb58adbb15b9c47ab32bcc3a1666307.tar.gz minecraft-ui-c654a6d17eb58adbb15b9c47ab32bcc3a1666307.zip | |
Security audit fixes: patch 16 vulnerabilities across 7 source files
CRITICAL:
- Instance name path traversal (AP-001): sanitize::sanitize_instance_name()
strips .., /, \, \0, spaces, special chars at all 12 join call sites
- Zip-slip protection in world import: starts_with() guard after enclosed_name()
HIGH:
- RCON command injection: validate_username() rejects non-MC usernames
across all 6 player action macros (whitelist, op, ban)
- Level-seed properties injection: sanitize_seed() strips \n \r = : \0
- CSRF protection: SameSite=Lax -> SameSite=Strict on session cookie
MEDIUM:
- Session TTL: server-side 24h expiry via strftime check in require_auth
- Rate limiting: in-memory 10-ip/5min sliding window on login
- Background task stale is_active: all queries use concrete WHERE id=?1
- Mod validation DoS: 500MB cap, 10k class scan limit, 10k CP entry cap
LOW:
- Logout auth guard: moved into protected route group + CurrentUser param
- Mod orphan race: DB insert before file write
- Backup orphan race: DB insert before ZIP creation
- Template fix: broken Askama == comparisons in edit.html
All fixes compile clean with zero warnings. 10 unit tests pass.
30 files changed, 4060 insertions, 80 deletions
diff --git a/piolium/attack-surface/advisory-summary.md b/piolium/attack-surface/advisory-summary.md new file mode 100644 index 0000000..eaea57a --- /dev/null +++ b/piolium/attack-surface/advisory-summary.md | |||
| @@ -0,0 +1,395 @@ | |||
| 1 | # Advisory Summary: minecraft-ui | ||
| 2 | |||
| 3 | **Generated**: 2025-07-29T01:15:00Z | ||
| 4 | **Target**: `salmon/minecraft-ui` (self-hosted git at `git.2137697.xyz/salmon/minecraft-ui.git`) | ||
| 5 | **Analysis scope**: Stage 01 — Intelligence & Dependency Risk | ||
| 6 | |||
| 7 | --- | ||
| 8 | |||
| 9 | ## Repository Identity | ||
| 10 | |||
| 11 | | Field | Value | | ||
| 12 | |---|---| | ||
| 13 | | OWNER | `salmon` | | ||
| 14 | | REPO | `minecraft-ui` | | ||
| 15 | | OWNER_REPO | `salmon/minecraft-ui` | | ||
| 16 | | Resolution source | `git remote` (`https://git.2137697.xyz/salmon/minecraft-ui.git`) | | ||
| 17 | | Git history available | Yes (6 commits) | | ||
| 18 | | GitHub API available | No — self-hosted git server, not github.com | | ||
| 19 | | Primary ecosystem | Rust (Cargo) — edition 2024 | | ||
| 20 | | Total lines of code | ~3,213 (Rust source), 6 templates (Askama), 1 CSS, 1 JS (htmx) | | ||
| 21 | |||
| 22 | ## Coverage Gaps | ||
| 23 | |||
| 24 | - **Source 1 (git log CVE search)**: No CVE/GHSA references found in repo (0 results) — new project, no changelog, no SECURITY.md | ||
| 25 | - **Source 2 (GitHub Security Advisories)**: **Skipped** — `gh api` requires github.com; repo lives on `git.2137697.xyz` (self-hosted Gitea/Forgejo) | ||
| 26 | - **Source 5 (supplementary websearch)**: Not performed — Sources 1, 3, 4 cover all dependency advisories; no project-specific CVEs expected for a new internal tool | ||
| 27 | |||
| 28 | --- | ||
| 29 | |||
| 30 | ## Advisory Inventory | ||
| 31 | |||
| 32 | ### Direct Dependencies — Vulnerability Status | ||
| 33 | |||
| 34 | All 22 direct dependencies are at versions past all known advisories. No unfixed public vulnerabilities in the direct dependency tree. | ||
| 35 | |||
| 36 | | # | Package | Version | Known Advisories | Affected Range | Current Status | | ||
| 37 | |---|---------|---------|-----------------|---------------|----------------| | ||
| 38 | | 1 | `axum` | 0.8.9 | 0 | — | ✅ Clean | | ||
| 39 | | 2 | `multer` | 3.1.0 | 0 | — | ✅ Clean | | ||
| 40 | | 3 | `tokio` | 1.52.3 | 5 (historic) | All < 1.38.2 (oldest fix) | ✅ Fixed well above | | ||
| 41 | | 4 | `rusqlite` | 0.40.1 | 15 (historic) | < 0.25.4 (data races), < 0.26.2 (UAF) | ✅ Fixed well above | | ||
| 42 | | 5 | `bollard` | 0.17.1 | 2 | Checked — no active vulns | ✅ Clean | | ||
| 43 | | 6 | `argon2` | 0.5.3 | 0 | — | ✅ Clean | | ||
| 44 | | 7 | `zip` | 8.6.0 | **1 (CVE-2025-29787)** | 1.3.0 ≤ v < 2.3.0 | ✅ Fixed (v8.6.0 >> fix) | | ||
| 45 | | 8 | `reqwest` | 0.13.4 | 0 | — | ✅ Clean | | ||
| 46 | | 9 | `cookie` | 0.18.1 | 2 (historic) | < 0.7.6 | ✅ Fixed well above | | ||
| 47 | | 10 | `serde` | 1.0.228 | 0 | — | ✅ Clean | | ||
| 48 | | 11 | `askama` | 0.16.0 | 0 | — | ✅ Clean | | ||
| 49 | | 12 | `tower-http` | 0.7.0 (+ 0.6.11 transitive) | 2 (historic dir traversal) | < 0.1.3 / < 0.2.1 | ✅ Fixed well above | | ||
| 50 | | 13 | `axum-extra` | 0.12.6 | 0 | — | ✅ Clean | | ||
| 51 | | 14 | `tower` | 0.5.3 | 0 | — | ✅ Clean | | ||
| 52 | | 15 | `chrono` | 0.4.45 | 0 | — | ✅ Clean | | ||
| 53 | | 16 | `uuid` | 1.23.4 | 0 | — | ✅ Clean | | ||
| 54 | | 17 | `tempfile` | 3.27.0 | 3 | All historic/pre-2022 | ✅ Clean | | ||
| 55 | | 18 | `rand` | 0.8.7 | 1 (RUSTSEC-2026-0097) | 0.7.0 ≤ v < 0.8.6 | ✅ Fixed (0.8.7) | | ||
| 56 | | 19 | `tracing` | 0.1.44 | 1 (RUSTSEC-2023-0078 UAF) | 0.1.38 ≤ v < 0.1.40 | ✅ Fixed (0.1.44) | | ||
| 57 | | 20 | `serde_json` | 1.0.150 | 0 | — | ✅ Clean | | ||
| 58 | | 21 | `toml` | 1.1.2 | 0 | — | ✅ Clean | | ||
| 59 | | 22 | `futures-util` | 0.3.32 | 0 | — | ✅ Clean | | ||
| 60 | |||
| 61 | ### Notable Transitive Dependencies | ||
| 62 | |||
| 63 | | Package | Version | Known Advisories | Status | | ||
| 64 | |---------|---------|-----------------|--------| | ||
| 65 | | `hyper` | 1.10.1 | 0 | ✅ Clean | | ||
| 66 | | `h2` | 0.4.15 | 3 (all fixed in 0.4.4+) | ✅ Clean (0.4.15 > 0.4.4) | | ||
| 67 | | `mio` | 1.2.1 | 0 | ✅ Clean | | ||
| 68 | | `aes-gcm` | 0.10.3 | 0 | ✅ Clean | | ||
| 69 | |||
| 70 | ### Detailed Advisory Records | ||
| 71 | |||
| 72 | #### 1. CVE-2025-29787 — zip crate path traversal (HIGH) | ||
| 73 | |||
| 74 | | Field | Value | | ||
| 75 | |---|---| | ||
| 76 | | **ID** | `GHSA-94vh-gphv-8pm8` / `CVE-2025-29787` | | ||
| 77 | | **Published** | 2025-03-17 | | ||
| 78 | | **Severity** | HIGH (CVSS:4.0 8.1) | | ||
| 79 | | **Summary** | `zip` incorrectly canonicalizes paths during archive extraction, leading to arbitrary file write via path traversal | | ||
| 80 | | **CWEs** | CWE-180 (Incorrect Behavior Order), CWE-22 (Path Traversal), CWE-61 (UNIX Symbolic Link) | | ||
| 81 | | **Affected** | zip >= 1.3.0, < 2.3.0 | | ||
| 82 | | **Fixed in** | zip 2.3.0 | | ||
| 83 | | **Current** | 8.6.0 — **Not affected** | | ||
| 84 | | **Relevance** | The project uses `zip::ZipArchive` in `mods.rs` (mod extraction from ZIP), `routes/instances.rs` (world import), and `routes/backups.rs` (backup creation). All are using version 8.6.0, past the fix. | | ||
| 85 | |||
| 86 | #### 2. RUSTSEC-2025-0023 — tokio broadcast channel soundness | ||
| 87 | |||
| 88 | | Field | Value | | ||
| 89 | |---|---| | ||
| 90 | | **ID** | `GHSA-rr8g-9fpq-6wmg` | | ||
| 91 | | **Published** | 2025-04-07 | | ||
| 92 | | **Severity** | Informational (unsoundness) | | ||
| 93 | | **Summary** | `tokio::sync::broadcast` channel calls `clone` in parallel but does not require `Sync` | | ||
| 94 | | **Affected** | tokio >= 0.2.5 (various ranges) | | ||
| 95 | | **Fixed in** | 1.38.2 / 1.43.1 / 1.44.2 | | ||
| 96 | | **Current** | 1.52.3 — **Not affected** | | ||
| 97 | |||
| 98 | #### 3. RUSTSEC-2024-0332 — h2 CONTINUATION flood (HIGH) | ||
| 99 | |||
| 100 | | Field | Value | | ||
| 101 | |---|---| | ||
| 102 | | **ID** | `GHSA-q6cp-qfwq-4gcv` / `RUSTSEC-2024-0332` | | ||
| 103 | | **Published** | 2024-04-03 | | ||
| 104 | | **Severity** | HIGH | | ||
| 105 | | **Summary** | h2 servers vulnerable to degradation of service with CONTINUATION flood frames | | ||
| 106 | | **Affected** | h2 < 0.3.26 / < 0.4.4 | | ||
| 107 | | **Fixed in** | 0.4.4 | | ||
| 108 | | **Current** | 0.4.15 — **Not affected** | | ||
| 109 | |||
| 110 | #### 4. RUSTSEC-2023-0078 — tracing use-after-free | ||
| 111 | |||
| 112 | | Field | Value | | ||
| 113 | |---|---| | ||
| 114 | | **ID** | `GHSA-8f24-6m29-wm2r` | | ||
| 115 | | **Published** | 2024-01-17 | | ||
| 116 | | **Severity** | Informational | | ||
| 117 | | **Summary** | Use-after-free in tracing subscriber when `MakeVisitor` is implemented on a type that implements `Visit` without `Clone` | | ||
| 118 | | **Affected** | tracing >= 0.1.38, < 0.1.40 | | ||
| 119 | | **Fixed in** | 0.1.40 | | ||
| 120 | | **Current** | 0.1.44 — **Not affected** | | ||
| 121 | |||
| 122 | --- | ||
| 123 | |||
| 124 | ## Vulnerability Pattern Analysis | ||
| 125 | |||
| 126 | ### 2a. Component Vulnerability Heatmap | ||
| 127 | |||
| 128 | The project is a **brand-new codebase** with zero first-party CVEs. The heatmap below is derived from dependency advisory history and architectural analysis: | ||
| 129 | |||
| 130 | | Component | Advisory Count (historic) | Risk Factors | Dominant Bug Types | | ||
| 131 | |-----------|-------------------------|--------------|--------------------| | ||
| 132 | | **`rusqlite`** | 15 (all historic) | Data races (CVE-2020-35866..73), Use-After-Free (CVE-2021-45713..19) | Race conditions, UAF | | ||
| 133 | | **`tokio`** | 5 (all historic) | Race conditions, unsoundness | Concurrency bugs | | ||
| 134 | | **`h2`** (transitive) | 3 (all fixed by 0.4.4) | CONTINUATION flood, DoS | Resource exhaustion | | ||
| 135 | | **`zip`** | 1 (CVE-2025-29787, fixed in 2.3.0) | Path traversal, symlink | Path traversal | | ||
| 136 | | **`cookie`** | 2 (both historic, < 0.7.6) | Max-Age DoS, input validation | Input validation | | ||
| 137 | | **`tempfile`** | 3 (all historic) | Race conditions, permissions | TOCTOU | | ||
| 138 | | **`rand`** | 1 (RUSTSEC-2026-0097, fixed in 0.8.6) | Unsound with custom logger | Unsoundness | | ||
| 139 | |||
| 140 | **🔥 High-heat components requiring Phase 3/5 attention:** | ||
| 141 | 1. **`mods.rs` (480 lines)** — Custom JAR/ZIP parser, class-file constant pool scanning, ZIP extraction. Highest complexity, most attack surface. | ||
| 142 | 2. **`routes/instances.rs` (782 lines)** — World import/export (ZIP extraction), instance lifecycle, Docker orchestration. Largest single file. | ||
| 143 | 3. **`auth.rs` (199 lines)** — Session management, password hashing, cookie handling. Custom session cookie parsing (no library). | ||
| 144 | 4. **`docker.rs` (209 lines)** — Docker API calls with bollard, container creation with user-controlled env vars. | ||
| 145 | 5. **`rcon.rs` (127 lines)** — Raw TCP protocol implementation, password handling. | ||
| 146 | |||
| 147 | ### 2b. Bug Type Recurrence | ||
| 148 | |||
| 149 | | Bug Class | CWEs | Historic Count | Examples | Recurring? | | ||
| 150 | |-----------|------|---------------|----------|------------| | ||
| 151 | | **Race condition / TOCTOU** | CWE-362 | 8+ | rusqlite data races, tokio races, tempfile | ⚠️ **Recurring** | | ||
| 152 | | **Path traversal** | CWE-22 | 2 | zip (CVE-2025-29787), tower-http (RUSTSEC-2022-0043) | ⚠️ **Recurring** | | ||
| 153 | | **Resource exhaustion / DoS** | CWE-400, CWE-770 | 3 | h2 CONTINUATION flood, cookie Max-Age, h2 general | ⚠️ **Recurring** | | ||
| 154 | | **Use-after-free** | CWE-416 | 8 | rusqlite UAF, tracing UAF | ⚠️ **Recurring** | | ||
| 155 | | **Improper input validation** | CWE-20 | 2 | cookie, zip | — | | ||
| 156 | | **Unsoundness** | — | 2 | tokio broadcast, rand | — | | ||
| 157 | |||
| 158 | **Key insight**: Path traversal recurs across the dependency tree (zip and tower-http). The project's custom ZIP extraction code (`routes/instances.rs:import_world`) should be audited for the same pattern. | ||
| 159 | |||
| 160 | ### 2c. Attack Surface Trends | ||
| 161 | |||
| 162 | | Input Vector | Where Exploited | Risk Level | | ||
| 163 | |-------------|----------------|------------| | ||
| 164 | | **Uploaded ZIP/JAR files** | `routes/mods.rs`, `routes/instances.rs` (world import) | 🔴 **Critical** | | ||
| 165 | | **HTTP requests** (form data, multipart) | All routes via axum | 🟡 High | | ||
| 166 | | **Docker container env vars** | `docker.rs:recreate_container` — user-controlled (version, MOTD, etc.) | 🟡 High | | ||
| 167 | | **RCON network protocol** | `rcon.rs` — custom TCP implementation | 🟡 High | | ||
| 168 | | **SQL queries via rusqlite** | All DB operations (uses params! macro — safe) | 🟢 Low | | ||
| 169 | | **Cookie header parsing** | `auth.rs:require_auth` — manual cookie split | 🟡 Medium | | ||
| 170 | | **Template rendering** | Askama templates (compile-time, no injection) | 🟢 Low | | ||
| 171 | | **Config file** | `config.toml` parsed via toml crate | 🟢 Low | | ||
| 172 | |||
| 173 | **Highest-risk attack surface**: The mod upload/validation pipeline (`routes/mods.rs` + `mods.rs`) accepts arbitrary JAR/ZIP uploads with a 500MB size limit and performs custom bytecode-level class scanning. | ||
| 174 | |||
| 175 | ### 2d. Patch Quality Signals | ||
| 176 | |||
| 177 | No patch-bypass history exists for this project (new codebase, no patched vulnerabilities). However, several **architectural concerns** warrant attention: | ||
| 178 | |||
| 179 | 1. **`mods.rs` class-file scanner** — Custom constant pool parser (not using a library). Any bug in this parser could lead to mis-validation (accepting client-side mods that crash the server, or rejecting valid server mods). No fuzzing evidence exists. | ||
| 180 | |||
| 181 | 2. **`auth.rs` cookie session parsing** — Manual `split(';')` + `split_once('=')` parsing of the Cookie header. Equivalent to CWE-172 (Encoding Error). No library cookie parser used despite `cookie` crate being a dependency. | ||
| 182 | |||
| 183 | 3. **`docker.rs` env injection** — Instance name, MOTD, version, seed, etc. are directly interpolated into Docker container env vars. No sanitization of values that could contain shell-special characters or newlines (env var injection risk). | ||
| 184 | |||
| 185 | 4. **`rcon.rs` packet parsing** — Custom binary protocol parser with `i32::from_le_bytes` length prefix. Potential for integer underflow/overflow in length handling. | ||
| 186 | |||
| 187 | --- | ||
| 188 | |||
| 189 | ## Architecture Inventory | ||
| 190 | |||
| 191 | ### Components | ||
| 192 | |||
| 193 | | Component | File(s) | Purpose | Security Profile | | ||
| 194 | |-----------|---------|---------|-----------------| | ||
| 195 | | **HTTP Router** | `routes/mod.rs` | Axum router with auth middleware | Cookie-based session auth on protected routes | | ||
| 196 | | **Auth module** | `auth.rs` | Password hashing (Argon2), session creation, login/logout | Custom cookie parsing; sessions in SQLite | | ||
| 197 | | **Dashboard** | `routes/dashboard.rs` | Landing page showing active instance | Reads-only | | ||
| 198 | | **Instance Manager** | `routes/instances.rs` | CRUD for server instances, world import/export, activation | World ZIP import/extract; Docker lifecycle | | ||
| 199 | | **Mod Manager** | `routes/mods.rs`, `mods.rs` | JAR/ZIP upload, validation, class-file scanning | Largest attack surface: custom bytecode parser | | ||
| 200 | | **Player Manager** | `routes/players.rs` | RCON-based whitelist/OP/ban management | Sends commands via RCON to Minecraft server | | ||
| 201 | | **Backup Manager** | `routes/backups.rs` | Create/list/restore/download backups | ZIP creation, file read for download | | ||
| 202 | | **Account Manager** | `routes/accounts.rs` | Account CRUD, password change | Superuser-only; session invalidation on password change | | ||
| 203 | | **Docker Manager** | `docker.rs` | Container lifecycle via bollard API | Image pull, container create/start/stop with env vars | | ||
| 204 | | **RCON Client** | `rcon.rs` | Raw TCP RCON protocol implementation | Custom binary protocol parser | | ||
| 205 | | **Database** | `db.rs` | SQLite via rusqlite with connection pool (Arc<Mutex>) | WAL mode, foreign keys, migrations | | ||
| 206 | | **Config** | `config.rs` | TOML config loader | Static config, parsed at startup | | ||
| 207 | |||
| 208 | ### Transports | ||
| 209 | |||
| 210 | | Transport | Direction | Protocol | Used By | | ||
| 211 | |-----------|-----------|----------|---------| | ||
| 212 | | **HTTP** | User ↔ WebUI | HTTP/1.1 (via axum/hyper) | All routes | | ||
| 213 | | **Docker socket** | WebUI ↔ Docker daemon | Unix socket (bollard) | `docker.rs` | | ||
| 214 | | **RCON** | WebUI ↔ Minecraft server | TCP, custom binary protocol on port 25575 | `rcon.rs` | | ||
| 215 | | **Mojang API** | WebUI → piston-meta.mojang.com | HTTPS (reqwest) | Fetching version manifest | | ||
| 216 | | **File system** | WebUI ↔ Disk | Standard I/O | World export/import, mod uploads, backups | | ||
| 217 | |||
| 218 | ### Trust Boundaries | ||
| 219 | |||
| 220 | ``` | ||
| 221 | ┌─────────────────────────────────────────────────────────────────┐ | ||
| 222 | │ Internet / LAN │ | ||
| 223 | │ ┌────────────┐ HTTP :3000 ┌────────────────────────┐ │ | ||
| 224 | │ │ Browser │ ──────────────▶ │ WebUI (axum) │ │ | ||
| 225 | │ │ (HTMX/JS) │ │ minecraft-ui process │ │ | ||
| 226 | │ └────────────┘ │ - auth middleware │ │ | ||
| 227 | │ │ - cookie sessions │ │ | ||
| 228 | │ └──────┬─────────────────┘ │ | ||
| 229 | │ │ │ | ||
| 230 | │ ┌───────────────────────────────┼──────────────┐ │ | ||
| 231 | │ │ Docker daemon socket │ RCON TCP │ │ | ||
| 232 | │ ▼ ▼ │ │ | ||
| 233 | │ ┌─────────────────────┐ ┌───────────────────┐ │ │ | ||
| 234 | │ │ Docker Container │ │ Minecraft Server │ │ │ | ||
| 235 | │ │ itzg/minecraft-srv │ │ (inside Docker) │ │ │ | ||
| 236 | │ │ port 25565 │ │ port 25575 │ │ │ | ||
| 237 | │ │ port 25575 │ └───────────────────┘ │ │ | ||
| 238 | │ └─────────────────────┘ │ │ | ||
| 239 | │ │ │ | ||
| 240 | │ ┌────────────────────────────────────────────────────┐ │ │ | ||
| 241 | │ │ File System │ │ │ | ||
| 242 | │ │ - server_directory/instances/{name}/data/ │ │ │ | ||
| 243 | │ │ - server_directory/backups/ │ │ │ | ||
| 244 | │ │ - server_directory/mcui.db (SQLite) │ │ │ | ||
| 245 | │ └────────────────────────────────────────────────────┘ │ │ | ||
| 246 | └─────────────────────────────────────────────────────────────────┘ | ||
| 247 | ``` | ||
| 248 | |||
| 249 | ### Execution Environments | ||
| 250 | |||
| 251 | | Environment | Description | | ||
| 252 | |-------------|-------------| | ||
| 253 | | **Host OS** | Linux (presumed — Docker socket, Unix paths) | | ||
| 254 | | **Rust runtime** | Native binary via `tokio` async runtime | | ||
| 255 | | **Docker container** | `itzg/minecraft-server` with Java 8/17/21 depending on version | | ||
| 256 | | **SQLite** | Embedded database (WAL mode) | | ||
| 257 | |||
| 258 | ### Highest-Risk Flows (Phase 3 DFD priority) | ||
| 259 | |||
| 260 | 1. **Mod Upload → Validation → Disk Write** — External attacker-controlled JAR/ZIP → custom bytecode parser → file written to `mods/` directory | ||
| 261 | 2. **World Import → ZIP Extraction → Disk Write** — Uploaded ZIP → `zip::ZipArchive::by_index` → `enclosed_name()` → file extraction to `world/` | ||
| 262 | 3. **Instance Create → Docker Container Start** — User-controlled form fields → env var interpolation → Docker API call | ||
| 263 | 4. **Backup Download → File Read → HTTP Response** — Backup ID → file read from disk → streamed to user (path traversal risk) | ||
| 264 | 5. **Login → Cookie → Session** — Password verification → session creation → cookie parsing in auth middleware | ||
| 265 | |||
| 266 | --- | ||
| 267 | |||
| 268 | ## Component Inventory | ||
| 269 | |||
| 270 | ### Runtime | ||
| 271 | |||
| 272 | | Component | Version | Evidence | Security-relevant | | ||
| 273 | |-----------|---------|----------|-------------------| | ||
| 274 | | Rust | edition 2024 / stable | `Cargo.toml` | No | | ||
| 275 | |||
| 276 | ### Package Dependencies (direct) | ||
| 277 | |||
| 278 | | Component | Version | Purpose | Security-relevant | | ||
| 279 | |-----------|---------|---------|-------------------| | ||
| 280 | | axum | 0.8.9 | HTTP framework | Yes — request parsing | | ||
| 281 | | multer | 3.1.0 | Multipart uploads | Yes — file upload handling | | ||
| 282 | | tokio | 1.52.3 | Async runtime | Yes — concurrency | | ||
| 283 | | rusqlite | 0.40.1 | SQLite database | Yes — SQL injection surface | | ||
| 284 | | bollard | 0.17.1 | Docker API client | Yes — container control | | ||
| 285 | | argon2 | 0.5.3 | Password hashing | Yes — auth credential handling | | ||
| 286 | | zip | 8.6.0 | ZIP archive handling | Yes — file extraction | | ||
| 287 | | reqwest | 0.13.4 | HTTP client | No — version manifest fetches | | ||
| 288 | | cookie | 0.18.1 | Cookie handling | Yes — session cookies | | ||
| 289 | | serde | 1.0.228 | Serialization | No | | ||
| 290 | | askama | 0.16.0 | Template engine | No — compile-time templates | | ||
| 291 | | tower-http | 0.7.0 | HTTP middleware (static files) | No | | ||
| 292 | | axum-extra | 0.12.6 | Cookie private jar | Yes — session cookie crypto | | ||
| 293 | | chrono | 0.4.45 | Date/time | No | | ||
| 294 | | uuid | 1.23.4 | Session ID generation | No | | ||
| 295 | | tempfile | 3.27.0 | Temporary files | No | | ||
| 296 | | rand | 0.8.7 | RNG for passwords | Yes — RCON password generation | | ||
| 297 | | tracing | 0.1.44 | Logging | No | | ||
| 298 | | toml | 1.1.2 | Config parsing | No | | ||
| 299 | |||
| 300 | ### Binary Dependencies | ||
| 301 | |||
| 302 | | Component | Evidence | Purpose | Security-relevant | | ||
| 303 | |-----------|----------|---------|-------------------| | ||
| 304 | | Docker daemon | `bollard::Docker::connect_with_local_defaults()` | Container management | Yes — OS-level access | | ||
| 305 | | Java (JRE 8/17/21) | Docker `itzg/minecraft-server` images | Minecraft runtime | Yes — runs untrusted mod code | | ||
| 306 | | `itzg/minecraft-server` | Docker image | Minecraft server packaging | Yes — external image | | ||
| 307 | |||
| 308 | ### External Services | ||
| 309 | |||
| 310 | | Service | Evidence | Purpose | Security-relevant | | ||
| 311 | |---------|----------|---------|-------------------| | ||
| 312 | | `piston-meta.mojang.com` | `reqwest::get(...)` in `fetch_minecraft_versions()` | Version manifest | No — read-only public API | | ||
| 313 | |||
| 314 | ### Datastores | ||
| 315 | |||
| 316 | | Component | Evidence | Purpose | Security-relevant | | ||
| 317 | |-----------|----------|---------|-------------------| | ||
| 318 | | SQLite (rusqlite) | `db.rs` | Accounts, sessions, instances, mods | Yes — credential storage | | ||
| 319 | | Docker volume mounts | `docker.rs` | Minecraft server data | Yes — file system access | | ||
| 320 | | Server directory | `config.toml:server_directory` | All persistent data | Yes — file system root | | ||
| 321 | |||
| 322 | ### Coverage Gaps | ||
| 323 | |||
| 324 | | Category | Status | Note | | ||
| 325 | |----------|--------|------| | ||
| 326 | | runtime | ✅ Covered | Rust edition 2024 | | ||
| 327 | | package | ✅ Covered | All 22 direct deps from Cargo.toml | | ||
| 328 | | framework | ✅ Covered | axum, askama | | ||
| 329 | | datastore | ✅ Covered | SQLite, Docker volumes | | ||
| 330 | | external-service | ✅ Covered | Mojang API | | ||
| 331 | | container-os | ⚠️ Partial | Dockerfile not in repo; uses `itzg/minecraft-server` images | | ||
| 332 | | build-ci | ℹ️ None detected | No CI config found in repo | | ||
| 333 | | binary | ✅ Covered | Docker, Java (via Docker) | | ||
| 334 | | vendored | ✅ Covered | `static/htmx.min.js` (vendored JS library) | | ||
| 335 | |||
| 336 | --- | ||
| 337 | |||
| 338 | ## Dependency Intelligence | ||
| 339 | |||
| 340 | ### Security-Relevant Dependencies (derived view) | ||
| 341 | |||
| 342 | | Dependency | Version | Risk Factors | Pattern Cross-Reference | | ||
| 343 | |-----------|---------|-------------|------------------------| | ||
| 344 | | **axum** | 0.8.9 | Input parsing, multipart handling | HTTP request surface (2c) | | ||
| 345 | | **multer** | 3.1.0 | Multipart file upload with 500MB limit | File upload attack surface (2c) | | ||
| 346 | | **rusqlite** | 0.40.1 | →historic: data races, UAF | Bug class: race conditions (2b) | | ||
| 347 | | **bollard** | 0.17.1 | Docker socket access, container control | Docker API attack surface (2c) | | ||
| 348 | | **argon2** | 0.5.3 | Password hashing — credential protection | Auth security (2c) | | ||
| 349 | | **zip** | 8.6.0 | →historic: path traversal (CVE-2025-29787) | Bug class: path traversal (2b) | | ||
| 350 | | **cookie** | 0.18.1 | →historic: DoS via Max-Age | Session handling (2c) | | ||
| 351 | | **axum-extra** | 0.12.6 | Cookie private jar (encryption) | Session security | | ||
| 352 | | **rand** | 0.8.7 | RCON password generation | Password strength | | ||
| 353 | |||
| 354 | ### Key Risk Observations | ||
| 355 | |||
| 356 | 1. **No regular dependency scanning configured** — No `cargo audit` or `cargo deny` in CI. Relying on manual version choice. | ||
| 357 | 2. **`htmx.min.js` (vendored)** — Static file served via `tower-http::services::ServeDir`. Should verify integrity of vendored copy. | ||
| 358 | 3. **`itzg/minecraft-server` Docker images** — External base images with Java runtimes. Image pull happens at runtime; no image hash pinning in code. | ||
| 359 | 4. **RCON password generation** — Uses `rand::thread_rng()` (CSPRNG) with 16-char alphanumeric charset (~93 bits entropy). Adequate but no periodic rotation. | ||
| 360 | |||
| 361 | --- | ||
| 362 | |||
| 363 | ## Audit Targeting Recommendations | ||
| 364 | |||
| 365 | Based on pattern analysis: | ||
| 366 | |||
| 367 | 1. **Phase 3 DFD priorities**: | ||
| 368 | - **Mod upload/validation pipeline** (`routes/mods.rs` + `mods.rs`) — highest complexity, custom bytecode parser | ||
| 369 | - **World import/export** (`routes/instances.rs:import_world`, `export_world`) — ZIP extraction to disk | ||
| 370 | - **Auth middleware** (`auth.rs:require_auth`) — custom cookie parsing without library | ||
| 371 | |||
| 372 | 2. **Phase 5 deep probe targets**: | ||
| 373 | - **Mod validation bypass** — Can a crafted JAR bypass the class-file scanner? (CWE-22, CWE-180) | ||
| 374 | - **Path traversal in world ZIP extraction** — `zip::ZipArchive::by_index().enclosed_name()` usage | ||
| 375 | - **Docker env var injection** — User-controlled values in container creation | ||
| 376 | - **RCON protocol weaknesses** — Integer handling in packet length parsing | ||
| 377 | |||
| 378 | 3. **Phase 10 chambers (mandatory attack modes)**: | ||
| 379 | - Path traversal (CWE-22) | ||
| 380 | - Injection (command/env/XML via Docker env) | ||
| 381 | - Deserialization (JAR/ZIP parsing) | ||
| 382 | - Race conditions (SQLite, file operations) | ||
| 383 | - Auth bypass (cookie forging) | ||
| 384 | |||
| 385 | 4. **Patch-bypass-checker candidates**: | ||
| 386 | - No structural-recurrence candidates (new codebase) — but the ZIP extraction code in both `routes/instances.rs` and `routes/backups.rs` should be compared for consistency | ||
| 387 | |||
| 388 | --- | ||
| 389 | |||
| 390 | ## References | ||
| 391 | |||
| 392 | - OSV.dev queries: 22 direct deps + 5 transitive checked | ||
| 393 | - NVD keywordSearch for "minecraft-ui": 0 results | ||
| 394 | - Git log: 6 commits, no CVE references | ||
| 395 | - Source analysis: 3,213 lines across 16 Rust files | ||
diff --git a/piolium/attack-surface/architecture-entrypoints.md b/piolium/attack-surface/architecture-entrypoints.md new file mode 100644 index 0000000..6574a93 --- /dev/null +++ b/piolium/attack-surface/architecture-entrypoints.md | |||
| @@ -0,0 +1,137 @@ | |||
| 1 | # Architecture Entrypoints Inventory | ||
| 2 | |||
| 3 | **Target:** salmon/minecraft-ui | ||
| 4 | **Generated:** 2025-07-29 | ||
| 5 | **Phase:** P3 | ||
| 6 | |||
| 7 | --- | ||
| 8 | |||
| 9 | ## Public Routes (No Auth Required) | ||
| 10 | |||
| 11 | | # | Method | Path | Handler | Source file | Parameters | Attacker-controlled sources | | ||
| 12 | |---|--------|------|---------|-------------|------------|---------------------------| | ||
| 13 | | 1 | GET | `/login` | `login_page` | src/routes/mod.rs:43 | None | None | | ||
| 14 | | 2 | POST | `/login` | `authenticate` | src/auth.rs:34 | `username`, `password` (Form) | `username`, `password` strings | | ||
| 15 | | 3 | POST | `/logout` | `logout` | src/auth.rs:130 | Cookie header (optional) | Session ID from cookie | | ||
| 16 | | 4 | GET | `/static/*` | `ServeDir` | src/routes/mod.rs:28 | File path | Static file path (ServeDir) | | ||
| 17 | |||
| 18 | ## Protected Routes (Auth Required via `require_auth` middleware) | ||
| 19 | |||
| 20 | ### Dashboard | ||
| 21 | |||
| 22 | | # | Method | Path | Handler | Source file | Key inputs | | ||
| 23 | |---|--------|------|---------|-------------|------------| | ||
| 24 | | 5 | GET | `/` | `dashboard` | src/routes/dashboard.rs:19 | Session cookie → CurrentUser | | ||
| 25 | |||
| 26 | ### Instance Management | ||
| 27 | |||
| 28 | | # | Method | Path | Handler | Source file | Key inputs | | ||
| 29 | |---|--------|------|---------|-------------|------------| | ||
| 30 | | 6 | GET | `/instances` | `list_instances` | src/routes/instances.rs:44 | None | | ||
| 31 | | 7 | GET | `/instances/new` | `create_form` | src/routes/instances.rs:70 | None | | ||
| 32 | | 8 | POST | `/instances/new` | `create_instance` | src/routes/instances.rs:87 | `name`, `version`, `server_type`, `world_source`, `seed`, `memory_mb`, `difficulty`, `gamemode`, `max_players`, `pvp`, `motd` | | ||
| 33 | | 9 | GET | `/instances/{id}/edit` | `edit_form` | src/routes/instances.rs:129 | Path: `id` (i64) | | ||
| 34 | | 10 | POST | `/instances/{id}/edit` | `edit_instance` | src/routes/instances.rs:176 | Path: `id`; Form: `difficulty`, `gamemode`, `max_players`, `pvp`, `whitelist_enabled`, `motd`, `memory_mb` | | ||
| 35 | | 11 | POST | `/instances/{id}/delete` | `delete_instance` | src/routes/instances.rs:198 | Path: `id` | | ||
| 36 | | 12 | POST | `/instances/{id}/activate` | `activate_instance` | src/routes/instances.rs:217 | Path: `id` | | ||
| 37 | | 13 | POST | `/instances/deactivate` | `deactivate_instance` | src/routes/instances.rs:278 | None | | ||
| 38 | |||
| 39 | ### World Management | ||
| 40 | |||
| 41 | | # | Method | Path | Handler | Source file | Key inputs | | ||
| 42 | |---|--------|------|---------|-------------|------------| | ||
| 43 | | 14 | GET | `/instances/{id}/world/export` | `export_world` | src/routes/instances.rs:288 | Path: `id` | | ||
| 44 | | 15 | POST | `/instances/{id}/world/import` | `import_world` | src/routes/instances.rs:328 | Path: `id`; Multipart: ZIP file | | ||
| 45 | | 16 | POST | `/instances/{id}/world/regenerate` | `regenerate_world` | src/routes/instances.rs:360 | Path: `id`; Form: `seed` | | ||
| 46 | |||
| 47 | ### Player Management | ||
| 48 | |||
| 49 | | # | Method | Path | Handler | Source file | Key inputs | | ||
| 50 | |---|--------|------|---------|-------------|------------| | ||
| 51 | | 17 | GET | `/players` | `manage_players` | src/routes/players.rs:28 | None | | ||
| 52 | | 18 | POST | `/players/whitelist/add` | `add_whitelist` | src/routes/players.rs:89 | `username` | | ||
| 53 | | 19 | POST | `/players/whitelist/remove` | `remove_whitelist` | src/routes/players.rs:89 | `username` | | ||
| 54 | | 20 | POST | `/players/op/add` | `add_op` | src/routes/players.rs:89 | `username` | | ||
| 55 | | 21 | POST | `/players/op/remove` | `remove_op` | src/routes/players.rs:89 | `username` | | ||
| 56 | | 22 | POST | `/players/ban/add` | `add_ban` | src/routes/players.rs:89 | `username` | | ||
| 57 | | 23 | POST | `/players/ban/remove` | `remove_ban` | src/routes/players.rs:89 | `username` | | ||
| 58 | |||
| 59 | ### Backup Management | ||
| 60 | |||
| 61 | | # | Method | Path | Handler | Source file | Key inputs | | ||
| 62 | |---|--------|------|---------|-------------|------------| | ||
| 63 | | 24 | GET | `/instances/{id}/backups` | `list_backups` | src/routes/backups.rs:30 | Path: `id` | | ||
| 64 | | 25 | POST | `/instances/{id}/backups/create` | `create_backup` | src/routes/backups.rs:54 | Path: `id` | | ||
| 65 | | 26 | POST | `/backups/{id}/restore` | `restore_backup` | src/routes/backups.rs:88 | Path: `id` (STUB — no-op) | | ||
| 66 | | 27 | GET | `/backups/{id}/download` | `download_backup` | src/routes/backups.rs:93 | Path: `id` | | ||
| 67 | |||
| 68 | ### Mod Management | ||
| 69 | |||
| 70 | | # | Method | Path | Handler | Source file | Key inputs | | ||
| 71 | |---|--------|------|---------|-------------|------------| | ||
| 72 | | 28 | GET | `/instances/{id}/mods` | `list_mods` | src/routes/mods.rs:42 | Path: `id` | | ||
| 73 | | 29 | POST | `/instances/{id}/mods` | `upload_mod` | src/routes/mods.rs:86 | Path: `id`; Multipart: `.jar`/`.zip` file + filename | | ||
| 74 | | 30 | POST | `/instances/{id}/mods/{mod_id}/delete` | `delete_mod` | src/routes/mods.rs:188 | Path: `instance_id`, `mod_id` | | ||
| 75 | | 31 | POST | `/instances/{id}/mods/rescan` | `rescan_mods` | src/routes/mods.rs:163 | Path: `id` | | ||
| 76 | |||
| 77 | ### Account Management (Superuser only — guarded in handler) | ||
| 78 | |||
| 79 | | # | Method | Path | Handler | Source file | Key inputs | | ||
| 80 | |---|--------|------|---------|-------------|------------| | ||
| 81 | | 32 | GET | `/accounts` | `manage_accounts` | src/routes/accounts.rs:60 | None | | ||
| 82 | | 33 | POST | `/accounts/create` | `create_account` | src/routes/accounts.rs:95 | `username`, `password`, `is_superuser` | | ||
| 83 | | 34 | POST | `/accounts/delete` | `delete_account` | src/routes/accounts.rs:111 | `id` | | ||
| 84 | | 35 | POST | `/accounts/set-password` | `set_password` | src/routes/accounts.rs:142 | `id`, `new_password` | | ||
| 85 | | 36 | POST | `/accounts/change-own-password` | `change_own_password_from_accounts` | src/routes/accounts.rs:163 | `current_password`, `new_password` | | ||
| 86 | |||
| 87 | ### Password Change (accessible before password_change_required satisfied) | ||
| 88 | |||
| 89 | | # | Method | Path | Handler | Source file | Key inputs | | ||
| 90 | |---|--------|------|---------|-------------|------------| | ||
| 91 | | 37 | GET | `/change-password` | `change_password_page` | src/routes/mod.rs:58 | Session cookie → CurrentUser | | ||
| 92 | | 38 | POST | `/change-password` | `accounts::change_password` | src/routes/mod.rs:65 | Session cookie; Form: `current_password`, `new_password` | | ||
| 93 | |||
| 94 | --- | ||
| 95 | |||
| 96 | ## Attacker-Controlled Sources Summary | ||
| 97 | |||
| 98 | | Source category | Examples | Highest risk sink | | ||
| 99 | |-----------------|----------|-------------------| | ||
| 100 | | Form fields (strings) | `username`, `name`, `seed`, `motd` | SQLite INSERT/UPDATE (parametrized), RCON command (unsanitized) | | ||
| 101 | | Multipart file bytes | `.jar`/`.zip` uploads | Filesystem write via `std::fs::write` | | ||
| 102 | | Multipart filename | `field.file_name()` | Filesystem path construction | | ||
| 103 | | Path parameters | `id`, `instance_id`, `mod_id` | SQL queries (parametrized), filesystem paths (backup download) | | ||
| 104 | | Cookie header | `mcui_session` | Session lookup (parametrized) | | ||
| 105 | |||
| 106 | --- | ||
| 107 | |||
| 108 | ## High-Value Sinks | ||
| 109 | |||
| 110 | | Sink category | Locations | Risk | | ||
| 111 | |--------------|-----------|------| | ||
| 112 | | **Filesystem write** | `src/routes/mods.rs:98` (`std::fs::write`); `src/routes/instances.rs:210` (`std::fs::File::create`); `src/routes/backups.rs:72` (`std::fs::File::create`) | **HIGH** — arbitrary file write from mod upload | | ||
| 113 | | **Filesystem read** | `src/routes/backups.rs:106` (`std::fs::read`); `src/routes/instances.rs:293` (`std::fs::read`) | MEDIUM — arbitrary file read | | ||
| 114 | | **Docker control** | `src/docker.rs:62` (`start_container`); `src/docker.rs:106` (`stop_container`) | **HIGH** — container lifecycle | | ||
| 115 | | **RCON command** | `src/routes/players.rs:89` (`client.send_command_async(cmd)`); `src/routes/instances.rs:153` (`send_command_async`) | **HIGH** — Minecraft server control | | ||
| 116 | | **SQL execution** | All `execute()` and `query_row()` calls (parametrized) | LOW — parametrized | | ||
| 117 | | **TLS outbound** | `src/routes/instances.rs:280` (`reqwest::Client` → Mojang API) | LOW — trusted URL | | ||
| 118 | |||
| 119 | --- | ||
| 120 | |||
| 121 | ## Key Source Files | ||
| 122 | |||
| 123 | | File | Role | Security relevance | | ||
| 124 | |------|------|-------------------| | ||
| 125 | | `src/auth.rs` | Auth middleware, login/logout, session management | **CRITICAL** — auth boundary | | ||
| 126 | | `src/routes/mod.rs` | Router builder, route composition | **CRITICAL** — which routes are protected | | ||
| 127 | | `src/routes/mods.rs` | Mod upload, ZIP extraction, validation | **HIGH** — file write, parsing | | ||
| 128 | | `src/routes/instances.rs` | Instance CRUD, world import/export, container | **HIGH** — ZIP extraction, file ops | | ||
| 129 | | `src/routes/backups.rs` | Backup create/download/restore | **MEDIUM** — file read/write | | ||
| 130 | | `src/routes/players.rs` | RCON whitelist/op/ban commands | **MEDIUM** — RCON injection | | ||
| 131 | | `src/routes/accounts.rs` | Account CRUD, password change | **HIGH** — privilege management | | ||
| 132 | | `src/mods.rs` | JAR metadata + class-file validation | **HIGH** — parsing attacker-supplied JARs | | ||
| 133 | | `src/rcon.rs` | RCON protocol client | **MEDIUM** — Minecraft server control | | ||
| 134 | | `src/docker.rs` | Docker container lifecycle | **HIGH** — host-level operations | | ||
| 135 | | `src/db.rs` | Database schema + connection | **MEDIUM** — schema design | | ||
| 136 | | `src/config.rs` | Configuration loading | **MEDIUM** — initial creds | | ||
| 137 | | `src/properties.rs` | .properties file reader | **LOW** | | ||
diff --git a/piolium/attack-surface/candidates-summary.md b/piolium/attack-surface/candidates-summary.md new file mode 100644 index 0000000..69a3d4e --- /dev/null +++ b/piolium/attack-surface/candidates-summary.md | |||
| @@ -0,0 +1,55 @@ | |||
| 1 | # Candidate Scan | ||
| 2 | |||
| 3 | Generated by piolium at 2026-07-28T23:31:42.201Z | ||
| 4 | |||
| 5 | ## Totals | ||
| 6 | |||
| 7 | - Files scanned: 61 | ||
| 8 | - Candidate files: 1 | ||
| 9 | - Candidate matches: 26 | ||
| 10 | - Per-file records: disabled (set PIOLIUM_FILE_RECORDS=1 to enable) | ||
| 11 | |||
| 12 | ## Candidate Classes | ||
| 13 | |||
| 14 | - dynamic-code-execution: 8 match(es), max score 90. Dynamic code execution, expression evaluation, or runtime compilation. | ||
| 15 | - command-execution: 4 match(es), max score 80. Potential command execution or shell invocation with variable input. | ||
| 16 | - path-traversal-file-access: 7 match(es), max score 55. Filesystem access using path joins or user-controllable paths. | ||
| 17 | - hidden-control-channel: 6 match(es), max score 55. Request header or framework/proxy context read that may influence auth, routing, tenant, runtime, debug, or middleware behavior. | ||
| 18 | - unsafe-html-or-template: 1 match(es), max score 55. HTML injection sink or template escape bypass. | ||
| 19 | |||
| 20 | ## Top Files | ||
| 21 | |||
| 22 | - `static/htmx.min.js`: score 1800, 26 match(es) | ||
| 23 | |||
| 24 | ## Highest-Ranked Matches | ||
| 25 | |||
| 26 | - dynamic-code-execution (precise, score 90) at `static/htmx.min.js:1` - var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,add | ||
| 27 | - dynamic-code-execution (precise, score 90) at `static/htmx.min.js:1` - var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,add | ||
| 28 | - dynamic-code-execution (precise, score 90) at `static/htmx.min.js:1` - var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,add | ||
| 29 | - dynamic-code-execution (precise, score 90) at `static/htmx.min.js:1` - var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,add | ||
| 30 | - dynamic-code-execution (precise, score 90) at `static/htmx.min.js:1` - var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,add | ||
| 31 | - dynamic-code-execution (precise, score 90) at `static/htmx.min.js:1` - var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,add | ||
| 32 | - dynamic-code-execution (precise, score 90) at `static/htmx.min.js:1` - var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,add | ||
| 33 | - command-execution (precise, score 80) at `static/htmx.min.js:1` - var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,add | ||
| 34 | - command-execution (precise, score 80) at `static/htmx.min.js:1` - var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,add | ||
| 35 | - command-execution (precise, score 80) at `static/htmx.min.js:1` - var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,add | ||
| 36 | - command-execution (precise, score 80) at `static/htmx.min.js:1` - var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,add | ||
| 37 | - dynamic-code-execution (precise, score 80) at `static/htmx.min.js:1` - var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,add | ||
| 38 | - path-traversal-file-access (normal, score 55) at `static/htmx.min.js:1` - var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,add | ||
| 39 | - path-traversal-file-access (normal, score 55) at `static/htmx.min.js:1` - var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,add | ||
| 40 | - path-traversal-file-access (normal, score 55) at `static/htmx.min.js:1` - var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,add | ||
| 41 | - path-traversal-file-access (normal, score 55) at `static/htmx.min.js:1` - var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,add | ||
| 42 | - path-traversal-file-access (normal, score 55) at `static/htmx.min.js:1` - var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,add | ||
| 43 | - path-traversal-file-access (normal, score 55) at `static/htmx.min.js:1` - var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,add | ||
| 44 | - path-traversal-file-access (normal, score 55) at `static/htmx.min.js:1` - var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,add | ||
| 45 | - unsafe-html-or-template (normal, score 55) at `static/htmx.min.js:1` - var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,add | ||
| 46 | - hidden-control-channel (normal, score 55) at `static/htmx.min.js:1` - var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,add | ||
| 47 | - hidden-control-channel (normal, score 55) at `static/htmx.min.js:1` - var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,add | ||
| 48 | - hidden-control-channel (normal, score 55) at `static/htmx.min.js:1` - var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,add | ||
| 49 | - hidden-control-channel (normal, score 55) at `static/htmx.min.js:1` - var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,add | ||
| 50 | - hidden-control-channel (normal, score 55) at `static/htmx.min.js:1` - var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,add | ||
| 51 | - hidden-control-channel (normal, score 55) at `static/htmx.min.js:1` - var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,add | ||
| 52 | |||
| 53 | ## Custom Matchers | ||
| 54 | |||
| 55 | Project matchers can be added at `piolium/matchers.json`, `piolium/custom-matchers.json`, or `.piolium-matchers.json`. | ||
diff --git a/piolium/attack-surface/candidates.jsonl b/piolium/attack-surface/candidates.jsonl new file mode 100644 index 0000000..197a62f --- /dev/null +++ b/piolium/attack-surface/candidates.jsonl | |||
| @@ -0,0 +1,26 @@ | |||
| 1 | {"slug":"dynamic-code-execution","description":"Dynamic code execution, expression evaluation, or runtime compilation.","noise":"precise","filePath":"static/htmx.min.js","line":1,"snippet":"var htmx=function(){\"use strict\";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||\"post\");return n.values},remove:null,add","matchedPattern":"eval","score":90,"source":"builtin"} | ||
| 2 | {"slug":"dynamic-code-execution","description":"Dynamic code execution, expression evaluation, or runtime compilation.","noise":"precise","filePath":"static/htmx.min.js","line":1,"snippet":"var htmx=function(){\"use strict\";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||\"post\");return n.values},remove:null,add","matchedPattern":"python eval","score":90,"source":"builtin"} | ||
| 3 | {"slug":"dynamic-code-execution","description":"Dynamic code execution, expression evaluation, or runtime compilation.","noise":"precise","filePath":"static/htmx.min.js","line":1,"snippet":"var htmx=function(){\"use strict\";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||\"post\");return n.values},remove:null,add","matchedPattern":"python eval","score":90,"source":"builtin"} | ||
| 4 | {"slug":"dynamic-code-execution","description":"Dynamic code execution, expression evaluation, or runtime compilation.","noise":"precise","filePath":"static/htmx.min.js","line":1,"snippet":"var htmx=function(){\"use strict\";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||\"post\");return n.values},remove:null,add","matchedPattern":"python eval","score":90,"source":"builtin"} | ||
| 5 | {"slug":"dynamic-code-execution","description":"Dynamic code execution, expression evaluation, or runtime compilation.","noise":"precise","filePath":"static/htmx.min.js","line":1,"snippet":"var htmx=function(){\"use strict\";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||\"post\");return n.values},remove:null,add","matchedPattern":"python eval","score":90,"source":"builtin"} | ||
| 6 | {"slug":"dynamic-code-execution","description":"Dynamic code execution, expression evaluation, or runtime compilation.","noise":"precise","filePath":"static/htmx.min.js","line":1,"snippet":"var htmx=function(){\"use strict\";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||\"post\");return n.values},remove:null,add","matchedPattern":"python eval","score":90,"source":"builtin"} | ||
| 7 | {"slug":"dynamic-code-execution","description":"Dynamic code execution, expression evaluation, or runtime compilation.","noise":"precise","filePath":"static/htmx.min.js","line":1,"snippet":"var htmx=function(){\"use strict\";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||\"post\");return n.values},remove:null,add","matchedPattern":"ruby eval","score":90,"source":"builtin"} | ||
| 8 | {"slug":"command-execution","description":"Potential command execution or shell invocation with variable input.","noise":"precise","filePath":"static/htmx.min.js","line":1,"snippet":"var htmx=function(){\"use strict\";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||\"post\");return n.values},remove:null,add","matchedPattern":"node child_process","score":80,"source":"builtin"} | ||
| 9 | {"slug":"command-execution","description":"Potential command execution or shell invocation with variable input.","noise":"precise","filePath":"static/htmx.min.js","line":1,"snippet":"var htmx=function(){\"use strict\";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||\"post\");return n.values},remove:null,add","matchedPattern":"node child_process","score":80,"source":"builtin"} | ||
| 10 | {"slug":"command-execution","description":"Potential command execution or shell invocation with variable input.","noise":"precise","filePath":"static/htmx.min.js","line":1,"snippet":"var htmx=function(){\"use strict\";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||\"post\");return n.values},remove:null,add","matchedPattern":"node child_process","score":80,"source":"builtin"} | ||
| 11 | {"slug":"command-execution","description":"Potential command execution or shell invocation with variable input.","noise":"precise","filePath":"static/htmx.min.js","line":1,"snippet":"var htmx=function(){\"use strict\";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||\"post\");return n.values},remove:null,add","matchedPattern":"node child_process","score":80,"source":"builtin"} | ||
| 12 | {"slug":"dynamic-code-execution","description":"Dynamic code execution, expression evaluation, or runtime compilation.","noise":"precise","filePath":"static/htmx.min.js","line":1,"snippet":"var htmx=function(){\"use strict\";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||\"post\");return n.values},remove:null,add","matchedPattern":"function constructor","score":80,"source":"builtin"} | ||
| 13 | {"slug":"path-traversal-file-access","description":"Filesystem access using path joins or user-controllable paths.","noise":"normal","filePath":"static/htmx.min.js","line":1,"snippet":"var htmx=function(){\"use strict\";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||\"post\");return n.values},remove:null,add","matchedPattern":"path join","score":55,"source":"builtin"} | ||
| 14 | {"slug":"path-traversal-file-access","description":"Filesystem access using path joins or user-controllable paths.","noise":"normal","filePath":"static/htmx.min.js","line":1,"snippet":"var htmx=function(){\"use strict\";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||\"post\");return n.values},remove:null,add","matchedPattern":"path join","score":55,"source":"builtin"} | ||
| 15 | {"slug":"path-traversal-file-access","description":"Filesystem access using path joins or user-controllable paths.","noise":"normal","filePath":"static/htmx.min.js","line":1,"snippet":"var htmx=function(){\"use strict\";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||\"post\");return n.values},remove:null,add","matchedPattern":"path join","score":55,"source":"builtin"} | ||
| 16 | {"slug":"path-traversal-file-access","description":"Filesystem access using path joins or user-controllable paths.","noise":"normal","filePath":"static/htmx.min.js","line":1,"snippet":"var htmx=function(){\"use strict\";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||\"post\");return n.values},remove:null,add","matchedPattern":"path join","score":55,"source":"builtin"} | ||
| 17 | {"slug":"path-traversal-file-access","description":"Filesystem access using path joins or user-controllable paths.","noise":"normal","filePath":"static/htmx.min.js","line":1,"snippet":"var htmx=function(){\"use strict\";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||\"post\");return n.values},remove:null,add","matchedPattern":"path join","score":55,"source":"builtin"} | ||
| 18 | {"slug":"path-traversal-file-access","description":"Filesystem access using path joins or user-controllable paths.","noise":"normal","filePath":"static/htmx.min.js","line":1,"snippet":"var htmx=function(){\"use strict\";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||\"post\");return n.values},remove:null,add","matchedPattern":"python file open","score":55,"source":"builtin"} | ||
| 19 | {"slug":"path-traversal-file-access","description":"Filesystem access using path joins or user-controllable paths.","noise":"normal","filePath":"static/htmx.min.js","line":1,"snippet":"var htmx=function(){\"use strict\";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||\"post\");return n.values},remove:null,add","matchedPattern":"python file open","score":55,"source":"builtin"} | ||
| 20 | {"slug":"unsafe-html-or-template","description":"HTML injection sink or template escape bypass.","noise":"normal","filePath":"static/htmx.min.js","line":1,"snippet":"var htmx=function(){\"use strict\";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||\"post\");return n.values},remove:null,add","matchedPattern":"dangerous html","score":55,"source":"builtin"} | ||
| 21 | {"slug":"hidden-control-channel","description":"Request header or framework/proxy context read that may influence auth, routing, tenant, runtime, debug, or middleware behavior.","noise":"normal","filePath":"static/htmx.min.js","line":1,"snippet":"var htmx=function(){\"use strict\";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||\"post\");return n.values},remove:null,add","matchedPattern":"request header read","score":55,"source":"builtin"} | ||
| 22 | {"slug":"hidden-control-channel","description":"Request header or framework/proxy context read that may influence auth, routing, tenant, runtime, debug, or middleware behavior.","noise":"normal","filePath":"static/htmx.min.js","line":1,"snippet":"var htmx=function(){\"use strict\";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||\"post\");return n.values},remove:null,add","matchedPattern":"proxy or original request header","score":55,"source":"builtin"} | ||
| 23 | {"slug":"hidden-control-channel","description":"Request header or framework/proxy context read that may influence auth, routing, tenant, runtime, debug, or middleware behavior.","noise":"normal","filePath":"static/htmx.min.js","line":1,"snippet":"var htmx=function(){\"use strict\";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||\"post\");return n.values},remove:null,add","matchedPattern":"proxy or original request header","score":55,"source":"builtin"} | ||
| 24 | {"slug":"hidden-control-channel","description":"Request header or framework/proxy context read that may influence auth, routing, tenant, runtime, debug, or middleware behavior.","noise":"normal","filePath":"static/htmx.min.js","line":1,"snippet":"var htmx=function(){\"use strict\";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||\"post\");return n.values},remove:null,add","matchedPattern":"proxy or original request header","score":55,"source":"builtin"} | ||
| 25 | {"slug":"hidden-control-channel","description":"Request header or framework/proxy context read that may influence auth, routing, tenant, runtime, debug, or middleware behavior.","noise":"normal","filePath":"static/htmx.min.js","line":1,"snippet":"var htmx=function(){\"use strict\";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||\"post\");return n.values},remove:null,add","matchedPattern":"proxy or original request header","score":55,"source":"builtin"} | ||
| 26 | {"slug":"hidden-control-channel","description":"Request header or framework/proxy context read that may influence auth, routing, tenant, runtime, debug, or middleware behavior.","noise":"normal","filePath":"static/htmx.min.js","line":1,"snippet":"var htmx=function(){\"use strict\";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||\"post\");return n.values},remove:null,add","matchedPattern":"proxy or original request header","score":55,"source":"builtin"} | ||
diff --git a/piolium/attack-surface/cross-service-edges.json b/piolium/attack-surface/cross-service-edges.json new file mode 100644 index 0000000..c424625 --- /dev/null +++ b/piolium/attack-surface/cross-service-edges.json | |||
| @@ -0,0 +1 @@ | |||
| {"single_service": true} \ No newline at end of file | |||
diff --git a/piolium/attack-surface/deep-cleanup-summary.json b/piolium/attack-surface/deep-cleanup-summary.json new file mode 100644 index 0000000..5242495 --- /dev/null +++ b/piolium/attack-surface/deep-cleanup-summary.json | |||
| @@ -0,0 +1,34 @@ | |||
| 1 | { | ||
| 2 | "summaryPath": "piolium/attack-surface/deep-cleanup-summary.json", | ||
| 3 | "removed": [ | ||
| 4 | "piolium/tmp", | ||
| 5 | "piolium/chamber-workspace", | ||
| 6 | "piolium/adversarial-reviews", | ||
| 7 | "piolium/bypass-analysis", | ||
| 8 | "piolium/codeql-artifacts", | ||
| 9 | "piolium/codeql-queries", | ||
| 10 | "piolium/semgrep-rules", | ||
| 11 | "piolium/confirm-workspace", | ||
| 12 | "piolium/findings-draft", | ||
| 13 | "piolium/attack-pattern-registry.json" | ||
| 14 | ], | ||
| 15 | "missing": [ | ||
| 16 | "piolium/probe-workspace", | ||
| 17 | "piolium/agentic-actions-res", | ||
| 18 | "piolium/codeql-res", | ||
| 19 | "piolium/semgrep-res", | ||
| 20 | "piolium/real-env-evidence", | ||
| 21 | "piolium/raw", | ||
| 22 | "piolium/file-records", | ||
| 23 | "piolium/attack-surface/raw", | ||
| 24 | "piolium/authz-coverage-gaps.md", | ||
| 25 | "piolium/merged-results.sarif" | ||
| 26 | ], | ||
| 27 | "retained": [ | ||
| 28 | "piolium/attack-surface/", | ||
| 29 | "piolium/findings/", | ||
| 30 | "piolium/final-audit-report.md", | ||
| 31 | "piolium/confirmation-report.md", | ||
| 32 | "piolium/audit-state.json" | ||
| 33 | ] | ||
| 34 | } | ||
diff --git a/piolium/attack-surface/deep-probe-summary.md b/piolium/attack-surface/deep-probe-summary.md new file mode 100644 index 0000000..b3fb8df --- /dev/null +++ b/piolium/attack-surface/deep-probe-summary.md | |||
| @@ -0,0 +1,164 @@ | |||
| 1 | # Deep Probe Summary: salmon/minecraft-ui | ||
| 2 | |||
| 3 | **Generated:** 2025-07-29 | ||
| 4 | **Phase:** P8 — Manual Attack Surface Probe | ||
| 5 | **Status:** Complete | ||
| 6 | **Loops:** 1 | ||
| 7 | **Total findings:** 10 | ||
| 8 | **CRITICAL:** 1 (P8-003) | ||
| 9 | **HIGH:** 5 (P8-001, P8-002, P8-004, P8-006, P8-009) | ||
| 10 | **MEDIUM:** 3 (P8-005, P8-007, P8-010) | ||
| 11 | **LOW:** 1 (P8-008) | ||
| 12 | |||
| 13 | --- | ||
| 14 | |||
| 15 | ## Probe Methodology | ||
| 16 | |||
| 17 | This manual deep probe conducted: | ||
| 18 | |||
| 19 | 1. **Full source code review** of all 16 Rust source files (1,540+ lines analyzed) | ||
| 20 | 2. **Attack surface inventory** — 38 HTTP routes catalogued with entry points, sinks, and trust boundaries | ||
| 21 | 3. **Backward reasoning** — trace from sinks (file write, RCON command, Docker env, SQL) back to attacker inputs | ||
| 22 | 4. **Contradiction reasoning** — identify gaps between intended security boundaries and actual code | ||
| 23 | 5. **Cross-model analysis** — chain multiple low-severity issues into higher-severity attack paths | ||
| 24 | 6. **Verification** — all findings verified with file:line code evidence via read/grep | ||
| 25 | |||
| 26 | --- | ||
| 27 | |||
| 28 | ## Finding Summary | ||
| 29 | |||
| 30 | | ID | Title | Severity | Category | Attack Vector | Existing Draft | | ||
| 31 | |----|-------|----------|----------|---------------|---------------| | ||
| 32 | | P8-001 | MOTD Docker Environment Injection | **HIGH** | CWE-77, CWE-93 | Newline injection in MOTD overrides Docker container env vars | Enhanced from p4-016 | | ||
| 33 | | P8-002 | RCON Command Injection | **HIGH** | CWE-77, CWE-88 | Unsanitized username in RCON command strings | Updated p4-002 | | ||
| 34 | | P8-003 | Instance Name Path Traversal Chain | **CRITICAL** | CWE-22 | `..` in instance name enables arbitrary file write/read/delete | Expanded p4-001 | | ||
| 35 | | P8-004 | Level-Seed Properties Injection | **HIGH** | CWE-93, CWE-88 | Seed with newlines injects arbitrary Java properties | Enhanced p4-020 | | ||
| 36 | | P8-005 | No Server-Side Session Expiration | **MEDIUM** | CWE-613 | Stolen session cookies never expire | Confirmed p4-004 | | ||
| 37 | | P8-006 | Missing CSRF Protection | **HIGH** | CWE-352 | All POST endpoints vulnerable to cross-site request forgery | Confirmed p4-003 | | ||
| 38 | | P8-007 | POST /logout Missing Auth Guard | **MEDIUM** | CWE-306 | Logout handler not behind require_auth middleware | Confirmed p4-008 | | ||
| 39 | | P8-008 | Weak RCON Password Generation | **LOW** | CWE-338 | thread_rng() used instead of OsRng | Confirmed p4-007 | | ||
| 40 | | P8-009 | RCON Password Exposed to All Users | **HIGH** | CWE-200 | rcon_password in edit form for any authenticated user | Confirmed p4-006 | | ||
| 41 | | P8-010 | Mod JAR Validation Denial of Service | **MEDIUM** | CWE-770, CWE-674 | Crafted JAR constant pool, no timeout, no recursion limit | Enhanced p4-014 | | ||
| 42 | |||
| 43 | --- | ||
| 44 | |||
| 45 | ## Key Findings — Detailed Analysis | ||
| 46 | |||
| 47 | ### P8-003: Instance Name Path Traversal Chain (CRITICAL) | ||
| 48 | |||
| 49 | **The most severe finding.** The instance `name` field is used in 8 different filesystem operations without any sanitization. Rust's `Path::join()` follows `..` components, enabling: | ||
| 50 | |||
| 51 | - **Arbitrary directory creation** via `create_dir_all` at instance creation | ||
| 52 | - **Arbitrary file write** via mod upload (`fs::write`) and world import (`File::create`) | ||
| 53 | - **Arbitrary directory deletion** via `remove_dir_all` at instance deletion | ||
| 54 | - **Arbitrary file read** via world export and backup download | ||
| 55 | |||
| 56 | The full attack chain: | ||
| 57 | 1. Create instance with `name=../../../tmp/pwned` | ||
| 58 | 2. Upload mod/world to write files at `/tmp/pwned/data/mods/evil.so` | ||
| 59 | 3. Delete instance to remove `/tmp/pwned` directory tree | ||
| 60 | |||
| 61 | **Code path:** `src/routes/instances.rs:205` — `Path::join(&form.name)` with no validation → `std::fs::create_dir_all()` | ||
| 62 | |||
| 63 | ### P8-001: MOTD Docker Environment Injection (HIGH) | ||
| 64 | |||
| 65 | The MOTD field is passed directly into Docker container environment variables without newline escaping. Docker treats `\n` in env values as variable delimiters, allowing injection of arbitrary env vars. | ||
| 66 | |||
| 67 | **Code path:** `src/docker.rs:128` — `format!("MOTD={}", instance.motd)` → Docker container env → injected vars override security settings like `EULA`, `ONLINE_MODE`. | ||
| 68 | |||
| 69 | ### P8-002: RCON Command Injection (HIGH) | ||
| 70 | |||
| 71 | The `player_action!` macro constructs RCON commands via `format!($command, form.username)` with no sanitization. Spaces, semicolons, and special characters in usernames can inject arbitrary RCON commands. | ||
| 72 | |||
| 73 | **Code path:** `src/routes/players.rs:97` — `format!("op {}", form.username)` → `client.send_command_async(cmd)` → Minecraft server console. | ||
| 74 | |||
| 75 | ### P8-006: Missing CSRF Protection (HIGH) | ||
| 76 | |||
| 77 | All 31 POST endpoints lack CSRF tokens. The session cookie uses `SameSite=Lax` which allows cross-site top-level POST navigations. Combined with other findings (path traversal, RCON injection, instance deletion), CSRF enables fully weaponized cross-site attacks. | ||
| 78 | |||
| 79 | ### P8-009: RCON Password Exposure (HIGH) | ||
| 80 | |||
| 81 | The `GET /instances/{id}/edit` endpoint returns `rcon_password` for any instance to any authenticated user. No authorization check beyond `require_auth`. | ||
| 82 | |||
| 83 | **Code path:** `src/routes/instances.rs:313` — `rcon_password: row.get(15)?` — rendered in `EditInstanceTemplate`. | ||
| 84 | |||
| 85 | ### P8-004: Level-Seed Properties Injection (HIGH) | ||
| 86 | |||
| 87 | The `seed` field in world regeneration is written to `server.properties` without escaping. Newlines and special characters inject arbitrary Java `.properties` entries. | ||
| 88 | |||
| 89 | **Code path:** `src/routes/instances.rs:397` — `format!("level-seed={seed}")` → `std::fs::write(&props_path)`. | ||
| 90 | |||
| 91 | --- | ||
| 92 | |||
| 93 | ## Cross-Model Attack Chains | ||
| 94 | |||
| 95 | ### Chain 1: CSRF + Path Traversal for Full System Compromise | ||
| 96 | 1. CSRF (P8-006) tricks authenticated admin to create instance with traversed name | ||
| 97 | 2. Path traversal (P8-003) enables writing arbitrary files outside data directory | ||
| 98 | 3. Combined impact: full file system write without user interaction | ||
| 99 | |||
| 100 | ### Chain 2: RCON Password Exposure + Command Injection | ||
| 101 | 1. Any authenticated user views RCON password (P8-009) | ||
| 102 | 2. Same user exploits RCON command injection (P8-002) for server compromise | ||
| 103 | 3. Combined impact: horizontal privilege escalation to Minecraft server operator | ||
| 104 | |||
| 105 | ### Chain 3: Session Leak + No TTL | ||
| 106 | 1. Session cookie leaked through any channel (logs, DB backup, etc.) | ||
| 107 | 2. No server-side TTL (P8-005) means session never expires | ||
| 108 | 3. POST /logout (P8-007) allows anonymous invalidation of leaked session | ||
| 109 | 4. Combined impact: indefinite session hijacking window | ||
| 110 | |||
| 111 | --- | ||
| 112 | |||
| 113 | ## Coverage Summary | ||
| 114 | |||
| 115 | | Entry Point | Finding Coverage | Notes | | ||
| 116 | |------------|-----------------|-------| | ||
| 117 | | POST /login | P8-006 (CSRF) | No rate limiting finding also relevant | | ||
| 118 | | POST /logout | P8-006 (CSRF), P8-007 (Missing Auth) | Not behind require_auth | | ||
| 119 | | GET /static/* | None | Safe — static files only | | ||
| 120 | | POST /instances/new | P8-003 (Path Traversal), P8-006 (CSRF) | CRITICAL chain | | ||
| 121 | | POST /instances/{id}/edit | P8-001 (MOTD injection), P8-006 (CSRF) | Docker env injection path | | ||
| 122 | | POST /instances/{id}/delete | P8-003 (Path Traversal), P8-006 (CSRF) | Directory deletion | | ||
| 123 | | POST /instances/{id}/activate | P8-001 (MOTD injection chain) | Docker container start | | ||
| 124 | | POST /instances/{id}/world/regenerate | P8-004 (Properties Injection), P8-006 (CSRF) | server.properties injection | | ||
| 125 | | GET /instances/{id}/edit | P8-009 (RCON password exposure) | Sensitive data leak | | ||
| 126 | | POST /players/*/add, /players/*/remove | P8-002 (RCON command injection), P8-006 (CSRF) | 6 endpoints | | ||
| 127 | | POST /instances/{id}/mods | P8-003 (Path traversal via mod name), P8-010 (DoS), P8-006 (CSRF) | File write + DoS | | ||
| 128 | | POST /instances/{id}/mods/{mid}/delete | P8-003 (Path traversal), P8-006 (CSRF) | File delete | | ||
| 129 | | POST /instances/{id}/mods/rescan | P8-010 (DoS), P8-006 (CSRF) | File read + validation | | ||
| 130 | | GET /instances/{id}/world/export | P8-003 (Path traversal read) | File read | | ||
| 131 | | POST /instances/{id}/world/import | P8-003 (Path traversal write), P8-006 (CSRF) | File write (ZIP) | | ||
| 132 | | GET /backups/{id}/download | P8-003 (Path traversal read) | File read | | ||
| 133 | | POST /instances/{id}/backups/create | P8-006 (CSRF) | File write | | ||
| 134 | | GET /accounts, POST /accounts/* | P8-006 (CSRF) | Superuser operations | | ||
| 135 | | POST /change-password | P8-006 (CSRF) | Password change via CSRF | | ||
| 136 | | All authenticated routes | P8-005 (No session TTL) | Persistent session hijacking | | ||
| 137 | | Session management | P8-005, P8-007 | No TTL, logout without auth | | ||
| 138 | |||
| 139 | --- | ||
| 140 | |||
| 141 | ## Missing Coverage / Gaps | ||
| 142 | |||
| 143 | 1. **No rate limiting** — `POST /login` has no brute-force protection (existing p4-005, not re-drafted here) | ||
| 144 | 2. **No CSRF on any POST** — universal issue (P8-006) | ||
| 145 | 3. **Flat authorization model** — any authenticated user can access any instance (by-design for single-user/small-team) | ||
| 146 | 4. **No audit logging** — no record of state-changing operations (existing p4-017) | ||
| 147 | 5. **No encryption at rest** — SQLite database unencrypted on disk (existing p4-021) | ||
| 148 | 6. **No backup restore functionality** — `restore_backup` is a stub (existing p4-024) | ||
| 149 | 7. **Cookie without Secure flag** — missing when behind HTTPS proxy (existing p4-009) | ||
| 150 | |||
| 151 | --- | ||
| 152 | |||
| 153 | ## Recommendations (Priority Order) | ||
| 154 | |||
| 155 | 1. **CRITICAL: Sanitize instance name** — validate against `^[a-zA-Z0-9_-]+$` before filesystem use | ||
| 156 | 2. **HIGH: Strip newlines from Docker env vars** — sanitize MOTD and all user-controlled fields before Docker env injection | ||
| 157 | 3. **HIGH: Validate RCON usernames** — restrict to `[a-zA-Z0-9_]` (3-16 chars) before command construction | ||
| 158 | 4. **HIGH: Add CSRF protection** — SameSite=Strict cookie + CSRF tokens or origin validation | ||
| 159 | 5. **HIGH: Remove RCON password from edit form** — expose only to superusers | ||
| 160 | 6. **HIGH: Escape seed value in server.properties** — reject newlines and control characters | ||
| 161 | 7. **MEDIUM: Add server-side session TTL** — filter sessions by `created_at > datetime('now', '-1 day')` | ||
| 162 | 8. **MEDIUM: Move logout behind require_auth** — defense-in-depth | ||
| 163 | 9. **MEDIUM: Add timeout to mod validation** — prevent DoS via crafted JARs | ||
| 164 | 10. **LOW: Use OsRng for RCON password** — cryptographic PRNG for secrets | ||
diff --git a/piolium/attack-surface/knowledge-base-report.md b/piolium/attack-surface/knowledge-base-report.md new file mode 100644 index 0000000..6a0b502 --- /dev/null +++ b/piolium/attack-surface/knowledge-base-report.md | |||
| @@ -0,0 +1,597 @@ | |||
| 1 | # Phase 3 Knowledge Base Report | ||
| 2 | |||
| 3 | **Target:** salmon/minecraft-ui | ||
| 4 | **Generated:** 2025-07-29 | ||
| 5 | **Phase:** P3 (Architecture & Threat Model) | ||
| 6 | **Depth:** deep | ||
| 7 | |||
| 8 | --- | ||
| 9 | |||
| 10 | ## Project Classification | ||
| 11 | |||
| 12 | **Type:** Web App (self-hosted, single-user/small-team Minecraft server manager) | ||
| 13 | |||
| 14 | **Subtypes:** | ||
| 15 | - HTTP server (axum, localhost-bound) | ||
| 16 | - Docker orchestrator (bollard → Docker daemon → itzg/minecraft-server containers) | ||
| 17 | - File upload handler (multipart mod uploads, world ZIP imports) | ||
| 18 | - Database-backed CRUD app (SQLite via rusqlite) | ||
| 19 | - RCON client (TCP Minecraft Remote Console protocol) | ||
| 20 | |||
| 21 | **Deployment model:** Single-process server on a Linux host with Docker daemon. | ||
| 22 | Binds to `127.0.0.1:<port>` — expected to sit behind a reverse proxy (nginx). | ||
| 23 | |||
| 24 | --- | ||
| 25 | |||
| 26 | ## Architecture Model | ||
| 27 | |||
| 28 | ### Component Inventory | ||
| 29 | |||
| 30 | | Component | Technology | Role | Trust Boundary | | ||
| 31 | |-----------|-----------|------|----------------| | ||
| 32 | | **WebUI Server** | Rust, axum 0.8.9, tokio | HTTP request handler, template rendering | Listens on localhost; proxy-facing | | ||
| 33 | | **Auth Layer** | Custom session tokens, Argon2id | Authentication + session management | Unauthenticated→authenticated boundary | | ||
| 34 | | **Database** | SQLite (rusqlite 0.40.1 bundled) | Accounts, sessions, instances, mods, backups | Single process — no network exposure | | ||
| 35 | | **Docker Manager** | bollard 0.17 → Docker daemon | Container lifecycle, image pull, port binding | Local Unix socket — host-OS trust | | ||
| 36 | | **RCON Client** | Custom TCP client (127.0.0.1:25575) | Send commands to Minecraft server | Local network — Docker container boundary | | ||
| 37 | | **Mod Validator** | Custom JAR parser | Forge/Fabric metadata analysis, class-file scan | Accept/reject before write to disk | | ||
| 38 | | **Backup Engine** | ZIP archives via `zip` crate | World/instance snapshots and restore | Filesystem write to server_directory | | ||
| 39 | | **Mojang API Client** | reqwest → `piston-meta.mojang.com` | Fetch Minecraft version manifest | Outbound HTTPS to external API | | ||
| 40 | | **Static File Server** | tower-http ServeDir | Serve `/static/` (CSS, JS) | Public — no auth | | ||
| 41 | | **Docker Container** | itzg/minecraft-server (java8/17/21) | Minecraft server runtime | RCON boundary, network port 25565/25575 | | ||
| 42 | | **Config** | TOML file (config.toml) | Server directory, port, max memory, initial superuser | Filesystem — read at startup | | ||
| 43 | |||
| 44 | ### Data Transports | ||
| 45 | |||
| 46 | 1. **HTTP** (localhost:3000) — all web traffic | ||
| 47 | 2. **Docker Unix Socket** (`/var/run/docker.sock`) — container management | ||
| 48 | 3. **TCP** (localhost:25575) — RCON protocol to Minecraft container | ||
| 49 | 4. **TCP** (0.0.0.0:25565) — Minecraft client connections (managed by container, not webUI) | ||
| 50 | 5. **HTTPS** (outbound to Mojang API) — version manifest fetch | ||
| 51 | |||
| 52 | ### Trust Boundaries | ||
| 53 | |||
| 54 | ``` | ||
| 55 | HTTP Client (attacker) ──[localhost:3000]──▶ WebUI Server | ||
| 56 | │ | ||
| 57 | ┌───── auth layer ─────┐ | ||
| 58 | ▼ ▼ | ||
| 59 | Authenticated Unauthenticated routes | ||
| 60 | routes (auth'd) (/login, /logout, /static/*) | ||
| 61 | │ | ||
| 62 | ┌──────────┼──────────┐ | ||
| 63 | ▼ ▼ ▼ | ||
| 64 | SQLite DB Docker daemon RCON → Minecraft | ||
| 65 | (mcui.db) (/var/run/docker.sock) (127.0.0.1:25575) | ||
| 66 | ``` | ||
| 67 | |||
| 68 | **Key trust boundaries:** | ||
| 69 | 1. **Public→Protected routes:** session cookie check via `require_auth` middleware (src/auth.rs:55) | ||
| 70 | 2. **WebUI→Docker:** local Unix socket — any code running as the webUI user can control containers | ||
| 71 | 3. **WebUI→RCON:** localhost TCP — password-protected, but breakable if rcon_password exposed | ||
| 72 | 4. **WebUI→Database:** same-process SQLite — no isolation between auth and data ops | ||
| 73 | 5. **Mod upload→Filesystem:** validated JAR → instance mods directory (path traversal risk) | ||
| 74 | 6. **World import→Filesystem:** ZIP extraction → world directory (path traversal risk) | ||
| 75 | |||
| 76 | --- | ||
| 77 | |||
| 78 | ## DFD/CFD Slices | ||
| 79 | |||
| 80 | ### DFD Slice 1: Unauthenticated Login Flow | ||
| 81 | |||
| 82 | ``` | ||
| 83 | ┌─────────┐ POST /login ┌──────────────┐ SELECT ┌─────────┐ | ||
| 84 | │Attacker │ username, │ WebUI Auth │────────▶ │ SQLite │ | ||
| 85 | │(browser)│ password │ (auth.rs) │ │accounts │ | ||
| 86 | └─────────┘ │ │◀──────── │ table │ | ||
| 87 | │ │ verify_hash │ password └─────────┘ | ||
| 88 | │ └──────┬───────┘ | ||
| 89 | │ │ INSERT session | ||
| 90 | │ ┌──────▼───────┐ | ||
| 91 | │ │ SQLite │ | ||
| 92 | └── Set-Cookie ─────▶│ sessions │ | ||
| 93 | │ table │ | ||
| 94 | └──────────────┘ | ||
| 95 | ``` | ||
| 96 | **Security decisions:** password hashing (Argon2id), session ID generation (random UUID v4), cookie flags (HttpOnly, SameSite=Lax, Path=/, Max-Age=86400). | ||
| 97 | |||
| 98 | ### DFD Slice 2: Mod Upload (Highest Risk) | ||
| 99 | |||
| 100 | ``` | ||
| 101 | ┌─────────┐ POST /instances/{id}/mods ┌──────────────┐ | ||
| 102 | │Attacker │ multipart/form-data │ WebUI │ | ||
| 103 | │(auth'd) │ .jar / .zip file │ routes/mods │ | ||
| 104 | └─────────┘ │ .rs │ | ||
| 105 | │ └──────┬───────┘ | ||
| 106 | │ multer parse (500MB limit) │ | ||
| 107 | │ zip extraction (for .zip) │ | ||
| 108 | │ ▼ | ||
| 109 | │ ┌──────────────┐ | ||
| 110 | │ │ ModValidator │ | ||
| 111 | │ │ (src/mods.rs)│ | ||
| 112 | │ │ • forge meta │ | ||
| 113 | │ │ • fabric meta │ | ||
| 114 | │ │ • class scan │ | ||
| 115 | │ └──────┬───────┘ | ||
| 116 | │ accepted=True │ | ||
| 117 | │ ┌───────────────▼────────┐ | ||
| 118 | │ │ Filesystem: │ | ||
| 119 | │ │ .../mods/<filename> │ | ||
| 120 | │ │ SQLite: mods table │ | ||
| 121 | │ └───────────────────────┘ | ||
| 122 | ``` | ||
| 123 | **Security decisions:** file extension check (`.jar`/`.zip`), metadata validation, class-file scanning. **No path traversal sanitization** on filename. | ||
| 124 | |||
| 125 | ### DFD Slice 3: World Import (Zip Slip Vector) | ||
| 126 | |||
| 127 | ``` | ||
| 128 | Attacker ──▶ POST /instances/{id}/world/import ──▶ Multipart ZIP upload | ||
| 129 | │ | ||
| 130 | zip::ZipArchive::new() | ||
| 131 | │ | ||
| 132 | for i in 0..archive.len() { | ||
| 133 | file.enclosed_name() | ||
| 134 | │ | ||
| 135 | ┌──────▼────────┐ | ||
| 136 | │ out_path = │ | ||
| 137 | │ world_dir │ | ||
| 138 | │ .join(path) │ | ||
| 139 | └──────┬────────┘ | ||
| 140 | │ | ||
| 141 | std::fs::create_dir_all(out_path) | ||
| 142 | std::fs::File::create(out_path) | ||
| 143 | ``` | ||
| 144 | **Vulnerability:** `file.enclosed_name()` is used (zip crate's path sanitization), but if the zip entry name starts with `../` and `enclosed_name()` returns `None`, the entry is skipped with `continue`. This mitigates zip slip for standard zip entries — but other path normalization issues could still exist. | ||
| 145 | |||
| 146 | ### CFD Slice 1: Auth Middleware Chain | ||
| 147 | |||
| 148 | ``` | ||
| 149 | Request ──▶ Router | ||
| 150 | │ | ||
| 151 | ├── GET /login ──▶ login_page() [NO AUTH] | ||
| 152 | ├── POST /login ──▶ authenticate() [NO AUTH] | ||
| 153 | ├── POST /logout ──▶ logout() [NO AUTH middleware — relies on cookie parsing] | ||
| 154 | │ | ||
| 155 | ├── /static/* ──▶ ServeDir [NO AUTH] | ||
| 156 | │ | ||
| 157 | └── Protected Router ──▶ require_auth middleware | ||
| 158 | │ │ | ||
| 159 | │ ┌──────▼──────┐ | ||
| 160 | │ │ Parse Cookie │ | ||
| 161 | │ │ header │ | ||
| 162 | │ │ mcui_session │ | ||
| 163 | │ └──────┬───────┘ | ||
| 164 | │ │ | ||
| 165 | │ ┌──────▼───────┐ | ||
| 166 | │ │ Lookup in │ | ||
| 167 | │ │ sessions │ | ||
| 168 | │ │ table │ | ||
| 169 | │ └──────┬───────┘ | ||
| 170 | │ Found? │ Not found? | ||
| 171 | │ ┌──▼──┐ ┌──▼───┐ | ||
| 172 | │ │ OK │ │Redirect│ | ||
| 173 | │ │next()│ │/login │ | ||
| 174 | │ └─────┘ └───────┘ | ||
| 175 | │ | ||
| 176 | ├── / → dashboard | ||
| 177 | ├── /instances/* → instance CRUD | ||
| 178 | ├── /players/* → whitelist/op/ban | ||
| 179 | ├── /accounts/* → account management [superuser only] | ||
| 180 | ├── /backups/* → backup operations | ||
| 181 | └── /change-password → password change | ||
| 182 | ``` | ||
| 183 | |||
| 184 | --- | ||
| 185 | |||
| 186 | ## Attack Surface | ||
| 187 | |||
| 188 | ### Attacker-Controlled Input Sources | ||
| 189 | |||
| 190 | | Source | Entry point | Max size / rate | Risk | | ||
| 191 | |--------|------------|-----------------|------| | ||
| 192 | | `POST /login` form | `username`, `password` strings | ~4KB per field | Low — auth only | | ||
| 193 | | `POST /instances/new` form | `name`, `version`, `seed`, `motd` etc. | Form fields | Medium — SQLite INSERT | | ||
| 194 | | `POST /instances/{id}/edit` form | `difficulty`, `gamemode`, `motd` etc. | Form fields | Medium — SQLite UPDATE | | ||
| 195 | | `POST /instances/{id}/mods` multipart | `.jar`/`.zip` file bytes, filename | 500MB per field | **High** — file write, class parsing | | ||
| 196 | | `POST /instances/{id}/world/import` multipart | `.zip` file bytes | Multipart-limited | **High** — zip extraction | | ||
| 197 | | `POST /players/*` forms | usernames via RCON | ~16 chars | Medium — RCON injection | | ||
| 198 | | `GET /instances/{id}/world/export` | instance ID from path | N/A | Medium — file read | | ||
| 199 | | `GET /backups/{id}/download` | backup ID from path | N/A | Medium — file read | | ||
| 200 | | `POST /accounts/create` | `username`, `password` | form fields | Medium — superuser-only | | ||
| 201 | |||
| 202 | ### Execution Environments | ||
| 203 | |||
| 204 | 1. **WebUI Rust process** — local user, Docker socket access, SQLite access, filesystem write | ||
| 205 | 2. **Docker container** — itzg/minecraft-server, Java process, local network, RCON port | ||
| 206 | 3. **Outbound HTTP** — reqwest to Mojang API (no user-controlled URL at present) | ||
| 207 | |||
| 208 | --- | ||
| 209 | |||
| 210 | ## Key Dependencies | ||
| 211 | |||
| 212 | | Dependency | Version | Security role | Known CVEs | Reachability | | ||
| 213 | |-----------|---------|---------------|------------|-------------| | ||
| 214 | | **axum** | 0.8.9 | HTTP framework, routing, extractors | None recent | Every handler | | ||
| 215 | | **argon2** | 0.5.3 | Password hashing (Argon2id) | None — well-vetted | Auth flow only | | ||
| 216 | | **rusqlite** | 0.40.1 (bundled) | SQLite — all persistent data | SQLite bundled, no network | Every DB operation | | ||
| 217 | | **bollard** | 0.17.1 | Docker Engine API | Low (Docker socket) | Container ops | | ||
| 218 | | **zip** | 8.6.0 | ZIP archive parsing/writing | Zip slip in some versions — 8.6.0 has `enclosed_name()` | Mod upload, world import, backups | | ||
| 219 | | **multer** | 3.1.0 | Multipart/form-data parser | None recent | Mod upload only | | ||
| 220 | | **reqwest** | 0.13.4 | HTTP client to Mojang API | None recent | Version manifest fetch | | ||
| 221 | | **cookie** | 0.18.1 | Cookie parsing | None recent | Auth middleware | | ||
| 222 | | **axum-extra** | 0.12.6 | Cookie private jar | Not used for signing — feature listed but unused in code | — | | ||
| 223 | | **tokio** | 1.52.3 | Async runtime | None recent | Every async operation | | ||
| 224 | | **rand** | 0.8.7 | RCON password generation | Std RNG, not crypto-grade | RCON password | | ||
| 225 | | **tower-http** | 0.7.0 | Static file serving | None recent | `/static/*` | | ||
| 226 | |||
| 227 | **Security-relevant observations:** | ||
| 228 | - `rand::thread_rng()` (not `OsRng`) used for RCON password generation — not cryptographic-grade | ||
| 229 | - `axum-extra` `cookie-private` feature listed but unused — potential dead feature | ||
| 230 | - `zip` 8.6.0 uses `enclosed_name()` which requires explicit check — it IS checked in world import | ||
| 231 | |||
| 232 | --- | ||
| 233 | |||
| 234 | ## Framework Contracts and Hidden Control Channels | ||
| 235 | |||
| 236 | ### Axum Framework Contracts | ||
| 237 | |||
| 238 | 1. **Request path extraction** — `Path<i64>` extractors in handlers for instance/backup IDs: | ||
| 239 | - Axum validates that the path segment is a valid integer | ||
| 240 | - However, ID is used directly in SQL queries (no injection due to type safety of rusqlite params) | ||
| 241 | |||
| 242 | 2. **Form extraction** — `axum::Form<T>` deserializes `application/x-www-form-urlencoded` bodies: | ||
| 243 | - Strings accepted as-is, passed to SQL INSERT/UPDATE via `params![]` macro | ||
| 244 | - SQL injection prevented by rusqlite's parameterized queries | ||
| 245 | |||
| 246 | 3. **Multipart extraction** — manual multer parsing (not axum's built-in `Multipart`): | ||
| 247 | - Boundary parsed from Content-Type header; `Constraints` limit 500MB | ||
| 248 | - Filename from `field.file_name()` — **no sanitization** beyond stripping directory with `Path::new(filename).file_name()` | ||
| 249 | |||
| 250 | 4. **Extension-based auth** — `require_auth` places `Account` in `request.extensions_mut()`: | ||
| 251 | - All protected handlers get `CurrentUser` via `FromRequestParts` | ||
| 252 | - This is a framework-level contract — extensions survive across middleware boundaries | ||
| 253 | |||
| 254 | ### Cookie/Hidden Channel Contracts | ||
| 255 | |||
| 256 | | Header/Cookie | Set by | Read by | Security property | | ||
| 257 | |--------------|--------|---------|-------------------| | ||
| 258 | | `Cookie: mcui_session` | `authenticate()` in auth.rs | `require_auth()` middleware | Session identifier | | ||
| 259 | | `Content-Type` | Client | Multer parser in mods.rs | Boundary extraction for multipart | | ||
| 260 | | User-supplied filename | Client | `field.file_name()` in mods.rs | Written to disk directly | | ||
| 261 | |||
| 262 | ### Proxy Assumptions | ||
| 263 | |||
| 264 | - Server binds to `127.0.0.1:<port>` (localhost-only) | ||
| 265 | - **No TLS** — assumes a reverse proxy (nginx) handles HTTPS termination | ||
| 266 | - `X-Forwarded-*` headers are **not read** anywhere — trust decisions based on direct connection | ||
| 267 | - No CDN, no WAF, no rate limiting in the application | ||
| 268 | |||
| 269 | ### Auth Guard Analysis | ||
| 270 | |||
| 271 | - **`require_auth` middleware** (src/auth.rs:55): applied to the `protected` router group | ||
| 272 | - **Superuser checks**: done **inside handlers** (e.g., `if !account.is_superuser { return Err(Redirect::to("/")); }`) | ||
| 273 | - **No re-check** on sub-routes within the protected group — any authenticated user can access all instance/player/backup operations | ||
| 274 | - **Logout handler** (`POST /logout`): **NOT behind `require_auth`** — it's registered on the outer Router outside the `protected` group. This means anonymous requests could cause session deletion if they can guess a valid session ID from the cookie header. (Low risk due to session ID being UUID v4 — practically unpredictable.) | ||
| 275 | |||
| 276 | --- | ||
| 277 | |||
| 278 | ## Threat Model | ||
| 279 | |||
| 280 | ### Assets | ||
| 281 | |||
| 282 | | Asset | Description | Storage | Impact if compromised | | ||
| 283 | |-------|------------|---------|-----------------------| | ||
| 284 | | **Minecraft server instance** | Running Docker container with world, mods, config | Docker + filesystem | Game data loss, server hijack | | ||
| 285 | | **World data** | Minecraft saves (chunks, player inventories) | Filesystem (ZIP backups too) | Permanent data loss | | ||
| 286 | | **Accounts** | WebUI credentials and sessions | SQLite (Argon2id hashed) | Unauthorized web access | | ||
| 287 | | **RCON password** | Auto-generated, per-instance | SQLite (plaintext) + server.properties | Minecraft server control | | ||
| 288 | | **Mod files** | Uploaded JARs | Filesystem | Supply chain risk (arbitrary code in JVM) | | ||
| 289 | | **Docker socket** | `/var/run/docker.sock` | Host filesystem | Container escape, host compromise | | ||
| 290 | | **Config** | Server directory, initial superuser creds | config.toml | Full system access | | ||
| 291 | |||
| 292 | ### Threat Actors | ||
| 293 | |||
| 294 | | Actor | Capability | Motivation | | ||
| 295 | |-------|-----------|------------| | ||
| 296 | | **Anonymous web attacker** | Can reach localhost:3000 if exposed | Access webUI, break auth, RCE | | ||
| 297 | | **Authenticated user** | Valid session; equal access to all instances | Escalate to superuser, exfiltrate data | | ||
| 298 | | **Superuser** | Full webUI control | Any action webUI authorizes | | ||
| 299 | | **Network-level attacker** | Can reach Docker API, RCON port, Minecraft port | Side-channel or direct attack | | ||
| 300 | | **Supply-chain attacker** | Malicious mod JAR | Server-side code execution in Minecraft JVM | | ||
| 301 | |||
| 302 | ### Attack Scenarios (Prioritized) | ||
| 303 | |||
| 304 | | # | Threat | Actor | Vector | Impact | Existing Control | Priority | | ||
| 305 | |---|--------|-------|--------|--------|-----------------|----------| | ||
| 306 | | T1 | **World import zip slip** | Auth user | ZIP with `../` entries bypasses `enclosed_name()` → write files outside data dir | Filesystem write anywhere webUI user can write | `enclosed_name()` check in world import | **Medium** (mitigated for standard zip slip) | | ||
| 307 | | T2 | **Path traversal in mod filename** | Auth user | Upload mod with name `../../cronjob` → write to parent dirs | Arbitrary file write | `file_name()` call strips directory components | **Medium** (partially mitigated) | | ||
| 308 | | T3 | **Backup path traversal (download)** | Auth user | Backup filename with path traversal chars in DB | File read outside backup dir | Filename read from DB (stored at insert time) | **Low** | | ||
| 309 | | T4 | **Session fixation/logout bypass** | Anonymous | POST /logout can delete sessions without auth middleware | Force logout of legitimate user | UUIDv4 session IDs are unpredictable | **Low** | | ||
| 310 | | T5 | **RCON password plaintext in DB** | Auth user/Superuser | Read rcon_password from instances table via edit form | Minecraft server command injection | RCON password stored in DB, returned in edit form | **Medium** | | ||
| 311 | | T6 | **Weak RCON password generation** | Network attacker | `rand::thread_rng()` used for 16-char alphanumeric password | RCON brute force | 16 chars alphanumeric = 36^16 space | **Low** | | ||
| 312 | | T7 | **No rate limiting on login** | Anonymous | Brute-force login credentials | Account compromise | No rate limiting implemented | **High** | | ||
| 313 | | T8 | **No CSRF protection** | Any | Cross-site request forgery on any POST endpoint | State-changing operations without user intent | None — no CSRF token anywhere | **High** | | ||
| 314 | | T9 | **Mod JAR parsing denial of service** | Auth user | Upload crafted JAR that causes OOM or hangs in class-file scanner (constant pool loops) | Server DoS | No timeout on validate_mod() | **Medium** | | ||
| 315 | | T10 | **Large file upload DoS** | Auth user | 500MB max per field, but no per-request total limit | Memory exhaustion | 500MB per-field limit, but multipart streams | **Medium** | | ||
| 316 | |||
| 317 | --- | ||
| 318 | |||
| 319 | --- | ||
| 320 | |||
| 321 | ## Static Analysis Summary | ||
| 322 | |||
| 323 | **Phase:** P4 (Static Analysis & Triage) | ||
| 324 | **Date:** 2025-07-29 | ||
| 325 | **Mode:** deep (grep+read fallback — codeql and semgrep CLIs not available on this system) | ||
| 326 | |||
| 327 | ### Sub-step 4.1 — Structural Extraction Results | ||
| 328 | |||
| 329 | | Metric | Value | | ||
| 330 | |--------|-------| | ||
| 331 | | Entry points (HTTP handlers) | 31 (including static file serve) | | ||
| 332 | | Sinks identified | 14 unique sink types | | ||
| 333 | | Source-to-sink slices | 12 documented flows | | ||
| 334 | | Files analyzed | 15 Rust source files | | ||
| 335 | | Third-party JS minified file candidates | 26 matches (all in htmx.min.js — all false positives) | | ||
| 336 | |||
| 337 | ### Tools Used | ||
| 338 | |||
| 339 | - **grep + read** — manual structural extraction and flow tracing (codeql CLI not installed) | ||
| 340 | - **Manual source-to-sink analysis** — all 12 critical/high/medium flows traced | ||
| 341 | - **Custom rules** — 4 CodeQL query stubs + 15 Semgrep rules created for reference | ||
| 342 | - **Built-in suites:** Not applicable — codeql/semgrep CLIs not available | ||
| 343 | |||
| 344 | ### Custom SAST Artifacts | ||
| 345 | |||
| 346 | | Artifact | Location | Description | | ||
| 347 | |----------|----------|-------------| | ||
| 348 | | Entry points | `piolium/codeql-artifacts/entry-points.json` | 31 HTTP entry points catalogued | | ||
| 349 | | Sinks | `piolium/codeql-artifacts/sinks.json` | 14 sink types with locations | | ||
| 350 | | Call graph slices | `piolium/codeql-artifacts/call-graph-slices.json` | 12 source-to-sink data flow slices | | ||
| 351 | | Source-sink flows | `piolium/attack-surface/source-sink-flows-all-severities.md` | Full flow paths by severity | | ||
| 352 | | Custom CodeQL queries | `piolium/codeql-queries/` | 4 query stubs (path-traversal, rcon-injection, csrf, session-ttl) | | ||
| 353 | | Custom Semgrep rules | `piolium/semgrep-rules/security-rules.yml` | 15 detection rules | | ||
| 354 | | Draft findings | `piolium/findings-draft/` | 30 draft findings (p4-001 through p4-030) | | ||
| 355 | |||
| 356 | ### DFD/CFD Slices Driving Custom Analysis | ||
| 357 | |||
| 358 | Based on Phase 3 DFD/CFD analysis, custom targeted analysis was performed for: | ||
| 359 | |||
| 360 | | Slice | Custom Rule Focus | Finding | | ||
| 361 | |-------|-------------------|---------| | ||
| 362 | | DFD Slice 2 (Mod Upload) | Mod filename path traversal, mod validation DoS, ZIP recursion | p4-010, p4-014, p4-015 | | ||
| 363 | | DFD Slice 3 (World Import) | ZIP slip edge cases | p4-011 | | ||
| 364 | | CFD Slice 1 (Auth Middleware) | No session TTL, no Secure flag, cookie parsing | p4-004, p4-009, p4-019 | | ||
| 365 | | Hidden Control Channels | Cookie auth, Content-Type boundary parsing | p4-008, p4-019 | | ||
| 366 | | RCON Player Actions | RCON command injection | p4-002 | | ||
| 367 | | Instance Creation | Instance name path traversal, input validation | p4-001, p4-018 | | ||
| 368 | |||
| 369 | ### Inline Enrichment Summary | ||
| 370 | |||
| 371 | See the `## SAST Enrichment` section below for per-candidate classification and keep/drop decisions. | ||
| 372 | |||
| 373 | --- | ||
| 374 | |||
| 375 | ## CodeQL Structural Analysis | ||
| 376 | |||
| 377 | CodeQL was not available on this system. Structural extraction was performed manually using grep + read. | ||
| 378 | |||
| 379 | **Database:** Not built (codeql CLI not installed). Artifacts in `piolium/codeql-artifacts/` are manually constructed. | ||
| 380 | |||
| 381 | **Entry points found:** 31 (all HTTP route handlers) — see `piolium/codeql-artifacts/entry-points.json` | ||
| 382 | |||
| 383 | **Sinks found:** 14 categories — see `piolium/codeql-artifacts/sinks.json` | ||
| 384 | |||
| 385 | **Source-to-sink traces:** 12 documented flows — see `piolium/codeql-artifacts/call-graph-slices.json` and `piolium/attack-surface/source-sink-flows-all-severities.md` | ||
| 386 | |||
| 387 | Key flows by severity: | ||
| 388 | - **Critical:** Instance name path traversal (flow-1) | ||
| 389 | - **High:** RCON command injection (flow-4, flow-5), No session TTL (hidden control channel), No CSRF | ||
| 390 | - **Medium:** Mod filename special chars (flow-3), World import ZIP (flow-6), Backup path (flow-7), Docker env injection (flow-11), Seed injection (flow-12) | ||
| 391 | - **Low:** Content-type parsing, Static file serving, Cookie parsing, Template errors | ||
| 392 | |||
| 393 | --- | ||
| 394 | |||
| 395 | ## SAST Enrichment | ||
| 396 | |||
| 397 | ### Candidate Classification | ||
| 398 | |||
| 399 | All 26 candidate matches from the candidate scan were in `static/htmx.min.js` — a minified third-party JavaScript library (htmx v2.x). All candidates are **false positives** from the minified file. | ||
| 400 | |||
| 401 | | Finding | Classification | Attacker Control | Boundary | CodeQL Reachability | Verdict | | ||
| 402 | |---------|---------------|-----------------|----------|-------------------|---------| | ||
| 403 | | dynamic-code-execution (x8, htmx.min.js) | env/tooling-only | N/A (minified 3rd-party lib) | N/A | no-slice | **DROP** — minified 3rd-party lib, not project code | | ||
| 404 | | command-execution (x4, htmx.min.js) | env/tooling-only | N/A (minified 3rd-party lib) | N/A | no-slice | **DROP** — minified 3rd-party lib, not project code | | ||
| 405 | | path-traversal-file-access (x7, htmx.min.js) | env/tooling-only | N/A (minified 3rd-party lib) | N/A | no-slice | **DROP** — minified 3rd-party lib, not project code | | ||
| 406 | | unsafe-html-or-template (x1, htmx.min.js) | env/tooling-only | N/A (minified 3rd-party lib) | N/A | no-slice | **DROP** — minified 3rd-party lib, not project code | | ||
| 407 | | hidden-control-channel (x6, htmx.min.js) | env/tooling-only | N/A (minified 3rd-party lib) | N/A | no-slice | **DROP** — minified 3rd-party lib, not project code | | ||
| 408 | |||
| 409 | ### True Findings Identified Through Manual Analysis | ||
| 410 | |||
| 411 | All true findings were identified through manual structural analysis of the Rust source code. See `piolium/findings-draft/` for 30 drafted findings. | ||
| 412 | |||
| 413 | ### Entry Points Not in Phase 3 DFD Slices | ||
| 414 | |||
| 415 | All DFD/CFD entry points from Phase 3 were covered. Additional unauthenticated paths not explicitly listed in Phase 3 DFD slices: | ||
| 416 | - `GET /static/*` — static file serving (no auth) | ||
| 417 | - `POST /logout` — logout without auth middleware | ||
| 418 | |||
| 419 | ### Unmodeled High-Risk Flows | ||
| 420 | |||
| 421 | - **Docker environment variable injection** (flow-11) — user-controlled instance settings pass into Docker container env without sanitization | ||
| 422 | - **Level-seed injection into server.properties** (flow-12) — seed written to config file without escaping | ||
| 423 | |||
| 424 | --- | ||
| 425 | |||
| 426 | ## Domain Attack Research | ||
| 427 | |||
| 428 | ### Domain: ZIP Archive Handling | ||
| 429 | |||
| 430 | **Identified via:** `zip` crate dependency (8.6.0), world import (ZIP extraction), mod upload (nested JAR-in-ZIP), backup creation/restore | ||
| 431 | |||
| 432 | **Known attack classes:** | ||
| 433 | |||
| 434 | | Attack | Description | Detection strategy | Relevance | | ||
| 435 | |--------|-------------|-------------------|-----------| | ||
| 436 | | **Zip Slip** | Archive entry name with `../` writes outside target dir | Check all `file.enclosed_name()` calls + fallback paths | High — world import and mod ZIP both extract entries | | ||
| 437 | | **Zip Bomb** | Tiny archive expands to gigabytes; nested zip | Size limits, streaming extraction, recursion depth | Medium — 500MB stream limit partially mitigates | | ||
| 438 | | **Uncontrolled recursion depth** | ZIP within ZIP within ZIP | Recursion depth tracking in process_zip_upload | High — recursion via process_jar_upload→validate_mod has no depth limit | | ||
| 439 | | **Symlink traversal** | ZIP entry creates symlink outside target dir | Check zip crate version for symlink handling | Low — zip crate doesn't create symlinks by default | | ||
| 440 | | **Empty filename / directory traversal via encoding** | Different OS path normalization of entry names | Test with `\` vs `/` on different platforms | Low — Rust std::fs on Linux doesn't do special normalization | | ||
| 441 | |||
| 442 | **Custom SAST targets:** | ||
| 443 | |||
| 444 | | Attack pattern | Rule type | Source/sink or pattern | Priority | | ||
| 445 | |---------------|-----------|----------------------|----------| | ||
| 446 | | Zip entry path not using `enclosed_name()` | Semgrep | `archive.by_index(i)` → `.name()` but no `enclosed_name()` | High | | ||
| 447 | | Zip extraction without size limit | Semgrep | `archive.by_index(i)` → `read_to_end()` without size check | Medium | | ||
| 448 | | Recursive zip extraction no depth limit | CodeQL | Recursive call to extract function without depth counter | High | | ||
| 449 | |||
| 450 | **Manual review checklist:** | ||
| 451 | - [ ] All ZIP extraction uses `enclosed_name()` — checked in world import (src/routes/instances.rs:import_world) | ||
| 452 | - [ ] Mod ZIP extraction (src/routes/mods.rs:process_zip_upload) — only extracts `.jar` entries, reads them into memory | ||
| 453 | - [ ] No depth limit on nested ZIPs inside mod upload (process_zip_upload → process_jar_upload → validate_mod — validate_mod opens the JAR as a ZIP) | ||
| 454 | |||
| 455 | ### Domain: File Upload (Mod Upload) | ||
| 456 | |||
| 457 | **Identified via:** multer dependency, multipart handler for `.jar`/`.zip` files | ||
| 458 | |||
| 459 | **Known attack classes:** | ||
| 460 | |||
| 461 | | Attack | Description | Detection strategy | Relevance | | ||
| 462 | |--------|-------------|-------------------|-----------| | ||
| 463 | | **Unrestricted file upload** | Any file extension accepted | Check file extension filtering — `.jar`/`.zip` only | High — only JAR/ZIP accepted | | ||
| 464 | | **Path traversal via filename** | `../../evil.so` as upload filename | Filename sanitized via `file_name()` | High — partially sanitized | | ||
| 465 | | **Large file DoS** | 500MB upload exhausts memory | Multer size limit configured at 500MB | Medium — 500MB is high | | ||
| 466 | | **Content-type not validated** | No MIME type check | No content-type validation on upload | Medium — relies on extension | | ||
| 467 | |||
| 468 | **Manual review checklist:** | ||
| 469 | - [ ] Filename sanitized with `Path::new(filename).file_name()` in process_jar_upload — YES, directory components stripped | ||
| 470 | - [ ] File extension validated before processing — YES, `.jar`/`.zip`/`.JAR`/`.ZIP` only | ||
| 471 | - [ ] Uploaded file size validated — YES, 500MB per-field limit via multer Constraints | ||
| 472 | - [ ] File written before or after validation — AFTER validation (accept/reject) | ||
| 473 | - [ ] Rejected files are NOT written to disk — Correct; only `result.accepted` files are written | ||
| 474 | |||
| 475 | ### Domain: Session Management | ||
| 476 | |||
| 477 | **Identified via:** Custom cookie-based sessions, UUID v4 session IDs | ||
| 478 | |||
| 479 | **Known attack classes:** | ||
| 480 | |||
| 481 | | Attack | Description | Detection strategy | Relevance | | ||
| 482 | |--------|-------------|-------------------|-----------| | ||
| 483 | | **Session fixation** | Attacker sets known session ID before login | Session ID regenerated on login (new UUID issued) | Low — new UUID per login | | ||
| 484 | | **Session not invalidated on logout** | Server-side session persists | Logout handler deletes session from DB | High — logout DOES delete from DB | | ||
| 485 | | **No absolute session timeout** | Sessions valid indefinitely | No `expires_at` or `max-age` check on server side | **High** — sessions stored without TTL | | ||
| 486 | | **Missing Secure flag** | Cookie sent over HTTP | Cookie set with HttpOnly but NOT Secure | Medium — behind nginx but not marked | | ||
| 487 | | **CSRF via session cookie** | Cookie sent automatically | No SameSite=Strict (only SameSite=Lax) | Medium — POST operations still protected by Lax for same-site | | ||
| 488 | | **Brute-force session ID** | Predictable session IDs | UUIDv4 is random | Low — 122 bits of entropy | | ||
| 489 | |||
| 490 | **Manual review checklist:** | ||
| 491 | - [ ] Session ID regenerated on login — YES (UUIDv4 on each login) | ||
| 492 | - [ ] Server-side session deletion on logout — YES (DELETE FROM sessions WHERE id = ?1) | ||
| 493 | - [ ] Absolute timeout enforced — **NO** — no `expires_at` column or check | ||
| 494 | - [ ] Cookie flags: HttpOnly=YES, Secure=NO, SameSite=Lax=YES | ||
| 495 | - [ ] Max-age=86400 (24h) but no server-side enforcement after that | ||
| 496 | |||
| 497 | ### Domain: RCON Protocol | ||
| 498 | |||
| 499 | **Identified via:** Custom TCP client to 127.0.0.1:25575 | ||
| 500 | |||
| 501 | **Known attack classes:** | ||
| 502 | |||
| 503 | | Attack | Description | Detection strategy | Relevance | | ||
| 504 | |--------|-------------|-------------------|-----------| | ||
| 505 | | **Command injection via username** | Unsanitized username sent in RCON command | Username from form → `format!("whitelist add {}", form.username)` | **Medium** — username contains no shell chars but could inject RCON commands via spaces/semicolons | | ||
| 506 | | **RCON password exposure** | Plaintext password in DB and edit form | Stored as `rcon_password` in instances table | **High** — any authenticated user can see it | | ||
| 507 | | **RCON man-in-the-middle** | No TLS on RCON connection | Localhost only, plain TCP | Low — localhost only | | ||
| 508 | | **Authentication replay** | RCON auth packet capture | No session binding | Low — per-instance random password | | ||
| 509 | |||
| 510 | **Manual review checklist:** | ||
| 511 | - [ ] Username validated for RCON safety — NO — `format!("whitelist add {}", form.username)` — no sanitization on username | ||
| 512 | - [ ] RCON password generation uses crypto RNG — **NO** — `rand::thread_rng()` used (not `OsRng`) | ||
| 513 | - [ ] RCON password accessible to users — **YES** — returned in edit form via `rcon_password` field | ||
| 514 | |||
| 515 | ### Domain: ZIP Slip — Mod Nested ZIP Extraction | ||
| 516 | |||
| 517 | **Identified via:** process_zip_upload in src/routes/mods.rs extracts JARs from user-supplied ZIPs | ||
| 518 | |||
| 519 | **Attack class:** Recursive extraction with no depth limit — a ZIP containing a JAR (which is also a ZIP) is opened by `validate_mod` which calls `ZipArchive::new` on the extracted bytes. | ||
| 520 | |||
| 521 | **Manual review checklist:** | ||
| 522 | - [ ] Recursive extraction depth limited — **NO** — no depth counter in process_zip_upload → process_jar_upload → validate_mod | ||
| 523 | - [ ] Size of extracted JAR bytes bounded before passing to validate_mod — bounded by multer, but nested extraction may allocate | ||
| 524 | |||
| 525 | --- | ||
| 526 | |||
| 527 | ## Phase 4 CodeQL Extraction Targets | ||
| 528 | |||
| 529 | | DFD Slice | Source type | Sink kind | File:line | Rationale | | ||
| 530 | |-----------|------------|-----------|-----------|-----------| | ||
| 531 | | Mod upload file write | RemoteFlowSource | file-write | src/routes/mods.rs:98 (`std::fs::write`) | Attacker-controlled filename → arbitrary file write | | ||
| 532 | | World import ZIP extraction | RemoteFlowSource | file-write | src/routes/instances.rs:210 (`std::fs::File::create`) | ZIP entry path → file creation | | ||
| 533 | | Backup download path | RemoteFlowSource | file-read | src/routes/backups.rs:106 (`std::fs::read`) | Backup ID from DB → file read | | ||
| 534 | | Login username | RemoteFlowSource | sql-execution | src/auth.rs:45 (`query_row`) | String from form → SQL query (parametrized) | | ||
| 535 | | Create instance name | RemoteFlowSource | sql-execution | src/routes/instances.rs:119 (`execute`) | Name from form → SQL INSERT | | ||
| 536 | | Mod upload filename | RemoteFlowSource | path-traversal | src/routes/mods.rs:187 (`mods_dir.join(&safe_name)`) | Stripped but could still contain special chars | | ||
| 537 | | Player action username | RemoteFlowSource | command-execution | src/routes/players.rs:94 (`format!($command, form.username)`) | Username → RCON command string | | ||
| 538 | |||
| 539 | --- | ||
| 540 | |||
| 541 | ## Spec Gap Candidates | ||
| 542 | |||
| 543 | No formal specs or RFCs are implemented by this project. The application implements: | ||
| 544 | 1. **RCON protocol** (Minecraft Remote Console) — ad hoc binary protocol, not formally specified, but follows Minecraft community convention | ||
| 545 | 2. **Argon2id password hashing** (via `argon2` crate) — follows RFC 9106 | ||
| 546 | 3. **UUID v4** (via `uuid` crate) — follows RFC 4122 | ||
| 547 | 4. **`.properties` file parsing** — Java convention, ad hoc implementation in src/properties.rs | ||
| 548 | |||
| 549 | **Gap:** | ||
| 550 | - The RCON implementation (src/rcon.rs) follows Minecraft protocol but has no nonce or bound authentication — classic RCON vulnerability where any authenticated session can issue any command | ||
| 551 | - No explicit rate limiting spec — none implemented | ||
| 552 | - No session time-out spec — none enforced server-side | ||
| 553 | |||
| 554 | --- | ||
| 555 | |||
| 556 | ## Known False-Positive Sources | ||
| 557 | |||
| 558 | None identified — INFO.md was not present. | ||
| 559 | |||
| 560 | --- | ||
| 561 | |||
| 562 | ## Out-of-Scope Paths | ||
| 563 | |||
| 564 | - `minecraft/` — contains actual runtime data for a Minecraft server instance (world, logs, mods, configs). These are generated runtime artifacts, not source code. | ||
| 565 | - `target/` — Rust build artifacts | ||
| 566 | - `.git/` — VCS data | ||
| 567 | - `piolium/` — audit artifacts | ||
| 568 | |||
| 569 | --- | ||
| 570 | |||
| 571 | ## Coverage Gaps | ||
| 572 | |||
| 573 | 1. **No CSRF protection** — all POST endpoints are vulnerable to cross-site request forgery | ||
| 574 | 2. **No rate limiting** — login endpoint, mod upload, any POST form | ||
| 575 | 3. **No session TTL enforcement** — server never checks `created_at` on sessions | ||
| 576 | 4. **No audit logging** — no record of who performed what action (except RCON commands logged via tracing) | ||
| 577 | 5. **No input validation for RCON username** — could contain spaces or special RCON-parsed characters | ||
| 578 | 6. **No backup restore implementation** — `restore_backup` handler returns a redirect without performing any restore | ||
| 579 | 7. **No encryption at rest** — SQLite DB unencrypted on disk | ||
| 580 | 8. **No API key or token auth** — only cookie-based session auth | ||
| 581 | |||
| 582 | ## State & Concurrency Audit | ||
| 583 | |||
| 584 | - State-holding entities catalogued: 10 (across 5 tables: accounts, sessions, instances, mods, backups) | ||
| 585 | - Concurrency primitives observed: `Arc<Mutex<Connection>>` (per-statement serialisation, no transaction boundaries), SQLite WAL mode | ||
| 586 | - Idempotency infrastructure: **absent** — no idempotency keys, no dedup, no request ID storage | ||
| 587 | - Drafts filed: 6 (split by class: 3 TOCTOU, 1 state-machine-violation, 1 idempotency, 1 read-modify-write) | ||
| 588 | - See `piolium/attack-surface/state-concurrency-summary.md` for full catalogue | ||
| 589 | |||
| 590 | ## Cross-Service Taint Propagation | ||
| 591 | |||
| 592 | - Services analysed: 1 (single-process Rust/axum server — `src/main.rs` only) | ||
| 593 | - Edges stitched: 0 — no inter-service edges detected | ||
| 594 | - Coverage gaps: N/A | ||
| 595 | - Drafts filed: 0 | ||
| 596 | |||
| 597 | Skipped — single-service project; the only data flows cross boundaries to external infrastructure (Docker daemon via Unix socket, Minecraft server via RCON TCP, SQLite file, filesystem) which are not deployable services within this repo. | ||
diff --git a/piolium/attack-surface/lite-recon.md b/piolium/attack-surface/lite-recon.md new file mode 100644 index 0000000..d19f392 --- /dev/null +++ b/piolium/attack-surface/lite-recon.md | |||
| @@ -0,0 +1,37 @@ | |||
| 1 | # Lite Recon — Q0 | ||
| 2 | |||
| 3 | Generated by piolium at 2026-07-28T23:31:42.161Z | ||
| 4 | |||
| 5 | ## Target | ||
| 6 | |||
| 7 | - Path: `/home/salmon/Documents/minecraft-ui` | ||
| 8 | - Repository: (unknown) | ||
| 9 | - Total files (scanned): 901 | ||
| 10 | - Total bytes (scanned): 332.4 MB | ||
| 11 | |||
| 12 | ## Git | ||
| 13 | |||
| 14 | - Commit: 74d2ada067bd2fd9436710ab7ac260e0eddf1314 | ||
| 15 | - Branch: master | ||
| 16 | - History available: true | ||
| 17 | |||
| 18 | Recent commits: | ||
| 19 | |||
| 20 | ``` | ||
| 21 | 74d2ada Add per-instance mod management with client-side detection | ||
| 22 | 97920e4 create example config on startup | ||
| 23 | ce30a87 remove unused code, also, third stable | ||
| 24 | 9ad52d0 second stable | ||
| 25 | ae7ef10 first stable | ||
| 26 | 0c2e661 architecture | ||
| 27 | ``` | ||
| 28 | |||
| 29 | ## Languages | ||
| 30 | |||
| 31 | - Rust: 16 file(s) | ||
| 32 | - JavaScript: 1 file(s) | ||
| 33 | |||
| 34 | ## Build / Project Manifests | ||
| 35 | |||
| 36 | - `Cargo.toml` | ||
| 37 | - `minecraft/docker-compose.yml` | ||
diff --git a/piolium/attack-surface/manual-attack-surface-inventory.md b/piolium/attack-surface/manual-attack-surface-inventory.md new file mode 100644 index 0000000..c1793e4 --- /dev/null +++ b/piolium/attack-surface/manual-attack-surface-inventory.md | |||
| @@ -0,0 +1,311 @@ | |||
| 1 | # Manual Attack Surface Inventory — Phase 8 Deep Probe | ||
| 2 | |||
| 3 | **Target:** salmon/minecraft-ui | ||
| 4 | **Generated:** 2025-07-29 | ||
| 5 | **Phase:** P8 — Manual Attack Surface Probe | ||
| 6 | **Scope:** All Rust source files (16 files), templates, config | ||
| 7 | |||
| 8 | --- | ||
| 9 | |||
| 10 | ## 1. Public Routes / URLs (No Auth Required) | ||
| 11 | |||
| 12 | | Method | Path | Handler | Source File:Line | Input Sources | | ||
| 13 | |--------|------|---------|-----------------|---------------| | ||
| 14 | | GET | `/login` | `login_page` | src/routes/mod.rs:53 | None (static template) | | ||
| 15 | | POST | `/login` | `authenticate` | src/auth.rs:34 | `username`, `password` (Form) | | ||
| 16 | | POST | `/logout` | `logout` | src/auth.rs:130 | Cookie header (Cookie: mcui_session=...) | | ||
| 17 | | GET | `/static/*` | `ServeDir::new("static")` | src/routes/mod.rs:28 | File path after `/static/` | | ||
| 18 | |||
| 19 | ## 2. Authenticated Routes (All Behind `require_auth`) | ||
| 20 | |||
| 21 | | Method | Path | Handler | File:Line | Key Inputs | Sink | | ||
| 22 | |--------|------|---------|-----------|------------|------| | ||
| 23 | | GET | `/` | `dashboard` | src/routes/dashboard.rs:19 | CurrentUser extension | SQL SELECT | | ||
| 24 | | GET | `/instances` | `list_instances` | src/routes/instances.rs:44 | None | SQL SELECT | | ||
| 25 | | GET | `/instances/new` | `create_form` | src/routes/instances.rs:70 | None | Outbound HTTP (Mojang API) | | ||
| 26 | | POST | `/instances/new` | `create_instance` | src/routes/instances.rs:87 | `name`, `version`, `seed`, `motd`, etc. | SQL INSERT, `create_dir_all`, RCON password gen | | ||
| 27 | | GET | `/instances/{id}/edit` | `edit_form` | src/routes/instances.rs:129 | Path `id` (i64) | SQL SELECT, `read_instance_seed`, RCON `seed` cmd | | ||
| 28 | | POST | `/instances/{id}/edit` | `edit_instance` | src/routes/instances.rs:176 | Path `id`, Form: difficulty, gamemode, motd | SQL UPDATE, `stop_container` | | ||
| 29 | | POST | `/instances/{id}/delete` | `delete_instance` | src/routes/instances.rs:198 | Path `id` | SQL UPDATE/DELETE, `remove_dir_all` | | ||
| 30 | | POST | `/instances/{id}/activate` | `activate_instance` | src/routes/instances.rs:217 | Path `id` | Docker `start_container`, RCON polling | | ||
| 31 | | POST | `/instances/deactivate` | `deactivate_instance` | src/routes/instances.rs:278 | None | Docker `stop_container` | | ||
| 32 | | GET | `/instances/{id}/world/export` | `export_world` | src/routes/instances.rs:288 | Path `id` | `read_dir`, `ZipWriter`, file read | | ||
| 33 | | POST | `/instances/{id}/world/import` | `import_world` | src/routes/instances.rs:328 | Path `id`, Multipart ZIP | `ZipArchive`, `File::create`, `remove_dir_all` | | ||
| 34 | | POST | `/instances/{id}/world/regenerate` | `regenerate_world` | src/routes/instances.rs:360 | Path `id`, Form: `seed` | `read_to_string`, `write`, `remove_dir_all` | | ||
| 35 | | GET | `/players` | `manage_players` | src/routes/players.rs:28 | None | `read_to_string` (whitelist/ops/bans JSON) | | ||
| 36 | | POST | `/players/whitelist/add` | `add_whitelist` | src/routes/players.rs:89 | `username` (Form) | RCON `send_command_async` | | ||
| 37 | | POST | `/players/whitelist/remove` | `remove_whitelist` | src/routes/players.rs:89 | `username` | RCON `send_command_async` | | ||
| 38 | | POST | `/players/op/add` | `add_op` | src/routes/players.rs:89 | `username` | RCON `send_command_async` | | ||
| 39 | | POST | `/players/op/remove` | `remove_op` | src/routes/players.rs:89 | `username` | RCON `send_command_async` | | ||
| 40 | | POST | `/players/ban/add` | `add_ban` | src/routes/players.rs:89 | `username` | RCON `send_command_async` | | ||
| 41 | | POST | `/players/ban/remove` | `remove_ban` | src/routes/players.rs:89 | `username` | RCON `send_command_async` | | ||
| 42 | | GET | `/instances/{id}/mods` | `list_mods` | src/routes/mods.rs:42 | Path `id` | SQL SELECT | | ||
| 43 | | POST | `/instances/{id}/mods` | `upload_mod` | src/routes/mods.rs:86 | Path `id`, Multipart .jar/.zip | `std::fs::write`, JAR parsing, SQL INSERT | | ||
| 44 | | POST | `/instances/{id}/mods/{mod_id}/delete` | `delete_mod` | src/routes/mods.rs:188 | Path `instance_id`, `mod_id` | `remove_file`, SQL DELETE | | ||
| 45 | | POST | `/instances/{id}/mods/rescan` | `rescan_mods` | src/routes/mods.rs:163 | Path `id` | `read_dir`, `std::fs::read`, JAR parsing | | ||
| 46 | | GET | `/instances/{id}/backups` | `list_backups` | src/routes/backups.rs:30 | Path `id` | SQL SELECT | | ||
| 47 | | POST | `/instances/{id}/backups/create` | `create_backup` | src/routes/backups.rs:54 | Path `id` | `File::create`, `ZipWriter`, SQL INSERT | | ||
| 48 | | POST | `/backups/{id}/restore` | `restore_backup` | src/routes/backups.rs:88 | Path `id` | SQL SELECT (STUB — no-op) | | ||
| 49 | | GET | `/backups/{id}/download` | `download_backup` | src/routes/backups.rs:93 | Path `id` | SQL SELECT, `std::fs::read` | | ||
| 50 | | GET | `/accounts` | `manage_accounts` | src/routes/accounts.rs:60 | CurrentUser (must be superuser) | SQL SELECT | | ||
| 51 | | POST | `/accounts/create` | `create_account` | src/routes/accounts.rs:95 | `username`, `password`, `is_superuser` | SQL INSERT | | ||
| 52 | | POST | `/accounts/delete` | `delete_account` | src/routes/accounts.rs:111 | `form.id` | SQL DELETE | | ||
| 53 | | POST | `/accounts/set-password` | `set_password` | src/routes/accounts.rs:142 | `form.id`, `new_password` | SQL UPDATE, session DELETE | | ||
| 54 | | GET | `/change-password` | `change_password_page` | src/routes/mod.rs:58 | CurrentUser | None | | ||
| 55 | | POST | `/change-password` | `change_password` | src/routes/accounts.rs:163 | `current_password`, `new_password` | SQL UPDATE, session DELETE | | ||
| 56 | |||
| 57 | --- | ||
| 58 | |||
| 59 | ## 3. Attacker-Controlled Input Sources (By Category) | ||
| 60 | |||
| 61 | ### 3a. Form Fields (application/x-www-form-urlencoded) | ||
| 62 | | Field | Handler(s) | Max Size | Validation | Risk | | ||
| 63 | |-------|-----------|----------|------------|------| | ||
| 64 | | `username` | authenticate, player actions, create_account, set_password | ~64KB | None (SQL param prevents injection) | LOW (SQL safe), **HIGH** (RCON injection in player actions) | | ||
| 65 | | `password` | authenticate, change_password, create_account | ~64KB | Argon2id hashed | LOW | | ||
| 66 | | `name` | create_instance | ~64KB | **NONE** | **CRITICAL** — used in fs::create_dir_all, stored in DB, used in remove_dir_all | | ||
| 67 | | `seed` | create_instance, regenerate_world | ~64KB | **NONE** — written to server.properties | **MEDIUM** — properties injection | | ||
| 68 | | `motd` | create_instance, edit_instance | ~64KB | **NONE** — passed to Docker env | **HIGH** — Docker env injection | | ||
| 69 | | `version` | create_instance | ~64KB | **NONE** — passed to Docker env | MEDIUM | | ||
| 70 | | `difficulty` / `gamemode` / `max_players` | create_instance, edit_instance | i32 | Range limited | MEDIUM | | ||
| 71 | | `memory_mb` | create_instance, edit_instance | i32 | 512..max_memory_mb | LOW | | ||
| 72 | | `server_type` | create_instance | ~64KB | CHECK constraint in SQL | LOW (DB enforced) | | ||
| 73 | | `level_type` | create_instance | ~64KB | Default "DEFAULT" | MEDIUM — Docker env | | ||
| 74 | | `pvp` | create_instance, edit_instance | ~8 chars | Checkbox (Some("on") | LOW | | ||
| 75 | | `world_source` | create_instance | ~64KB | CHECK constraint in SQL | LOW | | ||
| 76 | |||
| 77 | ### 3b. Multipart File Uploads | ||
| 78 | | Field | Handler | Max Size | Validation | Risk | | ||
| 79 | |-------|---------|----------|------------|------| | ||
| 80 | | File bytes (.jar) | upload_mod | 500MB per field | JAR metadata validation + class scan | **HIGH** — file write | | ||
| 81 | | File bytes (.zip) | upload_mod | 500MB per field | ZIP → extract .jar → JAR validation | **HIGH** — file write, nested ZIP | | ||
| 82 | | File bytes (.zip) | import_world | Multipart stream | `enclosed_name()` check | **HIGH** — world file write, zip slip edge cases | | ||
| 83 | | Filename (from Content-Disposition) | upload_mod | ~255 chars | `file_name()` strips dir components | **MEDIUM** — special chars preserved | | ||
| 84 | | Content-Type / boundary | upload_mod | ~255 chars | **NONE** — directly parsed for multer | **LOW** — multer handles internally | | ||
| 85 | |||
| 86 | ### 3c. Path Parameters | ||
| 87 | | Parameter | Handler(s) | Validation | Risk | | ||
| 88 | |-----------|-----------|------------|------| | ||
| 89 | | `id` (i64) | All /instances/{id}/*, /backups/{id}/* | Axum Path<i64> (integer only) | LOW — SQL uses params | | ||
| 90 | | `instance_id` (i64) | delete_mod | Axum Path<(i64, i64)> | LOW | | ||
| 91 | | `mod_id` (i64) | delete_mod | Axum Path<(i64, i64)> | LOW | | ||
| 92 | |||
| 93 | ### 3d. Cookie / Header Sources | ||
| 94 | | Source | Handler | What It Provides | Risk | | ||
| 95 | |--------|---------|-----------------|------| | ||
| 96 | | `Cookie: mcui_session` | require_auth (all protected routes) | Session ID → Account | **MEDIUM** — no server-side TTL | | ||
| 97 | | `Cookie: mcui_session` | logout | Session ID for deletion | **LOW** — missing auth guard | | ||
| 98 | | `Content-Type: boundary=` | upload_mod | Multipart parsing boundary | **LOW** | | ||
| 99 | |||
| 100 | --- | ||
| 101 | |||
| 102 | ## 4. Sinks (Dangerous Operations) | ||
| 103 | |||
| 104 | ### 4a. Filesystem Write Sinks | ||
| 105 | | Sink | File:Line | Data Source | Traversal Protection | | ||
| 106 | |------|-----------|-------------|---------------------| | ||
| 107 | | `std::fs::create_dir_all(&instance_dir)` | src/routes/instances.rs:205 | `form.name` (user input) | **NONE** — `Path::join` traverses on `..` | | ||
| 108 | | `std::fs::write(&dest, bytes)` | src/routes/mods.rs:98 | `safe_name` (filename from upload) | Partial — `file_name()` strips dir | | ||
| 109 | | `std::fs::File::create(&out_path)` | src/routes/instances.rs:210 | `file.enclosed_name()` (ZIP entry path) | Partial — `enclosed_name()` checks | | ||
| 110 | | `std::fs::File::create(&backup_path)` | src/routes/backups.rs:72 | `filename` (timestamp-based) | LOW — controlled by app | | ||
| 111 | | `std::fs::write(&props_path, ...)` | src/routes/instances.rs:396 | `form.seed` (user input) | **NONE** — content injection | | ||
| 112 | | `std::fs::remove_dir_all(&instance_dir)` | src/routes/instances.rs:392-393 | `name` from DB (originally user input) | **NONE** | | ||
| 113 | | `std::fs::remove_dir_all(&world_dir)` | src/routes/instances.rs:421 | Instance name from DB | Partial | | ||
| 114 | | `std::fs::remove_file(&mod_path)` | src/routes/mods.rs:226 | `filename` from DB | Partial | | ||
| 115 | |||
| 116 | ### 4b. Filesystem Read Sinks | ||
| 117 | | Sink | File:Line | Data Source | Risk | | ||
| 118 | |------|-----------|-------------|------| | ||
| 119 | | `std::fs::read(&backup_path)` | src/routes/backups.rs:106 | `filename` from DB | **MEDIUM** — filename from DB could be traversed | | ||
| 120 | | `std::fs::read_dir(&data_dir)` | src/routes/backups.rs:68 | Instance name from DB | LOW | | ||
| 121 | | `std::fs::read_to_string(&props_path)` | src/routes/instances.rs:386 | Instance name from DB | LOW | | ||
| 122 | | `std::fs::read_dir(&mods_dir)` | src/routes/mods.rs:175 | Instance name from DB | LOW | | ||
| 123 | | `std::fs::read(&path)` | src/routes/mods.rs:183 | File path from read_dir | LOW | | ||
| 124 | |||
| 125 | ### 4c. RCON Command Sinks | ||
| 126 | | Sink | File:Line | Data Source | Command Template | Risk | | ||
| 127 | |------|-----------|-------------|-----------------|------| | ||
| 128 | | `client.send_command_async(cmd)` | src/routes/players.rs:97 | `form.username` | `format!("whitelist add {}", form.username)` | **HIGH** — command injection | | ||
| 129 | | `client.send_command_async(cmd)` | src/routes/players.rs:97 | `form.username` | `format!("op {}", form.username)` | **HIGH** | | ||
| 130 | | `client.send_command_async(cmd)` | src/routes/players.rs:97 | `form.username` | `format!("ban {}", form.username)` | **HIGH** | | ||
| 131 | | `client.send_command_async("seed".into())` | src/routes/instances.rs:153 | Hardcoded "seed" | `"seed"` | LOW | | ||
| 132 | | `client.send_command_async("list".into())` | src/routes/instances.rs:251 | Hardcoded "list" | `"list"` | LOW | | ||
| 133 | |||
| 134 | ### 4d. Docker Control Sinks | ||
| 135 | | Sink | File:Line | Data Source | Risk | | ||
| 136 | |------|-----------|-------------|------| | ||
| 137 | | `docker.start_container(&instance)` | src/routes/instances.rs:235 | Full Instance struct | **HIGH** — all settings go to Docker env | | ||
| 138 | | `docker.stop_container()` | src/routes/instances.rs:195 | None | LOW | | ||
| 139 | | `recreate_container()` | src/docker.rs:62 | `instance.name`, `instance.motd`, all settings | **HIGH** — env injection | | ||
| 140 | | `env_strings` construction | src/docker.rs:87-113 | `instance.version`, `instance.motd`, `instance.rcon_password`, etc. | **HIGH** — no escaping | | ||
| 141 | |||
| 142 | ### 4e. SQL Query Sinks | ||
| 143 | All SQL queries use `rusqlite::params![]` macro which provides parameterized queries. SQL injection is not feasible. | ||
| 144 | |||
| 145 | ### 4f. Template Rendering Sinks | ||
| 146 | | Sink | File:Line | Data | Risk | | ||
| 147 | |------|-----------|------|------| | ||
| 148 | | `template.render().unwrap_or_default()` | Various | User-controlled strings (name, motd, etc.) | **LOW** — Askama is compile-time template engine, no XSS injection possible | | ||
| 149 | |||
| 150 | --- | ||
| 151 | |||
| 152 | ## 5. Hidden Control Channels | ||
| 153 | |||
| 154 | | Channel | File:Line | Processing | What It Controls | Security Concern | | ||
| 155 | |---------|-----------|-----------|-----------------|-----------------| | ||
| 156 | | `Cookie: mcui_session` value | src/auth.rs:107 | Raw `split(';')` then `split_once('=')` | Identity (session ID) | Naive cookie parsing, no OWS stripping per RFC 6265. Cookie value with `=` or `;` could confuse parsing | | ||
| 157 | | `Content-Type: boundary=` | src/routes/mods.rs:141-148 | Custom parsing: `split(';')` → `strip_prefix("boundary=")` → `trim_matches('"')` | Multer multipart boundary | No validation on boundary value; multer validates internally but custom extraction may produce empty boundary | | ||
| 158 | | Instance properties → Docker env | src/docker.rs:87-113 | Direct `format!()` calls | Docker container environment | **No escaping** — newlines in MOTD or seed can inject arbitrary env vars | | ||
| 159 | | Instance name → filesystem path | src/routes/instances.rs:205 | `server_directory.join("instances").join(&form.name)` | Filesystem paths | **No sanitization** — `..` in name causes directory traversal | | ||
| 160 | | RCON password from DB → rendered HTML | src/routes/instances.rs:313 | `row.get(15)` (rcon_password) | Exposed to all authenticated users | Any authenticated user can see the RCON password for any instance | | ||
| 161 | | Seed → server.properties | src/routes/instances.rs:386-397 | Direct `format!("level-seed={seed}")` | server.properties content | **No escaping** — can inject arbitrary properties lines | | ||
| 162 | | Username → RCON command | src/routes/players.rs:93-97 | `format!("whitelist add {}", form.username)` | Minecraft server state | **No sanitization** — can inject arbitrary RCON commands | | ||
| 163 | |||
| 164 | --- | ||
| 165 | |||
| 166 | ## 6. Exploit-Relevant Paths (Verified with Code Evidence) | ||
| 167 | |||
| 168 | ### X1: Instance Name Path Traversal → Arbitrary Directory Write | ||
| 169 | ``` | ||
| 170 | POST /instances/new → CreateInstanceForm.name = "../../../tmp/evil" | ||
| 171 | → src/routes/instances.rs:205: std::fs::create_dir_all(server_dir/instances/../../../tmp/evil/data) | ||
| 172 | → SQL INSERT at line 119 with name stored as-is | ||
| 173 | → Later: GET /instances/{id}/edit → edit_form reads name from DB | ||
| 174 | → Later: POST /instances/{id}/delete → remove_dir_all(server_dir/instances/../../../tmp/evil) | ||
| 175 | ``` | ||
| 176 | **Evidence:** `Path::join` on user-controlled `form.name` at line 173. No sanitization on name before `create_dir_all` at line 205. | ||
| 177 | |||
| 178 | ### X2: RCON Command Injection via Player Username | ||
| 179 | ``` | ||
| 180 | POST /players/op/add → PlayerAction.username = "attacker; op injected_player" | ||
| 181 | → src/routes/players.rs:93: format!("op {}", form.username) | ||
| 182 | → src/routes/players.rs:97: client.send_command_async("op attacker; op injected_player") | ||
| 183 | ``` | ||
| 184 | **Evidence:** `player_action!` macro at line 89 generates direct format string. No sanitization, no validation of username characters. | ||
| 185 | |||
| 186 | ### X3: MOTD Docker Environment Injection | ||
| 187 | ``` | ||
| 188 | POST /instances/{id}/edit → EditInstanceForm.motd = "hello\nEULA=FALSE\nONLINE_MODE=true" | ||
| 189 | → SQL UPDATE at line 186 (stored in DB) | ||
| 190 | → POST /instances/{id}/activate → reads Instance struct | ||
| 191 | → src/docker.rs:103: format!("MOTD={}", instance.motd) → env_strings.push("MOTD=hello\nEULA=FALSE\nONLINE_MODE=true") | ||
| 192 | → Docker container env includes injected vars | ||
| 193 | ``` | ||
| 194 | **Evidence:** `format!("MOTD={}", instance.motd)` at src/docker.rs:103. No escaping of newlines or special characters. | ||
| 195 | |||
| 196 | ### X4: Level-Seed Injection into server.properties | ||
| 197 | ``` | ||
| 198 | POST /instances/{id}/world/regenerate → RegenerateWorldForm.seed = "123\nlevel-type=FLAT\ngamerule=doDaylightCycle=false\n" | ||
| 199 | → src/routes/instances.rs:397: format!("level-seed={seed}") | ||
| 200 | → Written to server.properties via line replacement (line 390-393) | ||
| 201 | → Injected lines become new properties | ||
| 202 | ``` | ||
| 203 | **Evidence:** Line 397: `new_lines.push(format!("level-seed={seed}"));` No escaping. Java `.properties` format treats newline-separated lines as separate entries. | ||
| 204 | |||
| 205 | ### X5: Mod Filename Path Traversal (Partially Mitigated) | ||
| 206 | ``` | ||
| 207 | POST /instances/{id}/mods → multipart file with filename "../../../tmp/evil.jar" | ||
| 208 | → src/routes/mods.rs:98: process_jar_upload → Path::new(filename).file_name() | ||
| 209 | → "evil.jar" preserved, "../.." stripped by file_name() | ||
| 210 | → Mitigation: streps directory components BUT unsafe write to mods_dir/evil.jar | ||
| 211 | ``` | ||
| 212 | **Evidence:** `safe_name` derived from `file_name()` at line 96, then `mods_dir.join(&safe_name)` at line 98. | ||
| 213 | |||
| 214 | ### X6: No Server-Side Session TTL | ||
| 215 | ``` | ||
| 216 | POST /login → sets cookie Max-Age=86400 (24h) | ||
| 217 | src/auth.rs:108-115: require_auth middleware executes: | ||
| 218 | "SELECT ... FROM sessions s JOIN accounts a ON s.account_id = a.id | ||
| 219 | WHERE s.id = ?1" | ||
| 220 | No "AND s.created_at > datetime('now', '-1 day')" filter | ||
| 221 | → Sessions persist indefinitely in DB | ||
| 222 | ``` | ||
| 223 | **Evidence:** Session query at src/auth.rs:113 has no time-based filter. Session ID from leaked cookie works forever. | ||
| 224 | |||
| 225 | ### X7: No CSRF Protection on Any POST Endpoint | ||
| 226 | ``` | ||
| 227 | All POST handlers: No token validation, no origin/referer check, no SameSite=Strict | ||
| 228 | → CSRF on: /logout, /instances/new, /instances/{id}/delete, /players/op/add, etc. | ||
| 229 | ``` | ||
| 230 | **Evidence:** No CSRF middleware in src/routes/mod.rs. Cookie has SameSite=Lax (not Strict). All POST operations lack CSRF tokens. | ||
| 231 | |||
| 232 | ### X8: POST /logout Missing Auth Guard | ||
| 233 | ``` | ||
| 234 | POST /logout registered on outer Router NOT behind require_auth | ||
| 235 | → src/auth.rs:130-152: Reads Cookie header, extracts session ID, DELETEs from DB | ||
| 236 | → No authentication required for the request | ||
| 237 | ``` | ||
| 238 | **Evidence:** src/routes/mod.rs:20 registers logout on outer Router, before `.merge(protected)` at line 33. | ||
| 239 | |||
| 240 | ### X9: RCON Password Exposed to All Authenticated Users | ||
| 241 | ``` | ||
| 242 | GET /instances/{id}/edit → edit_form handler | ||
| 243 | → src/routes/instances.rs:313: rcon_password: row.get(15)? | ||
| 244 | → Rendered in HTML template (EditInstanceTemplate) | ||
| 245 | → Any authenticated user can see RCON password for any instance | ||
| 246 | ``` | ||
| 247 | **Evidence:** SELECT query at line 301 includes `rcon_password`. No superuser or ownership check before rendering. | ||
| 248 | |||
| 249 | ### X10: Weak RCON Password Generation | ||
| 250 | ``` | ||
| 251 | src/routes/instances.rs:523-529: generate_rcon_password() | ||
| 252 | → rand::thread_rng() (not OsRng/crypto RNG) | ||
| 253 | → 36^16 space (lowercase alphanumeric) | ||
| 254 | ``` | ||
| 255 | **Evidence:** `rand::thread_rng()` at line 524 is not cryptographically secure. 62^16 would be stronger. | ||
| 256 | |||
| 257 | ### X11: Docker Env Variables Set Without Sanitization (Multiple) | ||
| 258 | ALL instance fields injected into Docker container env without sanitization: | ||
| 259 | - `VERSION={version}` (src/docker.rs:93) | ||
| 260 | - `MAX_PLAYERS={max_players}` (src/docker.rs:95) | ||
| 261 | - `DIFFICULTY={difficulty}` (src/docker.rs:96) | ||
| 262 | - `RCON_PASSWORD={rcon_password}` (src/docker.rs:109) | ||
| 263 | - `MOTD={motd}` (src/docker.rs:103) | ||
| 264 | - `LEVEL_TYPE={level_type}` (src/docker.rs:111) | ||
| 265 | - `LEVEL_SEED={seed}` (src/docker.rs:116) | ||
| 266 | - `GAMEMODE={gamemode}` (src/docker.rs:112) | ||
| 267 | |||
| 268 | **Evidence:** src/docker.rs:87-116 — All `format!()` calls with direct `instance.*` values. No escaping. | ||
| 269 | |||
| 270 | ### X12: Backup Restore is No-Op (Data Loss Risk) | ||
| 271 | ``` | ||
| 272 | POST /backups/{id}/restore → restore_backup handler | ||
| 273 | → src/routes/backups.rs:88-96: Looks up backup in DB → redirects | ||
| 274 | → NO actual file restoration performed | ||
| 275 | ``` | ||
| 276 | **Evidence:** Handler at line 88 does SELECT then returns Redirect. No file copy/restore operations. | ||
| 277 | |||
| 278 | ### X13: Mod Validation DoS via Crafted JAR | ||
| 279 | ``` | ||
| 280 | POST /instances/{id}/mods → upload crafted JAR with malicious constant pool | ||
| 281 | → src/mods.rs:120: ZipArchive::new(data) | ||
| 282 | → src/mods.rs:185: scan_single_class(data) — walks constant pool | ||
| 283 | → No timeout, no recursion depth limit, no size limits on class files | ||
| 284 | → Crafted constant pool (e.g., giant CONSTANT_Utf8 entries) could cause OOM | ||
| 285 | ``` | ||
| 286 | **Evidence:** Loop at line 173 (`while i < cp_count`) has no iteration limit beyond cp_count. No bounds on CONSTANT_Utf8 length. | ||
| 287 | |||
| 288 | ### X14: Recursive ZIP Extraction Without Depth Limit | ||
| 289 | ``` | ||
| 290 | POST /instances/{id}/mods → upload ZIP containing JAR (which is also a ZIP) | ||
| 291 | → src/routes/mods.rs:131: process_zip_upload → extracts JAR bytes | ||
| 292 | → src/routes/mods.rs:149: process_jar_upload → validate_mod | ||
| 293 | → src/mods.rs:120: ZipArchive::new(data) — re-opens JAR as ZIP | ||
| 294 | → No depth counter; possible infinite recursion | ||
| 295 | ``` | ||
| 296 | **Evidence:** process_zip_upload calls process_jar_upload which calls validate_mod which opens the JAR as ZIP. No depth tracking. | ||
| 297 | |||
| 298 | --- | ||
| 299 | |||
| 300 | ## 7. Trust Boundary Crossings | ||
| 301 | |||
| 302 | | Boundary | Crossing Point | Direction | Data | Protection | | ||
| 303 | |----------|---------------|-----------|------|------------| | ||
| 304 | | HTTP → Auth | require_auth middleware (src/auth.rs:55) | Inbound | Session cookie → Account | Session lookup in DB | | ||
| 305 | | Auth → Instance Data | All protected handlers | Inbound | Authenticated request → CRUD | None (flat auth model) | | ||
| 306 | | WebUI → Filesystem | create_dir_all, fs::write, File::create | Outbound | User-controlled name/file content | Partial (mod filename) | | ||
| 307 | | WebUI → Docker | docker::start_container | Outbound | Instance settings | **NONE** | | ||
| 308 | | WebUI → RCON | RconClient::send_command_async | Outbound | Player username, commands | **NONE** | | ||
| 309 | | WebUI → Database | rusqlite execute/query | Outbound | Form data | Parameterized queries | | ||
| 310 | | WebUI → HTTP (Mojang) | reqwest::Client::get | Outbound | None (hardcoded URL) | Trusted URL | | ||
| 311 | | Upload → Validation | validate_mod (src/mods.rs:99) | Internal | Raw JAR bytes | Metadata + class scan | | ||
diff --git a/piolium/attack-surface/patch-bypass-summary.md b/piolium/attack-surface/patch-bypass-summary.md new file mode 100644 index 0000000..f7e7709 --- /dev/null +++ b/piolium/attack-surface/patch-bypass-summary.md | |||
| @@ -0,0 +1,305 @@ | |||
| 1 | # Patch History & Bypass Summary | ||
| 2 | |||
| 3 | **Audit ID:** `2026-07-28T23:03:56.306Z` | ||
| 4 | **Repository:** `/home/salmon/Documents/minecraft-ui` | ||
| 5 | **Branch:** `master` (HEAD `74d2ada`) | ||
| 6 | **Scan Window:** 500 commits / 60 days (all 6 commits captured) | ||
| 7 | |||
| 8 | --- | ||
| 9 | |||
| 10 | ## Repository Overview | ||
| 11 | |||
| 12 | Minecraft server management webUI built with **Rust/Axum** + **SQLite** + **Docker** (itzg/minecraft-server). | ||
| 13 | Manages multiple server instances, mods, backups, world files, whitelist/ops/bans. | ||
| 14 | |||
| 15 | **Total commits in window:** 6 | ||
| 16 | **Security-dedicated commits:** **0** — no commits explicitly fix a disclosed vulnerability. | ||
| 17 | All commits are feature additions, cleanup, or initial scaffolding. | ||
| 18 | |||
| 19 | --- | ||
| 20 | |||
| 21 | ## Commit Inventory & Security Relevance | ||
| 22 | |||
| 23 | | # | Commit | Date | Type | Security Relevance | | ||
| 24 | |---|--------|------|------|--------------------| | ||
| 25 | | 1 | `0c2e661` | 2026-07-12 | Architecture | Setup only. No code. | | ||
| 26 | | 2 | `ae7ef10` | 2026-07-12 | First Stable | Introduced auth (Argon2 hashing, sessions), RCON, Docker, instance CRUD, backups. | | ||
| 27 | | 3 | `9ad52d0` | 2026-07-12 | Second Stable | Added world import/export/regenerate; `properties.rs` parser. **Introduced Zip upload surface.** | | ||
| 28 | | 4 | `ce30a87` | 2026-07-12 | Third Stable | Removed dead code; added RCON readiness poll; **added `file_name()` basename extraction** for path safety. | | ||
| 29 | | 5 | `97920e4` | 2026-07-12 | Config | Auto-create `config.toml` from default if missing. | | ||
| 30 | | 6 | `74d2ada` | 2026-07-29 | Mod Feature | Mod validation engine + upload; whitelist toggle; dynamic Java tags; **session cookie debug logging**; removed `hx-boost` (was breaking multipart). | | ||
| 31 | |||
| 32 | **No advisory metadata exists for any commit.** Every change is a functional addition, not a security fix. | ||
| 33 | |||
| 34 | --- | ||
| 35 | |||
| 36 | ## Security-Relevant Diffs (Per Commit) | ||
| 37 | |||
| 38 | ### Commit `ae7ef10` — Initial Authentication System | ||
| 39 | |||
| 40 | **What was introduced:** | ||
| 41 | - Argon2 password hashing + verification | ||
| 42 | - Session-based auth with UUID v4 session IDs | ||
| 43 | - HttpOnly + SameSite=Lax cookie (max-age 86400s) | ||
| 44 | - Superuser guard on account management routes | ||
| 45 | - `require_auth` middleware applied to all protected routes | ||
| 46 | |||
| 47 | **Potential bypass vectors evaluated:** | ||
| 48 | |||
| 49 | | Vector | Assessment | | ||
| 50 | |--------|------------| | ||
| 51 | | Session prediction | UUID v4 is cryptographically random; no predictability. **Sound.** | | ||
| 52 | | Cookie theft via XSS | HttpOnly prevents JS access. But no `Secure` flag → cookie sent in cleartext over HTTP. **Bypassable** — network-level theft possible on LAN/public wifi. | | ||
| 53 | | Session fixation | New UUID generated on every login. No way to pre-set session ID. **Sound.** | | ||
| 54 | | SQL injection in session lookup | Uses parameterized queries. **Sound.** | | ||
| 55 | | Timing attack on password | Argon2 constant-time comparison. **Sound.** | | ||
| 56 | | Brute force | No rate limiting on `/login` endpoint. **Bypassable.** | | ||
| 57 | | Missing Secure flag | Cookie lacks `Secure`; SameSite=Lax helps but not on first request. **Bypassable.** | | ||
| 58 | |||
| 59 | **Verdict: Relocated** — the auth primitive is sound in isolation, but the missing `Secure` flag and absent rate limiting shift the attack surface to network interception and brute force. | ||
| 60 | |||
| 61 | --- | ||
| 62 | |||
| 63 | ### Commit `9ad52d0` — World Import (ZIP extraction) | ||
| 64 | |||
| 65 | **What was introduced:** | ||
| 66 | - `import_world()` — multipart ZIP upload → extracted to `data/world/` | ||
| 67 | - `export_world()` — directory zipped and streamed to browser | ||
| 68 | - `regenerate_world()` — deletes world, rewrites `server.properties` | ||
| 69 | |||
| 70 | **Security controls in import_world:** | ||
| 71 | - `file.enclosed_name()` from the `zip` crate prevents Zip Slip (path traversal) | ||
| 72 | |||
| 73 | **Potential bypass vectors:** | ||
| 74 | |||
| 75 | | Vector | Assessment | | ||
| 76 | |--------|------------| | ||
| 77 | | Zip Slip via symlink | `enclosed_name()` rejects entries whose path escapes the target dir. **Sound.** | | ||
| 78 | | Arbitrary file overwrite | Only files within `world_dir` can be written. **Sound.** | | ||
| 79 | | Zip bomb | No size limit on uploaded field. `bytes().await` loads entire upload into memory. **Bypassable** — OOM denial of service. | | ||
| 80 | | World seed manipulation | `regenerate_world()` writes user-supplied seed to `server.properties` without validation of seed content (e.g., newlines). Could inject extra properties. **Bypassable** — property injection. | | ||
| 81 | |||
| 82 | **Specific bypass — property injection via seed:** | ||
| 83 | ```rust | ||
| 84 | // In regenerate_world: | ||
| 85 | let seed = form.seed.unwrap_or_default(); | ||
| 86 | // ... | ||
| 87 | new_lines.push(format!("level-seed={seed}")); | ||
| 88 | ``` | ||
| 89 | A seed containing newlines (`level-seed=123\nmax-players=999`) injects additional properties. | ||
| 90 | |||
| 91 | **Verdict: Bypassable** — property injection via seed field; no upload size limits. | ||
| 92 | |||
| 93 | --- | ||
| 94 | |||
| 95 | ### Commit `ce30a87` — Path Safety via `file_name()` | ||
| 96 | |||
| 97 | **What was introduced:** | ||
| 98 | - Backup creation now uses `path.file_name()` to get basename before adding to ZIP | ||
| 99 | - Removed dead `container_status()` code | ||
| 100 | - RCON readiness polling (waits up to 60s for server to respond) | ||
| 101 | |||
| 102 | **Security-relevant change:** | ||
| 103 | The switch to `file_name()` prevents path traversal in backup content (files inside `data/` are only stored by basename): | ||
| 104 | ```rust | ||
| 105 | // Before (ae7ef10): | ||
| 106 | if let Some(name) = path.file_name() { ... } | ||
| 107 | |||
| 108 | // After (ce30a87 — same pattern, already present) | ||
| 109 | ``` | ||
| 110 | |||
| 111 | This was already safe in the initial commit — no change. The commit just removed unused template fields and the `ContainerStatus` enum. | ||
| 112 | |||
| 113 | **Verdict: Not a security fix.** No bypass applicable. | ||
| 114 | |||
| 115 | --- | ||
| 116 | |||
| 117 | ### Commit `74d2ada` — Mod Validation Engine | ||
| 118 | |||
| 119 | **What was introduced:** | ||
| 120 | - `src/mods.rs` — two-layer mod validation (metadata + class-file constant-pool scan) | ||
| 121 | - `src/routes/mods.rs` — CRUD routes for mod upload, rescan, delete | ||
| 122 | - Mods table in DB | ||
| 123 | - Whitelist enable/disable checkbox on edit page | ||
| 124 | - Dynamic Java image tag based on MC version | ||
| 125 | - Session cookie debug logging | ||
| 126 | |||
| 127 | **Mod validation policy:** | ||
| 128 | - Only mods that **explicitly declare** themselves client-only in metadata are rejected | ||
| 129 | - Class-file scanning is informational only — never causes rejection | ||
| 130 | - Many Forge mods that work on servers reference `net/minecraft/client/...` (rendering/GUI) | ||
| 131 | |||
| 132 | **Potential bypass vectors:** | ||
| 133 | |||
| 134 | | Vector | Assessment | | ||
| 135 | |--------|------------| | ||
| 136 | | Malicious mod upload | No signature verification, no integrity check. Any server-capable mod with malicious code is accepted. **Bypassable by design** — the validation only catches honest client-only declarations. | | ||
| 137 | | Metadata spoofing | A server-side malicious mod declaring `side=CLIENT` in `mods.toml` would be **rejected** even though it works on servers. But a server-side malicious mod with `side=BOTH` or no metadata sails through. **Sound for the declared policy.** | | ||
| 138 | | Class scan evasion | Mod obfuscation that doesn't reference `net/minecraft/server/...` could bypass detection, but the scan is informational-only, so no security boundary to bypass. | | ||
| 139 | | Zip content extraction | `process_zip_upload()` extracts nested JARs from ZIPs. Uses `file_name()` basename. **Sound** — no directory traversal. | | ||
| 140 | | Path traversal in filename | `safe_name = Path::new(filename).file_name()` strips directory components. **Sound.** | | ||
| 141 | | Arbitrary file write via mod | Once accepted, mod JAR is written to `mods_dir`. No validation that file is actually a valid JAR. But Docker container mounts that dir, so any file ends up in `/data/mods/`. **Sound** — jar extension enforced by route, no symlink. | | ||
| 142 | |||
| 143 | **Whitelist toggle fix:** | ||
| 144 | The `ENABLE_WHITELIST` env var was hardcoded `true` before this commit. Now it's configurable per-instance. This is a **feature addition** masquerading as a hardening — whitelist being off is a valid configuration choice, not a vulnerability. | ||
| 145 | |||
| 146 | **Session cookie debug logging:** | ||
| 147 | Added tracing lines that log `raw_cookies` to stderr. If tracing output is captured in production logs, session IDs could leak into log files. **Potential information disclosure.** | ||
| 148 | |||
| 149 | **Verdict: Sound for declared policy**, but the policy itself is weak — any malicious server-side mod is accepted. No bypass of the validation logic exists. | ||
| 150 | |||
| 151 | --- | ||
| 152 | |||
| 153 | ## Cross-Cutting Vulnerability Analysis | ||
| 154 | |||
| 155 | ### 1. RCON Command Injection [UNFIXED] | ||
| 156 | |||
| 157 | **Location:** `src/routes/players.rs` — `player_action!` macro | ||
| 158 | |||
| 159 | **Mechanism:** | ||
| 160 | ```rust | ||
| 161 | macro_rules! player_action { | ||
| 162 | ($name:ident, $command:expr) => { | ||
| 163 | async fn $name( | ||
| 164 | ... | ||
| 165 | Form(form): Form<PlayerAction>, | ||
| 166 | ) -> Result<Redirect, ...> { | ||
| 167 | ... | ||
| 168 | let cmd = format!($command, form.username); | ||
| 169 | // e.g., "whitelist add {}", "op {}", "ban {}", etc. | ||
| 170 | client.send_command_async(cmd.clone()).await | ||
| 171 | } | ||
| 172 | }; | ||
| 173 | } | ||
| 174 | ``` | ||
| 175 | |||
| 176 | **Impact:** Username is directly interpolated into an RCON command string. An attacker can inject additional RCON commands via a crafted username: | ||
| 177 | ``` | ||
| 178 | POST /players/whitelist/add | ||
| 179 | username=player1;say%20pwned | ||
| 180 | ``` | ||
| 181 | Would execute: `whitelist add player1;say pwned` — both commands run. | ||
| 182 | |||
| 183 | **Bypass:** No validation or sanitization of `form.username`. No character restrictions. RCON protocol sends commands as null-terminated strings; the `;` is interpreted by the Minecraft server's command parser, which chains commands. | ||
| 184 | |||
| 185 | **Cluster:** This is a command injection class bug. No commit has attempted to fix it. | ||
| 186 | |||
| 187 | --- | ||
| 188 | |||
| 189 | ### 2. Missing CSRF Protection [UNFIXED] | ||
| 190 | |||
| 191 | **Location:** All form handlers | ||
| 192 | |||
| 193 | **Mechanism:** No CSRF tokens on any POST form. `SameSite=Lax` on cookies provides partial protection (not for GET-based state changes, but all state changes use POST). However, `SameSite=Lax` sends cookies on top-level navigations from external sites, and `POST` forms from cross-origin without `SameSite=Strict` are still sent with cookies in some browser configurations. | ||
| 194 | |||
| 195 | **Impact:** Cross-site request forgery on all admin actions (create instance, delete, activate, upload mods, manage players, change passwords, etc.). | ||
| 196 | |||
| 197 | **Bypass:** No CSRF token validation exists anywhere. Attacker site can POST to any endpoint and the victim's browser will include the session cookie for top-level navigations. | ||
| 198 | |||
| 199 | **Cluster:** No commit addresses CSRF. | ||
| 200 | |||
| 201 | --- | ||
| 202 | |||
| 203 | ### 3. No Rate Limiting [UNFIXED] | ||
| 204 | |||
| 205 | **Location:** `/login` endpoint, all POST handlers | ||
| 206 | |||
| 207 | **Mechanism:** No rate limiting on any endpoint. Brute-force password attacks against the login form are unbounded. | ||
| 208 | |||
| 209 | **Impact:** Unlimited login attempts for credential brute force. | ||
| 210 | |||
| 211 | **Bypass:** Directly exploitable — no commit adds any throttling. | ||
| 212 | |||
| 213 | --- | ||
| 214 | |||
| 215 | ### 4. Information Disclosure — World Seed via RCON [DESIGN ISSUE] | ||
| 216 | |||
| 217 | **Location:** `src/routes/instances.rs` — `edit_form()` | ||
| 218 | |||
| 219 | **Mechanism:** The world seed is fetched via RCON `seed` command and displayed on the edit page. Any authenticated user can see the seed. | ||
| 220 | |||
| 221 | **Impact:** World seed is considered sensitive by some server operators (enables griefers to locate structures). The seed is also stored in DB and `server.properties`. | ||
| 222 | |||
| 223 | **Not a patch bypass** — no commit has attempted to restrict seed access. | ||
| 224 | |||
| 225 | --- | ||
| 226 | |||
| 227 | ### 5. Insecure Default Credentials [SOFT] | ||
| 228 | |||
| 229 | **Location:** `config.toml` | ||
| 230 | |||
| 231 | **Config:** | ||
| 232 | ```toml | ||
| 233 | initial_superuser = "admin" | ||
| 234 | initial_superuser_password = "admin" | ||
| 235 | ``` | ||
| 236 | |||
| 237 | **Mechanism:** Default credentials are hardcoded in the example config. On first run, these create the initial superuser. `password_change_required=1` is set, forcing a password change on first login. | ||
| 238 | |||
| 239 | **Bypass:** If an attacker gains access before the admin changes the password (e.g., upon deployment), they can log in with `admin:admin`. The forced password change can be bypassed if the attacker changes it to their own password. | ||
| 240 | |||
| 241 | **Verdict: Relocated** — the forced-change mitigates but doesn't eliminate the window of exposure. | ||
| 242 | |||
| 243 | --- | ||
| 244 | |||
| 245 | ### 6. Missing Authorization Boundaries [DESIGN ISSUE] | ||
| 246 | |||
| 247 | **Location:** All instance routes | ||
| 248 | |||
| 249 | **Mechanism:** All authenticated users can manage all instances. No per-instance access control. The `is_superuser` check only gates account management, not instance operations. | ||
| 250 | |||
| 251 | **Impact:** Any user can create, delete, modify, activate, upload mods to, or back up any instance. Single tenancy by design, but problematic if user accounts are meant to isolate administrators. | ||
| 252 | |||
| 253 | **Not a patch bypass** — no commit has attempted per-instance authorization. | ||
| 254 | |||
| 255 | --- | ||
| 256 | |||
| 257 | ## Bypass Verdict Summary | ||
| 258 | |||
| 259 | | # | Issue | Affected Since | Fix Status | Bypass Verdict | | ||
| 260 | |---|-------|---------------|------------|----------------| | ||
| 261 | | 1 | RCON command injection via username | `ae7ef10` | **UNFIXED** | Bypassable — no sanitization | | ||
| 262 | | 2 | Missing CSRF tokens | `ae7ef10` | **UNFIXED** | Bypassable — no protection | | ||
| 263 | | 3 | No login rate limiting | `ae7ef10` | **UNFIXED** | Bypassable — unbounded attempts | | ||
| 264 | | 4 | Cookie missing Secure flag | `ae7ef10` | **UNFIXED** | Bypassable — cleartext on HTTP | | ||
| 265 | | 5 | Property injection via world seed | `9ad52d0` | **UNFIXED** | Bypassable — no seed validation | | ||
| 266 | | 6 | Mod validation (policy weakness) | `74d2ada` | **BY DESIGN** | Sound — policy allows server mods | | ||
| 267 | | 7 | Zip bomb / no upload size limit | `9ad52d0` | **UNFIXED** | Bypassable — OOM DoS | | ||
| 268 | | 8 | Default admin credentials | `97920e4` | Relocated | Bypassable during initial window | | ||
| 269 | | 9 | Session log leakage | `74d2ada` | **UNFIXED** | Bypassable — if logs accessible | | ||
| 270 | | 10 | Static file serving without auth | `ae7ef10` | **BY DESIGN** | Sound — static dir is controlled | | ||
| 271 | |||
| 272 | --- | ||
| 273 | |||
| 274 | ## Cluster IDs | ||
| 275 | |||
| 276 | | Cluster ID | Commits | Theme | | ||
| 277 | |-----------|---------|-------| | ||
| 278 | | `C001` | `ae7ef10`, `9ad52d0`, `ce30a87`, `74d2ada` | Auth & Session Management | | ||
| 279 | | `C002` | `9ad52d0`, `ce30a87`, `74d2ada` | File Upload & Path Safety | | ||
| 280 | | `C003` | `9ad52d0`, `74d2ada` | Docker Container Configuration | | ||
| 281 | | `C004` | `74d2ada` | Mod Validation Engine | | ||
| 282 | |||
| 283 | --- | ||
| 284 | |||
| 285 | ## Undisclosed Fix Candidates | ||
| 286 | |||
| 287 | No commits in this repository appear to be silent (undisclosed) security fixes. All commits are feature additions or cleanup. The repository has no advisory history. | ||
| 288 | |||
| 289 | However, the following patterns suggest latent vulnerabilities that **should** have been fixed but remain open: | ||
| 290 | |||
| 291 | 1. **RCON injection in `player_action!` macro** — the most severe unfixed issue (CWE-77) | ||
| 292 | 2. **Property injection in `regenerate_world`** — seed field allows injecting arbitrary `server.properties` lines | ||
| 293 | 3. **World import ZIP bomb** — no size limit on multipart field in `import_world()` | ||
| 294 | |||
| 295 | --- | ||
| 296 | |||
| 297 | ## Conclusion | ||
| 298 | |||
| 299 | - **0 security patches** found in the commit history. | ||
| 300 | - **3 critical unfixed vulnerabilities** (RCON injection, CSRF, rate limiting). | ||
| 301 | - **3 moderate unfixed issues** (cookie Secure flag, property injection, ZIP bomb). | ||
| 302 | - **2 design-level weaknesses** (mod validation policy, static file serving). | ||
| 303 | - No existing fix to bypass — the security controls that exist were built in from the initial commit and their bypassability is assessed above. | ||
| 304 | |||
| 305 | The most actionable finding is **RCON command injection** (CWE-77) in the player management routes, which allows any authenticated user to execute arbitrary Minecraft server commands through crafted usernames. | ||
diff --git a/piolium/attack-surface/public-routes-authz-matrix.md b/piolium/attack-surface/public-routes-authz-matrix.md new file mode 100644 index 0000000..d8f9389 --- /dev/null +++ b/piolium/attack-surface/public-routes-authz-matrix.md | |||
| @@ -0,0 +1,92 @@ | |||
| 1 | # Authorization Matrix — Public Routes & Access Control | ||
| 2 | |||
| 3 | **Coverage stats**: 37 endpoints discovered | 1 endpoint with no guard detected (missing-guard) | 24 endpoints taking object-id parameter | ||
| 4 | **Coverage gaps**: None — all routes are statically registered in the axum Router. No reflection-based or dynamically-registered handlers. | ||
| 5 | |||
| 6 | ## Legend | ||
| 7 | |||
| 8 | | Column | Description | | ||
| 9 | |--------|-------------| | ||
| 10 | | Layer-1 Guard | Declarative decorators/annotations (none in this Rust axum app) | | ||
| 11 | | In-body Authz | Handler-level checks (superuser guard, ownership filters, etc.) | | ||
| 12 | | Router/Middleware Guard | `require_auth` middleware applied at the router-group level | | ||
| 13 | | Hidden Control Channels | Request-derived values that influence auth, routing, or identity | | ||
| 14 | | Object-ID Param | Path parameter that identifies a resource (potential IDOR vector) | | ||
| 15 | | Ownership Check? | Does the handler filter queries by the acting user's identity? | | ||
| 16 | | Tenant Filter? | Does the handler filter by tenant/org scope? | | ||
| 17 | | Expected Scope | `public` / `authenticated` / `role:superuser` / `unknown` | | ||
| 18 | |||
| 19 | ## Matrix | ||
| 20 | |||
| 21 | | # | Method | Path | Handler (file:line) | Layer-1 Guard | In-body Authz | Router/Middleware Guard | Hidden Control Channels | Object-ID Param | Ownership Check? | Tenant Filter? | Expected Scope | Notes | | ||
| 22 | |---|--------|------|---------------------|---------------|---------------|-------------------------|-------------------------|------------------|------------------|----------------|----------------|-------| | ||
| 23 | | 1 | GET | `/login` | `login_page` (src/routes/mod.rs:53) | none | none | none | none | none | N/A | N/A | **public** | Login page — no session required | | ||
| 24 | | 2 | POST | `/login` | `authenticate` (src/auth.rs:34) | none | none | none | Cookie set on response, no proxy-derived identity | none | N/A | N/A | **public** | Auth entry point — credentials → session | | ||
| 25 | | 3 | POST | `/logout` | `logout` (src/auth.rs:130) | none | none | **missing guard** — not behind `require_auth` | Cookie header parsed for session ID | none | N/A | N/A | **public (anomaly: expected authenticated)** | ⚠️ Missing guard — see p4-008 | | ||
| 26 | | 4 | GET | `/static/*` | `ServeDir` (tower-http, src/routes/mod.rs:28) | none | none | none | File path after `/static/` | file path | N/A | N/A | **public** | Static files (CSS, JS only) | | ||
| 27 | | 5 | GET | `/` | `dashboard` (src/routes/dashboard.rs:19) | none | none | `require_auth` | Cookie → CurrentUser via `FromRequestParts` | none | N/A | N/A | **authenticated** | Dashboard — requires valid session | | ||
| 28 | | 6 | GET | `/instances` | `list_instances` (src/routes/instances.rs:44) | none | none | `require_auth` | Cookie → CurrentUser | none | N/A (all instances shared) | N/A (single-tenant) | **authenticated** | Lists all instances — no user scoping | | ||
| 29 | | 7 | GET | `/instances/new` | `create_form` (src/routes/instances.rs:70) | none | none | `require_auth` | Cookie → CurrentUser | none | N/A | N/A | **authenticated** | Instance creation form | | ||
| 30 | | 8 | POST | `/instances/new` | `create_instance` (src/routes/instances.rs:87) | none | none | `require_auth` | Cookie → CurrentUser | none | N/A | N/A | **authenticated** | Creates instance (name used as dir name) | | ||
| 31 | | 9 | GET | `/instances/{id}/edit` | `edit_form` (src/routes/instances.rs:129) | none | none | `require_auth` | Cookie → CurrentUser | `id` (i64, path) | **No** — queries by id without user filter | N/A | **authenticated** | Edit form — any auth user can edit any instance | | ||
| 32 | | 10 | POST | `/instances/{id}/edit` | `edit_instance` (src/routes/instances.rs:176) | none | none | `require_auth` | Cookie → CurrentUser | `id` (i64, path) | **No** | N/A | **authenticated** | Updates any instance by id | | ||
| 33 | | 11 | POST | `/instances/{id}/delete` | `delete_instance` (src/routes/instances.rs:198) | none | none | `require_auth` | Cookie → CurrentUser | `id` (i64, path) | **No** | N/A | **authenticated** | Deletes any instance — destructive | | ||
| 34 | | 12 | POST | `/instances/{id}/activate` | `activate_instance` (src/routes/instances.rs:217) | none | none | `require_auth` | Cookie → CurrentUser | `id` (i64, path) | **No** | N/A | **authenticated** | Activates instance → Docker container start | | ||
| 35 | | 13 | POST | `/instances/deactivate` | `deactivate_instance` (src/routes/instances.rs:278) | none | none | `require_auth` | Cookie → CurrentUser | none | N/A | N/A | **authenticated** | Deactivates active instance | | ||
| 36 | | 14 | GET | `/instances/{id}/world/export` | `export_world` (src/routes/instances.rs:288) | none | none | `require_auth` | Cookie → CurrentUser | `id` (i64, path) | **No** | N/A | **authenticated** | Exports world ZIP — file read | | ||
| 37 | | 15 | POST | `/instances/{id}/world/import` | `import_world` (src/routes/instances.rs:328) | none | none | `require_auth` | Cookie → CurrentUser; Multipart Content-Type/boundary | `id` (i64, path) | **No** | N/A | **authenticated** | Imports world ZIP — file write | | ||
| 38 | | 16 | POST | `/instances/{id}/world/regenerate` | `regenerate_world` (src/routes/instances.rs:360) | none | none | `require_auth` | Cookie → CurrentUser | `id` (i64, path) | **No** | N/A | **authenticated** | Regenerates world — file write | | ||
| 39 | | 17 | GET | `/players` | `manage_players` (src/routes/players.rs:28) | none | none | `require_auth` | Cookie → CurrentUser | none | N/A | N/A | **authenticated** | Player management page | | ||
| 40 | | 18 | POST | `/players/whitelist/add` | `add_whitelist` (src/routes/players.rs:89, macro) | none | none | `require_auth` | Cookie → CurrentUser; RCON password from DB | none (username in form) | N/A | N/A | **authenticated** | RCON command — any auth user can manage whitelist | | ||
| 41 | | 19 | POST | `/players/whitelist/remove` | `remove_whitelist` (src/routes/players.rs:89, macro) | none | none | `require_auth` | Same as #18 | none | N/A | N/A | **authenticated** | RCON command | | ||
| 42 | | 20 | POST | `/players/op/add` | `add_op` (src/routes/players.rs:89, macro) | none | none | `require_auth` | Same as #18 | none | N/A | N/A | **authenticated** | RCON command — any auth user can op players | | ||
| 43 | | 21 | POST | `/players/op/remove` | `remove_op` (src/routes/players.rs:89, macro) | none | none | `require_auth` | Same as #18 | none | N/A | N/A | **authenticated** | RCON command | | ||
| 44 | | 22 | POST | `/players/ban/add` | `add_ban` (src/routes/players.rs:89, macro) | none | none | `require_auth` | Same as #18 | none | N/A | N/A | **authenticated** | RCON command — any auth user can ban players | | ||
| 45 | | 23 | POST | `/players/ban/remove` | `remove_ban` (src/routes/players.rs:89, macro) | none | none | `require_auth` | Same as #18 | none | N/A | N/A | **authenticated** | RCON command | | ||
| 46 | | 24 | GET | `/instances/{id}/backups` | `list_backups` (src/routes/backups.rs:30) | none | none | `require_auth` | Cookie → CurrentUser | `id` (i64, path) | **No** | N/A | **authenticated** | Lists backups for any instance | | ||
| 47 | | 25 | POST | `/instances/{id}/backups/create` | `create_backup` (src/routes/backups.rs:54) | none | none | `require_auth` | Cookie → CurrentUser | `id` (i64, path) | **No** | N/A | **authenticated** | Creates backup — file write | | ||
| 48 | | 26 | POST | `/backups/{id}/restore` | `restore_backup` (src/routes/backups.rs:88) | none | none | `require_auth` | Cookie → CurrentUser | `id` (i64, path) | **No** | N/A | **authenticated** | STUB — no-op redirect | | ||
| 49 | | 27 | GET | `/backups/{id}/download` | `download_backup` (src/routes/backups.rs:93) | none | none | `require_auth` | Cookie → CurrentUser | `id` (i64, path) | **No** | N/A | **authenticated** | Downloads backup ZIP — file read | | ||
| 50 | | 28 | GET | `/instances/{id}/mods` | `list_mods` (src/routes/mods.rs:42) | none | none | `require_auth` | Cookie → CurrentUser | `id` (i64, path) | **No** | N/A | **authenticated** | Lists mods for any instance | | ||
| 51 | | 29 | POST | `/instances/{id}/mods` | `upload_mod` (src/routes/mods.rs:86) | none | none | `require_auth` | Cookie → CurrentUser; Content-Type boundary for multer | `id` (i64, path) | **No** | N/A | **authenticated** | Uploads mod — file write, JAR parsing | | ||
| 52 | | 30 | POST | `/instances/{id}/mods/{mod_id}/delete` | `delete_mod` (src/routes/mods.rs:188) | none | none | `require_auth` | Cookie → CurrentUser | `instance_id`, `mod_id` (path) | **No** | N/A | **authenticated** | Deletes mod — file delete | | ||
| 53 | | 31 | POST | `/instances/{id}/mods/rescan` | `rescan_mods` (src/routes/mods.rs:163) | none | none | `require_auth` | Cookie → CurrentUser | `id` (i64, path) | **No** | N/A | **authenticated** | Rescans mods directory — file read + DB insert | | ||
| 54 | | 32 | GET | `/accounts` | `manage_accounts` (src/routes/accounts.rs:60) | none | `if !account.is_superuser` (line 107) | `require_auth` | Cookie → CurrentUser | none | N/A | N/A | **role:superuser** | Account list — superuser inline check | | ||
| 55 | | 33 | POST | `/accounts/create` | `create_account` (src/routes/accounts.rs:95) | none | `if !account.is_superuser` (line 145) | `require_auth` | Cookie → CurrentUser | none (form fields) | N/A | N/A | **role:superuser** | Create account — superuser inline check | | ||
| 56 | | 34 | POST | `/accounts/delete` | `delete_account` (src/routes/accounts.rs:111) | none | `if !account.is_superuser` (line 167) | `require_auth` | Cookie → CurrentUser | `form.id` (body) | N/A | N/A | **role:superuser** | Delete account — superuser inline check | | ||
| 57 | | 35 | POST | `/accounts/set-password` | `set_password` (src/routes/accounts.rs:142) | none | `if !account.is_superuser` (line 204) | `require_auth` | Cookie → CurrentUser | `form.id` (body) | N/A | N/A | **role:superuser** | Set password — superuser inline check | | ||
| 58 | | 36 | GET | `/change-password` | `change_password_page` (src/routes/mod.rs:58) | none | none | `require_auth` | Cookie → CurrentUser (`FromRequestParts`) | none | N/A | N/A | **authenticated** | Password change page — requires auth | | ||
| 59 | | 37 | POST | `/change-password` | `accounts::change_password` (src/routes/accounts.rs:163) | none | none | `require_auth` | Cookie → CurrentUser | none | Self (uses `account.id` from auth) | N/A | **authenticated** | Change own password — verifies current password | | ||
| 60 | | 38 | POST | `/accounts/change-own-password` | `change_own_password_from_accounts` (src/routes/accounts.rs:163) | none | none | `require_auth` | Cookie → CurrentUser | none | Self (uses `account.id` from auth) | N/A | **authenticated** | Duplicate of #37, same handler | | ||
| 61 | |||
| 62 | ## Anomaly Summary | ||
| 63 | |||
| 64 | | # | Anomaly | Routes Affected | Severity | Existing Draft | | ||
| 65 | |---|---------|-----------------|----------|----------------| | ||
| 66 | | A1 | **Missing guard** — POST /logout registered on outer router, not behind `require_auth` middleware | #3 | MEDIUM | p4-008 | | ||
| 67 | | A2 | **Fragile superuser pattern** — superuser check done inline in handler body via `if !account.is_superuser` instead of middleware/extractor | #32, #33, #34, #35 | LOW (pattern concern) | p4-025 | | ||
| 68 | | A3 | **No ownership/tenant scoping** — all authenticated users have equal access to ALL instances, mods, backups, player management | #6–#31 | INFO (by design: single-user/small-team) | — | | ||
| 69 | | A4 | **No CSRF protection** — all POST endpoints lack CSRF tokens | #2, #3, #8, #10, #11, #12, #13, #15, #16, #18–#23, #25, #26, #29, #30, #31, #33–#35, #37, #38 | HIGH | p4-003 | | ||
| 70 | | A5 | **Hidden control channel** — `Content-Type` boundary parsed for multer multipart (attacker-controlled boundary string) | #29 | LOW | — | | ||
| 71 | | A6 | **Hidden control channel** — Cookie header parsing relies on raw string splitting (no validation of session ID format) | All protected routes | LOW | p4-019 | | ||
| 72 | |||
| 73 | ## Hidden Control Channels Map | ||
| 74 | |||
| 75 | | Channel | Where Used | What It Controls | Can It Bypass Auth? | | ||
| 76 | |---------|-----------|-----------------|---------------------| | ||
| 77 | | `Cookie: mcui_session` | `require_auth` (src/auth.rs:55), `logout` (src/auth.rs:130) | Identity (session lookup → Account) | No — session must exist in DB | | ||
| 78 | | `Content-Type: boundary=` | `upload_mod` (src/routes/mods.rs:86) | Multipart parsing boundary | No — only affects file parsing | | ||
| 79 | | Path parameters `{id}` | All `/instances/{id}/*`, `/backups/{id}/*` routes | Resource selection | No — but no ownership check on selected resource | | ||
| 80 | | Form fields | All POST handlers | Operation parameters | No — parametrized queries prevent injection | | ||
| 81 | | X-Forwarded-* Headers | **Not read anywhere** | N/A | N/A (safe — not trusted) | | ||
| 82 | | X-HTTP-Method-Override | **Not supported** | N/A | N/A (safe) | | ||
| 83 | | X-Admin / X-Internal | **Not supported** | N/A | N/A (safe) | | ||
| 84 | |||
| 85 | ## Authz Architecture Summary | ||
| 86 | |||
| 87 | - **Authentication**: Cookie-based session (UUIDv4) via `require_auth` middleware (src/auth.rs:55) | ||
| 88 | - **Authorization model**: Flat — all authenticated users are equal. No per-resource ownership, no roles beyond `is_superuser`. | ||
| 89 | - **Superuser gating**: Inline `if !account.is_superuser` checks in handler bodies (src/routes/accounts.rs) | ||
| 90 | - **Static file serving**: Completely public | ||
| 91 | - **Logout**: Public (missing guard — only by design because session IDs are unpredictable UUIDs) | ||
| 92 | - **Session management**: 24h max-age cookie, NO server-side TTL check, NO Secure flag | ||
diff --git a/piolium/attack-surface/sbom.json b/piolium/attack-surface/sbom.json new file mode 100644 index 0000000..3f7d7eb --- /dev/null +++ b/piolium/attack-surface/sbom.json | |||
| @@ -0,0 +1,282 @@ | |||
| 1 | { | ||
| 2 | "target": "salmon/minecraft-ui", | ||
| 3 | "generated_at": "2025-07-29T01:15:00Z", | ||
| 4 | "components": [ | ||
| 5 | { | ||
| 6 | "name": "rust", | ||
| 7 | "category": "runtime", | ||
| 8 | "ecosystem": null, | ||
| 9 | "version": "edition 2024", | ||
| 10 | "relationship": "direct", | ||
| 11 | "purpose": "Language runtime for the application", | ||
| 12 | "evidence": ["Cargo.toml:3 edition = \"2024\""], | ||
| 13 | "security_relevant": false | ||
| 14 | }, | ||
| 15 | { | ||
| 16 | "name": "axum", | ||
| 17 | "category": "framework", | ||
| 18 | "ecosystem": "crates.io", | ||
| 19 | "version": "0.8.9", | ||
| 20 | "relationship": "direct", | ||
| 21 | "purpose": "HTTP web framework (router, middleware, extractors)", | ||
| 22 | "evidence": ["Cargo.toml:7", "src/routes/mod.rs:3 use"], | ||
| 23 | "security_relevant": true | ||
| 24 | }, | ||
| 25 | { | ||
| 26 | "name": "multer", | ||
| 27 | "category": "package", | ||
| 28 | "ecosystem": "crates.io", | ||
| 29 | "version": "3.1.0", | ||
| 30 | "relationship": "direct", | ||
| 31 | "purpose": "Multipart/form-data parser for file uploads", | ||
| 32 | "evidence": ["Cargo.toml:8", "src/routes/mods.rs:106 use multer::Multipart"], | ||
| 33 | "security_relevant": true | ||
| 34 | }, | ||
| 35 | { | ||
| 36 | "name": "tokio", | ||
| 37 | "category": "package", | ||
| 38 | "ecosystem": "crates.io", | ||
| 39 | "version": "1.52.3", | ||
| 40 | "relationship": "direct", | ||
| 41 | "purpose": "Async runtime (TCP, timers, tasks)", | ||
| 42 | "evidence": ["Cargo.toml:9", "src/main.rs:15 #[tokio::main]"], | ||
| 43 | "security_relevant": true | ||
| 44 | }, | ||
| 45 | { | ||
| 46 | "name": "rusqlite", | ||
| 47 | "category": "package", | ||
| 48 | "ecosystem": "crates.io", | ||
| 49 | "version": "0.40.1", | ||
| 50 | "relationship": "direct", | ||
| 51 | "purpose": "SQLite database interface", | ||
| 52 | "evidence": ["Cargo.toml:11", "src/db.rs:2 use"], | ||
| 53 | "security_relevant": true | ||
| 54 | }, | ||
| 55 | { | ||
| 56 | "name": "bollard", | ||
| 57 | "category": "package", | ||
| 58 | "ecosystem": "crates.io", | ||
| 59 | "version": "0.17.1", | ||
| 60 | "relationship": "direct", | ||
| 61 | "purpose": "Docker Engine API client", | ||
| 62 | "evidence": ["Cargo.toml:12", "src/docker.rs:2-8 use bollard::*"], | ||
| 63 | "security_relevant": true | ||
| 64 | }, | ||
| 65 | { | ||
| 66 | "name": "argon2", | ||
| 67 | "category": "package", | ||
| 68 | "ecosystem": "crates.io", | ||
| 69 | "version": "0.5.3", | ||
| 70 | "relationship": "direct", | ||
| 71 | "purpose": "Password hashing (Argon2id algorithm)", | ||
| 72 | "evidence": ["Cargo.toml:17", "src/auth.rs:1-2 use argon2::*"], | ||
| 73 | "security_relevant": true | ||
| 74 | }, | ||
| 75 | { | ||
| 76 | "name": "zip", | ||
| 77 | "category": "package", | ||
| 78 | "ecosystem": "crates.io", | ||
| 79 | "version": "8.6.0", | ||
| 80 | "relationship": "direct", | ||
| 81 | "purpose": "ZIP archive reading/writing (world export/import, mods, backups)", | ||
| 82 | "evidence": ["Cargo.toml:20", "src/mods.rs:8 use zip::ZipArchive"], | ||
| 83 | "security_relevant": true | ||
| 84 | }, | ||
| 85 | { | ||
| 86 | "name": "reqwest", | ||
| 87 | "category": "package", | ||
| 88 | "ecosystem": "crates.io", | ||
| 89 | "version": "0.13.4", | ||
| 90 | "relationship": "direct", | ||
| 91 | "purpose": "HTTP client for Mojang version manifest API", | ||
| 92 | "evidence": ["Cargo.toml:26", "src/routes/instances.rs:280"], | ||
| 93 | "security_relevant": false | ||
| 94 | }, | ||
| 95 | { | ||
| 96 | "name": "cookie", | ||
| 97 | "category": "package", | ||
| 98 | "ecosystem": "crates.io", | ||
| 99 | "version": "0.18.1", | ||
| 100 | "relationship": "direct", | ||
| 101 | "purpose": "Cookie parsing utilities", | ||
| 102 | "evidence": ["Cargo.toml:28"], | ||
| 103 | "security_relevant": true | ||
| 104 | }, | ||
| 105 | { | ||
| 106 | "name": "axum-extra", | ||
| 107 | "category": "package", | ||
| 108 | "ecosystem": "crates.io", | ||
| 109 | "version": "0.12.6", | ||
| 110 | "relationship": "direct", | ||
| 111 | "purpose": "Extra axum utilities including cookie private jar", | ||
| 112 | "evidence": ["Cargo.toml:29", "features = [\"cookie-private\"]"], | ||
| 113 | "security_relevant": true | ||
| 114 | }, | ||
| 115 | { | ||
| 116 | "name": "serde", | ||
| 117 | "category": "package", | ||
| 118 | "ecosystem": "crates.io", | ||
| 119 | "version": "1.0.228", | ||
| 120 | "relationship": "direct", | ||
| 121 | "purpose": "Serialization/deserialization framework", | ||
| 122 | "evidence": ["Cargo.toml:13"], | ||
| 123 | "security_relevant": false | ||
| 124 | }, | ||
| 125 | { | ||
| 126 | "name": "askama", | ||
| 127 | "category": "framework", | ||
| 128 | "ecosystem": "crates.io", | ||
| 129 | "version": "0.16.0", | ||
| 130 | "relationship": "direct", | ||
| 131 | "purpose": "Compile-time template engine", | ||
| 132 | "evidence": ["Cargo.toml:10", "src/routes/*.rs: use askama::Template"], | ||
| 133 | "security_relevant": false | ||
| 134 | }, | ||
| 135 | { | ||
| 136 | "name": "tower-http", | ||
| 137 | "category": "package", | ||
| 138 | "ecosystem": "crates.io", | ||
| 139 | "version": "0.7.0", | ||
| 140 | "relationship": "direct", | ||
| 141 | "purpose": "HTTP middleware (static file serving via ServeDir)", | ||
| 142 | "evidence": ["Cargo.toml:15", "src/routes/mod.rs:6"], | ||
| 143 | "security_relevant": false | ||
| 144 | }, | ||
| 145 | { | ||
| 146 | "name": "chrono", | ||
| 147 | "category": "package", | ||
| 148 | "ecosystem": "crates.io", | ||
| 149 | "version": "0.4.45", | ||
| 150 | "relationship": "direct", | ||
| 151 | "purpose": "Date/time handling for backup timestamps", | ||
| 152 | "evidence": ["Cargo.toml:27", "src/routes/backups.rs:69"], | ||
| 153 | "security_relevant": false | ||
| 154 | }, | ||
| 155 | { | ||
| 156 | "name": "uuid", | ||
| 157 | "category": "package", | ||
| 158 | "ecosystem": "crates.io", | ||
| 159 | "version": "1.23.4", | ||
| 160 | "relationship": "direct", | ||
| 161 | "purpose": "UUID generation for session IDs", | ||
| 162 | "evidence": ["Cargo.toml:18", "src/auth.rs:82"], | ||
| 163 | "security_relevant": false | ||
| 164 | }, | ||
| 165 | { | ||
| 166 | "name": "tempfile", | ||
| 167 | "category": "package", | ||
| 168 | "ecosystem": "crates.io", | ||
| 169 | "version": "3.27.0", | ||
| 170 | "relationship": "direct", | ||
| 171 | "purpose": "Temporary file/directory management", | ||
| 172 | "evidence": ["Cargo.toml:21"], | ||
| 173 | "security_relevant": false | ||
| 174 | }, | ||
| 175 | { | ||
| 176 | "name": "rand", | ||
| 177 | "category": "package", | ||
| 178 | "ecosystem": "crates.io", | ||
| 179 | "version": "0.8.7", | ||
| 180 | "relationship": "direct", | ||
| 181 | "purpose": "Random number generation for RCON passwords", | ||
| 182 | "evidence": ["Cargo.toml:19", "src/routes/instances.rs:263"], | ||
| 183 | "security_relevant": true | ||
| 184 | }, | ||
| 185 | { | ||
| 186 | "name": "tracing", | ||
| 187 | "category": "package", | ||
| 188 | "ecosystem": "crates.io", | ||
| 189 | "version": "0.1.44", | ||
| 190 | "relationship": "direct", | ||
| 191 | "purpose": "Logging and instrumentation framework", | ||
| 192 | "evidence": ["Cargo.toml:22", "src/main.rs:3 use tracing_subscriber"], | ||
| 193 | "security_relevant": false | ||
| 194 | }, | ||
| 195 | { | ||
| 196 | "name": "serde_json", | ||
| 197 | "category": "package", | ||
| 198 | "ecosystem": "crates.io", | ||
| 199 | "version": "1.0.150", | ||
| 200 | "relationship": "direct", | ||
| 201 | "purpose": "JSON parsing (Fabric mod metadata, Mojang API)", | ||
| 202 | "evidence": ["Cargo.toml:14", "src/mods.rs: parse_fabric_metadata"], | ||
| 203 | "security_relevant": false | ||
| 204 | }, | ||
| 205 | { | ||
| 206 | "name": "toml", | ||
| 207 | "category": "package", | ||
| 208 | "ecosystem": "crates.io", | ||
| 209 | "version": "1.1.2+spec-1.1.0", | ||
| 210 | "relationship": "direct", | ||
| 211 | "purpose": "TOML config parsing + Forge mod metadata parsing", | ||
| 212 | "evidence": ["Cargo.toml:16", "src/config.rs:9", "src/mods.rs: parse_forge_metadata"], | ||
| 213 | "security_relevant": false | ||
| 214 | }, | ||
| 215 | { | ||
| 216 | "name": "futures-util", | ||
| 217 | "category": "package", | ||
| 218 | "ecosystem": "crates.io", | ||
| 219 | "version": "0.3.32", | ||
| 220 | "relationship": "direct", | ||
| 221 | "purpose": "Async stream utilities for Docker image pull", | ||
| 222 | "evidence": ["Cargo.toml:30", "src/docker.rs:6 use futures_util::StreamExt"], | ||
| 223 | "security_relevant": false | ||
| 224 | }, | ||
| 225 | { | ||
| 226 | "name": "Docker daemon", | ||
| 227 | "category": "binary", | ||
| 228 | "ecosystem": null, | ||
| 229 | "version": "unknown (local)", | ||
| 230 | "relationship": "direct", | ||
| 231 | "purpose": "Container runtime for Minecraft server instances", | ||
| 232 | "evidence": ["src/docker.rs:28 Docker::connect_with_local_defaults()"], | ||
| 233 | "security_relevant": true | ||
| 234 | }, | ||
| 235 | { | ||
| 236 | "name": "itzg/minecraft-server", | ||
| 237 | "category": "container-os", | ||
| 238 | "ecosystem": "docker", | ||
| 239 | "version": "java8 / java17 / java21 (dynamic per version)", | ||
| 240 | "relationship": "direct", | ||
| 241 | "purpose": "Minecraft server Docker image (Java + Minecraft server jar)", | ||
| 242 | "evidence": ["src/docker.rs:34-43 java_tag()", "minecraft/docker-compose.yml"], | ||
| 243 | "security_relevant": true | ||
| 244 | }, | ||
| 245 | { | ||
| 246 | "name": "piston-meta.mojang.com", | ||
| 247 | "category": "external-service", | ||
| 248 | "ecosystem": null, | ||
| 249 | "version": "N/A (API)", | ||
| 250 | "relationship": "direct", | ||
| 251 | "purpose": "Minecraft version manifest API", | ||
| 252 | "evidence": ["src/routes/instances.rs:280"], | ||
| 253 | "security_relevant": false | ||
| 254 | }, | ||
| 255 | { | ||
| 256 | "name": "htmx.min.js", | ||
| 257 | "category": "vendored", | ||
| 258 | "ecosystem": null, | ||
| 259 | "version": "unknown (vendored)", | ||
| 260 | "relationship": "direct", | ||
| 261 | "purpose": "HTMX JavaScript library for dynamic HTML", | ||
| 262 | "evidence": ["static/htmx.min.js"], | ||
| 263 | "security_relevant": false | ||
| 264 | }, | ||
| 265 | { | ||
| 266 | "name": "SQLite", | ||
| 267 | "category": "datastore", | ||
| 268 | "ecosystem": null, | ||
| 269 | "version": "bundled (via rusqlite bundled feature)", | ||
| 270 | "relationship": "direct", | ||
| 271 | "purpose": "Embedded database for accounts, sessions, instances, mods, backups", | ||
| 272 | "evidence": ["Cargo.toml:11 features = [\"bundled\"]", "src/db.rs"], | ||
| 273 | "security_relevant": true | ||
| 274 | } | ||
| 275 | ], | ||
| 276 | "categories_covered": ["runtime", "package", "framework", "datastore", "external-service", "container-os", "binary", "vendored"], | ||
| 277 | "coverage_gaps": [ | ||
| 278 | "build-ci: No CI configuration found in repository", | ||
| 279 | "container-os: Dockerfile not in repo — base image pulled externally (itzg/minecraft-server)", | ||
| 280 | "binary: Java runtime version is dynamic per Minecraft version, not pinned" | ||
| 281 | ] | ||
| 282 | } | ||
diff --git a/piolium/attack-surface/source-sink-flows-all-severities.md b/piolium/attack-surface/source-sink-flows-all-severities.md new file mode 100644 index 0000000..d7f93da --- /dev/null +++ b/piolium/attack-surface/source-sink-flows-all-severities.md | |||
| @@ -0,0 +1,143 @@ | |||
| 1 | # Source-to-Sink Flow Paths (All Severities) | ||
| 2 | |||
| 3 | Generated by manual structural extraction (codeql not available on this system). | ||
| 4 | |||
| 5 | ## Critical Flows | ||
| 6 | |||
| 7 | ### Flow C1: Instance Name Path Traversal → Arbitrary Directory Creation | ||
| 8 | - **Source:** `POST /instances/new` → `CreateInstanceForm.name` (free text, no validation) | ||
| 9 | - **Path:** `src/routes/instances.rs:165` → `form.name` → `src/routes/instances.rs:205` `.join(&form.name)` → `std::fs::create_dir_all()` | ||
| 10 | - **Risk:** Attacker can create directories anywhere the webUI user has write access via `../` in instance name | ||
| 11 | - **Control:** NO sanitization whatsoever on `form.name` | ||
| 12 | - **CWE:** CWE-22 (Path Traversal) | ||
| 13 | |||
| 14 | ### Flow C2: Instance Name Path Traversal → Arbitrary Directory Deletion | ||
| 15 | - **Source:** `POST /instances/{id}/delete` → name from DB (originally `form.name`, no sanitization) | ||
| 16 | - **Path:** `src/routes/instances.rs:360` → `DELETE FROM instances WHERE id=?` → name from DB → `src/routes/instances.rs:392-393` `.join(&name)` → `std::fs::remove_dir_all()` | ||
| 17 | - **Risk:** Attacker can delete directories anywhere via `../` in instance name | ||
| 18 | - **Control:** Name stored in DB at creation time without sanitization | ||
| 19 | - **CWE:** CWE-22 (Path Traversal) | ||
| 20 | |||
| 21 | ## High Severity Flows | ||
| 22 | |||
| 23 | ### Flow H1: RCON Command Injection via Player Username | ||
| 24 | - **Source:** `POST /players/whitelist/add` → `PlayerAction.username` (free text) | ||
| 25 | - **Path:** `src/routes/players.rs:93` → `form.username` → `src/routes/players.rs:97` `format!("whitelist add {}", form.username)` → `RconClient.send_command_async(cmd)` | ||
| 26 | - **Risk:** Attacker can inject arbitrary RCON commands via username field (e.g., `foo; op attacker`) | ||
| 27 | - **Control:** NO sanitization on `form.username` | ||
| 28 | - **CWE:** CWE-77 (Command Injection), CWE-88 (Argument Injection) | ||
| 29 | |||
| 30 | ### Flow H2: RCON Command Injection via OP/Ban Username | ||
| 31 | - **Source:** `POST /players/op/add` → `PlayerAction.username` (same for ban/pardon/deop) | ||
| 32 | - **Path:** `src/routes/players.rs:90-98` → `player_action!` macro generates: `format!("op {}", form.username)` → `RconClient.send_command_async()` | ||
| 33 | - **Risk:** Same as H1 — arbitrary RCON command injection | ||
| 34 | - **CWE:** CWE-77, CWE-88 | ||
| 35 | |||
| 36 | ### Flow H3: No Session Expiration/TTL Server-Side | ||
| 37 | - **Source:** Any authenticated session | ||
| 38 | - **Path:** Session stored in SQLite with `created_at` timestamp but **never checked** on authentication | ||
| 39 | - **Risk:** Stolen session cookies remain valid indefinitely server-side | ||
| 40 | - **CWE:** CWE-613 (Insufficient Session Expiration) | ||
| 41 | |||
| 42 | ### Flow H4: No CSRF Protection on Any POST Endpoint | ||
| 43 | - **Source:** All POST endpoints | ||
| 44 | - **Path:** No CSRF token validation anywhere in the application | ||
| 45 | - **Risk:** Cross-site request forgery — any website can trigger state-changing operations | ||
| 46 | - **CWE:** CWE-352 (Cross-Site Request Forgery) | ||
| 47 | |||
| 48 | ## Medium Severity Flows | ||
| 49 | |||
| 50 | ### Flow M1: Mod Filename After Directory Stripping Special Characters | ||
| 51 | - **Source:** `POST /instances/{id}/mods` → multipart upload filename | ||
| 52 | - **Path:** `field.file_name()` → `process_jar_upload()` → `Path::new(filename).file_name()` (strips dir) → `mods_dir.join(&safe_name)` → `std::fs::write()` | ||
| 53 | - **Risk:** After directory stripping, filename may still contain special characters or Unicode normalization issues | ||
| 54 | - **CWE:** CWE-22 | ||
| 55 | |||
| 56 | ### Flow M2: World Import ZIP Extraction (Mitigated) | ||
| 57 | - **Source:** `POST /instances/{id}/world/import` → multipart ZIP | ||
| 58 | - **Path:** `zip::ZipArchive::new()` → `archive.by_index(i)` → `file.enclosed_name()` → `world_dir.join(p)` → `File::create()` | ||
| 59 | - **Risk:** Zip slip partially mitigated by `enclosed_name()` but edge cases may exist | ||
| 60 | - **CWE:** CWE-22 | ||
| 61 | |||
| 62 | ### Flow M3: Backup Download Using Filename from DB | ||
| 63 | - **Source:** `GET /backups/{id}/download` → `backup_id` path parameter | ||
| 64 | - **Path:** `Path(backup_id)` → SQL SELECT filename → `backup_dir.join(&filename)` → `std::fs::read()` | ||
| 65 | - **Risk:** If filename field in DB is manipulated, could read arbitrary files | ||
| 66 | - **CWE:** CWE-22 | ||
| 67 | |||
| 68 | ### Flow M4: Docker Container Environment Variable Injection | ||
| 69 | - **Source:** `POST /instances/new` → various form fields | ||
| 70 | - **Path:** `CreateInstanceForm` → SQL INSERT → Instance model → `docker.start_container(&instance)` → `ContainerConfig.env` includes instance fields unescaped | ||
| 71 | - **Risk:** User-controlled strings passed to Docker container environment | ||
| 72 | - **CWE:** CWE-77 | ||
| 73 | |||
| 74 | ### Flow M5: Level-Seed Injection into server.properties | ||
| 75 | - **Source:** `POST /instances/{id}/world/regenerate` → `form.seed` | ||
| 76 | - **Path:** `form.seed` → `format!("level-seed={seed}")` → `std::fs::write(&props_path, ...)` | ||
| 77 | - **Risk:** Seed string written directly to properties file; could corrupt configuration | ||
| 78 | - **CWE:** CWE-93 (CRLF Injection) | ||
| 79 | |||
| 80 | ### Flow M6: Weak RCON Password Generation | ||
| 81 | - **Source:** `generate_rcon_password()` called during instance creation | ||
| 82 | - **Path:** `rand::thread_rng()` → 16-char alphanumeric → stored in DB | ||
| 83 | - **Risk:** Not cryptographically secure RNG for password generation | ||
| 84 | - **CWE:** CWE-338 (Use of Cryptographically Weak PRNG) | ||
| 85 | |||
| 86 | ### Flow M7: RCON Password Exposed to All Authenticated Users | ||
| 87 | - **Source:** `GET /instances/{id}/edit` template | ||
| 88 | - **Path:** SQL SELECT includes `rcon_password` → `EditInstanceTemplate` → rendered in HTML | ||
| 89 | - **Risk:** Any authenticated user can see the RCON password | ||
| 90 | - **CWE:** CWE-200 (Information Exposure) | ||
| 91 | |||
| 92 | ### Flow M8: Logout Handler Without Auth Middleware | ||
| 93 | - **Source:** `POST /logout` (registered on outer router, not behind `require_auth`) | ||
| 94 | - **Path:** Cookie header parsed → `DELETE FROM sessions WHERE id = ?` | ||
| 95 | - **Risk:** Anonymous requests can delete sessions if session ID is known/guessed | ||
| 96 | - **CWE:** CWE-306 (Missing Authentication) | ||
| 97 | |||
| 98 | ### Flow M9: No Rate Limiting on Login | ||
| 99 | - **Source:** `POST /login` | ||
| 100 | - **Path:** Infinite brute-force attempts possible | ||
| 101 | - **Risk:** Account enumeration and password brute-force | ||
| 102 | - **CWE:** CWE-307 (Improper Restriction of Excessive Authentication Attempts) | ||
| 103 | |||
| 104 | ### Flow M10: Cookie Without Secure Flag | ||
| 105 | - **Source:** Login sets cookie | ||
| 106 | - **Path:** `format!("{SESSION_COOKIE}={session_id}; Path=/; HttpOnly; Max-Age=86400; SameSite=Lax")` | ||
| 107 | - **Risk:** Cookie transmitted over HTTP (missing Secure flag) | ||
| 108 | - **CWE:** CWE-614 (Sensitive Cookie in HTTPS Without Secure Attribute) | ||
| 109 | |||
| 110 | ## Low Severity Flows | ||
| 111 | |||
| 112 | ### Flow L1: Mod JAR Parsing Denial of Service | ||
| 113 | - **Source:** `POST /instances/{id}/mods` → uploaded JAR | ||
| 114 | - **Path:** `validate_mod()` → `ZipArchive::new()` → `scan_jar_for_side()` → constant pool parsing | ||
| 115 | - **Risk:** Crafted JAR with malicious constant pool could cause OOM or infinite loop | ||
| 116 | - **CWE:** CWE-770 (Allocation of Resources Without Limits) | ||
| 117 | |||
| 118 | ### Flow L2: Recursive ZIP Extraction Without Depth Limit | ||
| 119 | - **Source:** `POST /instances/{id}/mods` → ZIP containing JAR (which is also a ZIP) | ||
| 120 | - **Path:** `process_zip_upload()` → extracts JAR → `process_jar_upload()` → `validate_mod()` opens JAR as ZIP again | ||
| 121 | - **Risk:** No depth counter; nested ZIP-in-JAR could cause resource exhaustion | ||
| 122 | - **CWE:** CWE-674 (Uncontrolled Recursion) | ||
| 123 | |||
| 124 | ### Flow L3: Backup Restore is No-Op | ||
| 125 | - **Source:** `POST /backups/{id}/restore` | ||
| 126 | - **Path:** Looks up (instance_id, _filename) from DB, then returns redirect without restoring | ||
| 127 | - **Risk:** Backup restore functionality is implemented as a stub; users think data is restored | ||
| 128 | - **CWE:** CWE-1039 (Automated Recognition Mechanism with Inadequate Detection) | ||
| 129 | |||
| 130 | ### Flow L4: No Audit Logging | ||
| 131 | - **Source:** All state-changing operations | ||
| 132 | - **Path:** No logging of who performed what action | ||
| 133 | - **Risk:** No accountability or forensic evidence for security incidents | ||
| 134 | |||
| 135 | ### Flow L5: ThreadRng Used for Password Generation | ||
| 136 | - **Source:** `src/routes/instances.rs:530` | ||
| 137 | - **Path:** `rand::thread_rng()` → 36^16 password space | ||
| 138 | - **Risk:** Predictable RNG seed may make RCON password guessable | ||
| 139 | |||
| 140 | ### Flow L6: Cookie Splitting Parsing is Naive | ||
| 141 | - **Source:** `src/auth.rs:107` and `src/auth.rs:182` | ||
| 142 | - **Path:** `raw_cookies.split(';')` then `split_once('=')` | ||
| 143 | - **Risk:** If cookie value contains `=` or `;`, parsing may be confused | ||
diff --git a/piolium/attack-surface/spec-gap-summary.md b/piolium/attack-surface/spec-gap-summary.md new file mode 100644 index 0000000..5e52e81 --- /dev/null +++ b/piolium/attack-surface/spec-gap-summary.md | |||
| @@ -0,0 +1,164 @@ | |||
| 1 | # P7 Spec, Framework Contract & Parser Gap Summary | ||
| 2 | |||
| 3 | **Target:** salmon/minecraft-ui | ||
| 4 | **Date:** 2025-07-29 | ||
| 5 | **Phase:** P7 — Specification, Framework Contract & Parser Differential Analysis | ||
| 6 | **Mode:** deep | ||
| 7 | |||
| 8 | --- | ||
| 9 | |||
| 10 | ## Overview | ||
| 11 | |||
| 12 | This report documents gaps between authoritative specifications, framework contracts, and the codebase implementation for salmon/minecraft-ui. The analysis covers: | ||
| 13 | |||
| 14 | - **RFC 9106** (Argon2) — password hashing parameter compliance | ||
| 15 | - **Java `.properties` format** (Oracle spec) — server.properties parsing | ||
| 16 | - **Source RCON Protocol** (Minecraft community spec — wiki.vg/RCON) — packet handling | ||
| 17 | - **RFC 6265** (HTTP State Management) — cookie parsing and session handling | ||
| 18 | - **Axum Framework Contracts** — middleware ordering, extension-based auth, multipart boundary parsing | ||
| 19 | - **Hidden Control Channels** — Docker environment injection, file path construction, host header trust | ||
| 20 | - **Deployment Assumptions** — reverse proxy, localhost binding, no TLS | ||
| 21 | |||
| 22 | --- | ||
| 23 | |||
| 24 | ## Spec Gap Candidates | ||
| 25 | |||
| 26 | ### Gap 1: Java `.properties` — Missing Escape Sequence Processing | ||
| 27 | |||
| 28 | - **Spec:** Oracle Java `.properties` file format | ||
| 29 | - **Requirement:** The format specifies escape sequences: `\n`, `\r`, `\t`, `\\`, `\uXXXX`, `\ ` (literal space), `\!`, `\#`, `\=`, `\:`, and multi-line continuation with trailing `\`. | ||
| 30 | - **Code Path:** `src/properties.rs:13-19` — `read_properties()` splits on `=` or `:` without escaping | ||
| 31 | - **Gap Type:** parsing | canonicalization | ||
| 32 | - **Impact:** Values containing `=`, `:`, or Java escape sequences in `server.properties` are misparsed. An attacker who can control `server.properties` values (e.g., via `level-seed` setting) can inject fake key-value pairs or corrupt parsing. The `level-seed` value in `server.properties` is written by `regenerate_world` via a line-replacement loop that creates `level-seed={seed}` — the seed value is not escaped for the `.properties` format. | ||
| 33 | - **Attack Vector:** Auth'd user sets a seed containing `\nlevel-type=FLAT\n` which, when written to `server.properties` via the naive line-replacement in `regenerate_world`, injects additional properties. On next server start, the injected properties alter server behavior. | ||
| 34 | - **Exploit Conditions:** Auth'd user; instance not active; access to `regenerate_world` handler. | ||
| 35 | - **Severity:** MEDIUM | ||
| 36 | - **Evidence:** `src/properties.rs:13-19` uses `split_once('=')` and `split_once(':')` with no escape processing. Java `.properties` spec requires backslash escaping for separators. | ||
| 37 | |||
| 38 | ### Gap 2: Argon2id (RFC 9106) — Non-Recommended Default Parameters | ||
| 39 | |||
| 40 | - **RFC/Spec:** RFC 9106, Sections 3.1, 4, 7.4 | ||
| 41 | - **Requirement:** RFC 9106 §4 recommends as FIRST option: `Argon2id` with `t=1`, `p=4`, `m=2^21` (2 GiB). §7.4 states "Argon2id variant with t=1 and 2 GiB memory is the FIRST RECOMMENDED option". Salt SHOULD be 16 bytes and SHOULD be unique per password. | ||
| 42 | - **Code Path:** `src/auth.rs:28` — `Argon2::default().hash_password(password.as_bytes(), &salt)?` | ||
| 43 | - **Gap Type:** missing-check | parameter-choice | ||
| 44 | - **Impact:** `Argon2::default()` uses m_cost=19456 KiB (~19 MiB), t_cost=2 iterations, p_cost=1 lane. This is ~100x less memory than RFC 9106 recommends. While not a compliance vulnerability (any parameters are valid per the algorithm spec), the weak defaults reduce brute-force attack resistance relative to the RFC's recommended "default setting for all environments." | ||
| 45 | - **Attack Vector:** Offline password hash cracking benefits from the 19 MiB vs 2 GiB memory reduction (~100x cheaper per guess). | ||
| 46 | - **Exploit Conditions:** Attacker obtains password hash from database (SQLite file access or SQL injection). | ||
| 47 | - **Impact:** Accelerated offline brute-force by ~100x less memory per hash evaluation. | ||
| 48 | - **Severity:** MEDIUM | ||
| 49 | - **Evidence:** `src/auth.rs:28` uses `Argon2::default()` (m=19456 KiB, t=2, p=1). RFC 9106 §4 recommends m=2^21 KiB (2 GiB), t=1, p=4 for pass hashing. Source: `/home/salmon/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/argon2-0.5.3/src/params.rs:29` defines DEFAULT_M_COST = 19 * 1024. | ||
| 50 | |||
| 51 | ### Gap 3: RCON Protocol — Insufficient Response Read After EXECCOMMAND | ||
| 52 | |||
| 53 | - **Spec:** Source RCON Protocol (Minecraft community convention — wiki.vg/RCON) | ||
| 54 | - **Requirement:** After sending `SERVERDATA_EXECCOMMAND` (type 2), the server responds with a `SERVERDATA_RESPONSE_VALUE` (type 0) packet containing the command output, followed by a second `SERVERDATA_RESPONSE_VALUE` (type 0) as an end marker. Multi-packet responses are possible. | ||
| 55 | - **Code Path:** `src/rcon.rs:72-76` — reads one response, then ignores errors on second | ||
| 56 | - **Gap Type:** state-machine | error-handling | ||
| 57 | - **Impact:** Reads only one guaranteed response. Fragmented multi-packet responses are truncated. The comment acknowledges this: "some servers send it, some don't; ignore errors." | ||
| 58 | - **Attack Vector:** Long RCON command output (> 1400 bytes) gets truncated. | ||
| 59 | - **Exploit Conditions:** Any RCON command with output exceeding ~1400 bytes. | ||
| 60 | - **Impact:** Truncated command response (information disclosure). | ||
| 61 | - **Severity:** LOW — protocol compliance only, no direct security bypass. | ||
| 62 | - **Evidence:** `src/rcon.rs:72` — `let response = self.read_packet(&mut stream)?;` followed by `let _ = self.read_packet(&mut stream);` with error ignored. | ||
| 63 | |||
| 64 | ### Gap 4: Java `.properties` — Key Continuation and Whitespace Separator Not Supported | ||
| 65 | |||
| 66 | - **Spec:** Oracle Java `.properties` format | ||
| 67 | - **Code Path:** `src/properties.rs:13-19` | ||
| 68 | - **Gap Type:** parsing | ||
| 69 | - **Impact:** Lines with whitespace-only separators (e.g., `key value`) are silently ignored. | ||
| 70 | - **Severity:** LOW — informational; conventional Minecraft server.properties uses `=`. | ||
| 71 | |||
| 72 | ### Gap 5: Auth Middleware — Manual Cookie Parsing (RFC 6265) | ||
| 73 | |||
| 74 | - **RFC/Spec:** RFC 6265 (HTTP State Management), Sections 4.2.2, 5 | ||
| 75 | - **Code Path:** `src/auth.rs:105-118` — manual `split(';')` then `split_once('=')` | ||
| 76 | - **Gap Type:** parsing | canonicalization | ||
| 77 | - **Impact:** The parser does NOT strip OWS around cookie names per RFC 6265 §4.2.2. Does NOT handle quoted cookie values. | ||
| 78 | - **Severity:** LOW — well-formed browser cookies not affected; no auth bypass possible. | ||
| 79 | |||
| 80 | ### Gap 6: Framework Contract — Logout Without Auth Middleware | ||
| 81 | |||
| 82 | - **Contract:** Axum middleware; all state-changing ops require auth. | ||
| 83 | - **Code Path:** `src/routes/mod.rs:29` — `/logout` on outer router, not in `protected` group. | ||
| 84 | - **Gap Type:** middleware-ordering | ||
| 85 | - **Impact:** Anonymous requests can delete sessions (requires valid session ID — UUIDv4 impractical). | ||
| 86 | - **Severity:** LOW — theoretical; UUIDv4 unpredictability prevents exploitation. | ||
| 87 | |||
| 88 | ### Gap 7: Framework Contract — No Server-Side Session TTL Enforcement | ||
| 89 | |||
| 90 | - **Contract:** Session cookie Max-Age=86400 but no server-side expiration check. | ||
| 91 | - **Code Path:** `src/auth.rs:93` — `Max-Age=86400`; `src/auth.rs:108-115` — no `created_at` check. | ||
| 92 | - **Gap Type:** missing-check | ||
| 93 | - **Impact:** Session rows remain valid indefinitely in DB. Stolen session ID (via XSS, log access, DB leak) works forever. | ||
| 94 | - **Attack Vector:** Session ID leak → permanent session hijacking. No server-side TTL means an attacker who obtains a session ID can reuse it indefinitely, long after the Max-Age expires. | ||
| 95 | - **Exploit Conditions:** Attacker must first obtain a valid session ID. | ||
| 96 | - **Impact:** Session hijacking window extended indefinitely. | ||
| 97 | - **Severity:** MEDIUM | ||
| 98 | - **Evidence:** `src/auth.rs:93` sets `Max-Age=86400` but `src/auth.rs:108-115` does `WHERE s.id = ?1` with no date filter. | ||
| 99 | |||
| 100 | ### Gap 8: Hidden Control Channel — Instance MOTD Injected Into Docker Env Without Sanitization | ||
| 101 | |||
| 102 | - **Contract:** Docker env vars derived from instance settings; Docker env parsing splits on `\n`. | ||
| 103 | - **Code Path:** `src/docker.rs:103` — `format!("MOTD={}", instance.motd)` | ||
| 104 | - **Gap Type:** hidden-control-channel | missing-check | ||
| 105 | - **Impact:** MOTD containing newline characters (`%0a`) injects arbitrary additional env vars into the Docker container. For example, MOTD=`hello\nEULA=FALSE\nONLINE_MODE=true` would override security-critical env vars. All Docker env vars (VERSION, TYPE, MEMORY, MAX_PLAYERS, DIFFICULTY, RCON_PASSWORD, GAMEMODE, LEVEL_TYPE, LEVEL_SEED, PVP, MOTD) are set without sanitization. | ||
| 106 | - **Attack Vector:** Auth'd user edits instance MOTD → saves → triggers container restart → injected env vars alter Docker container behavior. | ||
| 107 | - **Exploit Conditions:** Auth'd user; container restarted after instance edit. | ||
| 108 | - **Impact:** Full Docker container environment variable injection — could override EULA, ONLINE_MODE, RCON settings, or inject arbitrary env vars. | ||
| 109 | - **Severity:** HIGH | ||
| 110 | - **Evidence:** `src/docker.rs:103` — `format!("MOTD={}", instance.motd)`. No escaping. MOTD sourced from `src/routes/instances.rs:206` via `Form<EditInstanceForm>` without sanitization. | ||
| 111 | |||
| 112 | ### Gap 9: Hidden Control Channel — Instance Name Used in File Paths Without Sanitization | ||
| 113 | |||
| 114 | - **Contract:** Instance name → filesystem path. | ||
| 115 | - **Code Path:** `src/routes/instances.rs:173` — `server_directory.join("instances").join(&form.name)` | ||
| 116 | - **Gap Type:** hidden-control-channel | missing-check | ||
| 117 | - **Impact:** `std::path::Path::join` DOES traverse on `..` prefix. Instance name `../../evil` results in path outside intended tree. All subsequent operations (mod uploads, world import, backups) land at attacker-chosen locations. Files created: instance data directory, mod JAR files, world files. | ||
| 118 | - **Attack Vector:** Auth'd user creates instance with `../../evil_name` → data dir created at `server_directory/instances/../../evil_name/data` = `server_directory/evil_name/data`. Then upload mod or import world to write files at traversed location. | ||
| 119 | - **Exploit Conditions:** Auth'd user. | ||
| 120 | - **Impact:** Arbitrary file write relative to server_directory (WebUI process user permissions). | ||
| 121 | - **Severity:** HIGH | ||
| 122 | - **Evidence:** `src/routes/instances.rs:173` — `let instance_dir = state.config.server_directory.join("instances").join(&form.name).join("data");` No validation of `form.name` for `..` or path separators. Also stored in DB at `src/routes/instances.rs:119` without sanitization. | ||
| 123 | |||
| 124 | ### Gap 10: Hidden Control Channel — Content-Type Boundary Parsing Trusts Attacker Input | ||
| 125 | |||
| 126 | - **Contract:** Multer multipart parser; boundary from Content-Type header. | ||
| 127 | - **Code Path:** `src/routes/mods.rs:140-149` — boundary parsed without validation | ||
| 128 | - **Gap Type:** hidden-control-channel | missing-check | ||
| 129 | - **Impact:** Attacker-controlled boundary value fed to `multer::Multipart::with_constraints`. While multer validates boundaries internally, a crafted boundary could cause field misidentification, size limit bypass, or field-skipping behavior. | ||
| 130 | - **Attack Vector:** Send crafted Content-Type header with boundary overlapping body content: malformed multipart parsing. | ||
| 131 | - **Exploit Conditions:** Auth'd user uploading mods. | ||
| 132 | - **Impact:** Potential DoS or file size limit bypass. | ||
| 133 | - **Severity:** MEDIUM | ||
| 134 | - **Evidence:** `src/routes/mods.rs:141-148` — boundary parsed from user-supplied `Content-Type`. | ||
| 135 | |||
| 136 | ### Gap 11: Hidden Control Channel — All Instance Settings In Docker Env Without Sanitization | ||
| 137 | |||
| 138 | - **Contract:** All instance settings → Docker env. | ||
| 139 | - **Code Path:** `src/docker.rs:87-113` | ||
| 140 | - **Gap Type:** hidden-control-channel | missing-check | ||
| 141 | - **Impact:** Beyond MOTD (Gap 8), LEVEL_SEED, LEVEL_TYPE, GAMEMODE, DIFFICULTY, VERSION, RCON_PASSWORD are all injected without sanitization. | ||
| 142 | - **Severity:** HIGH — combined with Gap 8. | ||
| 143 | |||
| 144 | ### Gap 12: Framework Contract — RCON Password Exposed to All Authenticated Users | ||
| 145 | |||
| 146 | - **Contract:** Supposed role-based access control for sensitive instance data. | ||
| 147 | - **Code Path:** `src/routes/instances.rs:313` — `rcon_password: row.get(15)?` in `edit_form` | ||
| 148 | - **Gap Type:** missing-check | ||
| 149 | - **Impact:** Any authenticated user can view any instance's RCON password by visiting `/instances/{id}/edit`. No superuser or instance-ownership check. RCON password grants full Minecraft server control. | ||
| 150 | - **Attack Vector:** Auth'd user navigates to `/instances/1/edit` → sees RCON password → uses RCON client to issue arbitrary commands. | ||
| 151 | - **Exploit Conditions:** Auth'd user. | ||
| 152 | - **Impact:** Full Minecraft server compromise via RCON — whitelist bypass, data destruction, command execution. | ||
| 153 | - **Severity:** HIGH | ||
| 154 | - **Evidence:** `src/routes/instances.rs:313` — `rcon_password` selected and returned. No superuser check. Compare with `src/routes/accounts.rs:35` where checks exist. | ||
| 155 | |||
| 156 | --- | ||
| 157 | |||
| 158 | ## Cross-Reference with Phase 3 Domain Attack Research | ||
| 159 | |||
| 160 | - **Gap 8/11** (Docker env injection) — NOT covered in Phase 3. New finding. | ||
| 161 | - **Gap 9** (Instance name path traversal) — NOT specifically covered. Phase 3 identified mod filename traversal (T2) but not instance name as traversal vector. | ||
| 162 | - **Gap 12** (RCON password exposure) — Covered in Phase 3 as T5. | ||
| 163 | - **Gap 7** (No session TTL) — Covered in Phase 3 domain research. | ||
| 164 | - **Gap 2** (Argon2 defaults) — NOT covered in Phase 3. | ||
diff --git a/piolium/attack-surface/state-concurrency-summary.md b/piolium/attack-surface/state-concurrency-summary.md new file mode 100644 index 0000000..48677b3 --- /dev/null +++ b/piolium/attack-surface/state-concurrency-summary.md | |||
| @@ -0,0 +1,46 @@ | |||
| 1 | # State & Concurrency Audit | ||
| 2 | |||
| 3 | ## State-Holding Entities Catalogued | ||
| 4 | |||
| 5 | | Entity | Table | State Columns | Allowable Values | Concurrency Risk | | ||
| 6 | |--------|-------|---------------|-----------------|------------------| | ||
| 7 | | Account | `accounts` | `is_superuser` | 0/1 | Low (rarely mutated) | | ||
| 8 | | Account | `accounts` | `password_change_required` | 0/1 | Low | | ||
| 9 | | Session | `sessions` | `created_at` | datetime | **High** — no TTL enforcement | | ||
| 10 | | Instance | `instances` | `is_active` | 0/1 | **CRITICAL** — background task stale writes | | ||
| 11 | | Instance | `instances` | `container_state` | stopped/pulling/booting/running/error | **CRITICAL** — not bound to instance ID in background writes | | ||
| 12 | | Instance | `instances` | `has_modpack` | 0/1 | Medium — RMW across mutex acquisitions | | ||
| 13 | | Instance | `instances` | `name` | TEXT (UNIQUE) | **HIGH** — name-reuse race between delete and create | | ||
| 14 | | Mod | `mods` | `accepted` | 0/1 | Low | | ||
| 15 | | Backup | `backups` | `filename`, `created_at` | TEXT, datetime | Medium — orphan ZIP on concurrent delete | | ||
| 16 | | All | — | — | — | **No idempotency infrastructure** | | ||
| 17 | |||
| 18 | **Total state-holding entities catalogued:** 10 (across 5 tables) | ||
| 19 | |||
| 20 | ## Concurrency Primitives Observed | ||
| 21 | |||
| 22 | | Primitive | Location | Scope | Limitation | | ||
| 23 | |-----------|----------|-------|------------| | ||
| 24 | | `Arc<Mutex<Connection>>` | `src/db.rs:7` | SQLite connection | Serialises per-statement, not per-transaction | | ||
| 25 | | `MutexGuard::drop()` implicit | Every `let conn = state.db.conn()` / `drop(conn)` pattern | Statement boundaries | Mutex released between read and write in multiple handlers | | ||
| 26 | | Tokio async tasks (background spawn) | `src/routes/instances.rs:457` | container lifecycle | **No instance ID binding** — uses `WHERE is_active = 1` | | ||
| 27 | | SQLite WAL mode | `src/db.rs:19` (PRAGMA) | Write-ahead logging | Allows concurrent reads during writes but does not prevent any of the races documented | | ||
| 28 | |||
| 29 | **No distributed locks, no advisory locks, no `SELECT ... FOR UPDATE`, no explicit BEGIN/COMMIT boundaries.** | ||
| 30 | |||
| 31 | ## Idempotency Infrastructure | ||
| 32 | |||
| 33 | **Absent.** No `idempotency_key` table, no deduplication tokens, no request ID storage, no unique constraints leveraged for dedup (except `accounts.username` and `instances.name`). Every POST handler is vulnerable to replay/double-submit. | ||
| 34 | |||
| 35 | ## Drafts Filed | ||
| 36 | |||
| 37 | | Draft | Severity | Class | Summary | | ||
| 38 | |-------|----------|-------|---------| | ||
| 39 | | p6-001 | CRITICAL | toctou | Background task in `activate_instance` uses `WHERE is_active = 1` (no instance ID bound). After deactivation/reactivation, stale task writes `container_state` on the wrong instance. | | ||
| 40 | | p6-002 | HIGH | toctou | `delete_instance` releases DB mutex before `remove_dir_all`. Concurrent `create_instance` with same name has its data directory wiped. Cross-resource TOCTOU with data loss. | | ||
| 41 | | p6-003 | MEDIUM | state-machine-violation | `edit_instance` stops container on active instances without restarting. `is_active=1` and stale `container_state='running'` persist. Instance appears running but isn't. | | ||
| 42 | | p6-004 | HIGH | idempotency | No idempotency infrastructure on any POST handler. Mod upload, backup create, login, account create all vulnerable to replay/double-submit. | | ||
| 43 | | p6-005 | MEDIUM | rmw-no-txn | `process_jar_upload` writes file to disk before DB INSERT. Crash between file write and DB insert leaves orphaned mod file. Also performs `has_modpack` update across separate mutex acquisition. | | ||
| 44 | | p6-006 | MEDIUM | toctou | `create_backup` reads instance name, releases mutex, creates ZIP file (long I/O window), then re-acquires mutex. Concurrent instance deletion causes FK violation — orphaned ZIP file with no DB record. | | ||
| 45 | |||
| 46 | **Total draft findings filed: 6** \ No newline at end of file | ||
diff --git a/piolium/attack-surface/unauthenticated-surface.md b/piolium/attack-surface/unauthenticated-surface.md new file mode 100644 index 0000000..d899854 --- /dev/null +++ b/piolium/attack-surface/unauthenticated-surface.md | |||
| @@ -0,0 +1,110 @@ | |||
| 1 | # Unauthenticated Attack Surface | ||
| 2 | |||
| 3 | Reachable by an anonymous attacker — no valid session, token, or API key. | ||
| 4 | |||
| 5 | **Coverage**: 4 entry points | 3 by-design public | 1 missing-guard | 0 middleware-gap | ||
| 6 | **Auth model**: Cookie-based session (mcui_session UUIDv4) via `require_auth` middleware (src/auth.rs:55). Login sets Set-Cookie with HttpOnly; middleware extracts cookie header and queries sessions table. | ||
| 7 | **Coverage gaps**: None — all routes are statically registered in the axum Router at src/routes/mod.rs. No reflection-based or dynamically-registered handlers. | ||
| 8 | |||
| 9 | ## Pre-Auth HTTP / API Routes | ||
| 10 | |||
| 11 | | # | Method | Path | Handler (file:line) | Why pre-auth | Notable inputs / sinks | Blast radius | | ||
| 12 | |---|--------|------|---------------------|--------------|------------------------|--------------| | ||
| 13 | | 1 | GET | `/login` | `login_page` (src/routes/mod.rs:53) | by-design | None — renders static template | None — no side effects, no data access | | ||
| 14 | | 2 | POST | `/login` | `authenticate` (src/auth.rs:34) | by-design | `username`, `password` form fields → Argon2id password hash verification → session INSERT | Account compromise if credentials brute-forced (no rate limiting — see p4-005). Session fixation not possible (new UUID on each login). | | ||
| 15 | | 3 | POST | `/logout` | `logout` (src/auth.rs:130) | **missing-guard** (p4-008) | Cookie header → raw string split on `;` and `=` → session ID → `DELETE FROM sessions WHERE id = ?1` | Session deletion if valid session ID known. UUIDv4 is unpredictable (122 bits). Low practical risk but defense-in-depth violation. | | ||
| 16 | | 4 | GET | `/static/*` | `ServeDir::new("static")` (src/routes/mod.rs:28) | by-design | File path after `/static/` → static file read | Static file read only. Directory contains only `style.css` and `htmx.min.js`. No user data, no secrets, no configuration. | | ||
| 17 | |||
| 18 | ## Other Unauthenticated Entry Points | ||
| 19 | |||
| 20 | | Kind | Entry point (file:line) | Why pre-auth | Notes | | ||
| 21 | |------|-------------------------|--------------|-------| | ||
| 22 | | **Static file server** | `ServeDir::new("static")` mounted at Router level (src/routes/mod.rs:28) | by-design | Serves `static/` directory contents (style.css, htmx.min.js). No auth, no session check, no dynamic content. Served directly without any middleware. | | ||
| 23 | | **Logout session deletion** | `logout` handler (src/auth.rs:130) registered on **outer Router** (src/routes/mod.rs:20) | missing-guard (p4-008) | Covered by row #3 above. The handler is on the outer Router, NOT behind `require_auth`. Any anonymous request can reach it. The handler extracts session ID from Cookie header via raw string splitting (`split(';')` then `split_once('=')`) and attempts to delete the session from DB. | | ||
| 24 | |||
| 25 | ## Why `POST /logout` is classified `missing-guard` | ||
| 26 | |||
| 27 | The route registration at src/routes/mod.rs:20: | ||
| 28 | |||
| 29 | ```rust | ||
| 30 | Router::new() | ||
| 31 | .route("/login", axum::routing::get(login_page)) | ||
| 32 | .route("/login", axum::routing::post(crate::auth::authenticate)) | ||
| 33 | .route("/logout", axum::routing::post(crate::auth::logout)) // <-- OUTSIDE protected group | ||
| 34 | .merge(protected) // <-- protected has .layer(auth_required) | ||
| 35 | ``` | ||
| 36 | |||
| 37 | The `require_auth` middleware (src/auth.rs:55) is applied only to the `protected` router group. The logout handler processes the request without any session validation — it simply reads the Cookie header, extracts the session ID by splitting on `;` and `=`, and executes `DELETE FROM sessions WHERE id = ?1`. | ||
| 38 | |||
| 39 | **Mitigating factors:** | ||
| 40 | 1. Session IDs are UUIDv4 (122 bits of entropy) — practically unpredictable | ||
| 41 | 2. Attacker would need a victim's exact session cookie value | ||
| 42 | 3. No account information is leaked; only session deletion occurs | ||
| 43 | |||
| 44 | **Risk if session IDs leak:** | ||
| 45 | Session IDs could leak via: | ||
| 46 | - Server access logs (if session IDs appear in logged cookie headers) | ||
| 47 | - Error messages (currently suppressed by `unwrap_or` / `ok()` patterns) | ||
| 48 | - Database backup exposure | ||
| 49 | - Shoulder surfing / physical access | ||
| 50 | |||
| 51 | If leaked, an attacker can force-logout the victim by sending `Cookie: mcui_session=<leaked-id>` to `/logout`. | ||
| 52 | |||
| 53 | ## Pre-Auth Risk Summary | ||
| 54 | |||
| 55 | The webUI is designed to bind to `127.0.0.1` and sit behind a reverse proxy (nginx), which mitigates direct anonymous access. If exposed to the internet (even unintentionally via misconfiguration, Docker port mapping, or reverse proxy error): | ||
| 56 | |||
| 57 | | Risk | Pre-auth endpoint | Impact | Existing draft | | ||
| 58 | |------|-------------------|--------|----------------| | ||
| 59 | | **HIGH** | `POST /login` (no rate limit) | Account compromise via credential brute-force | p4-005 | | ||
| 60 | | **LOW** | `POST /logout` (missing guard) | Session deletion (requires valid session ID) | p4-008 | | ||
| 61 | | **HIGH** | All POST endpoints (no CSRF) | Cross-site request forgery on any state-changing operation | p4-003 | | ||
| 62 | | **NONE** | `GET /login` | None — static page | — | | ||
| 63 | | **NONE** | `GET /static/*` | None — only CSS/JS files | — | | ||
| 64 | |||
| 65 | ## Auth Bypass Verification | ||
| 66 | |||
| 67 | **All protected routes are correctly behind `require_auth` middleware:** | ||
| 68 | - ✓ Dashboard (`/`) | ||
| 69 | - ✓ Instances CRUD (`/instances`, `/instances/{id}/edit`, etc.) | ||
| 70 | - ✓ World management (`/instances/{id}/world/*`) | ||
| 71 | - ✓ Player management (`/players`, `/players/*`) | ||
| 72 | - ✓ Backup management (`/instances/{id}/backups`, `/backups/{id}/*`) | ||
| 73 | - ✓ Mod management (`/instances/{id}/mods`, `/instances/{id}/mods/*`) | ||
| 74 | - ✓ Account management (`/accounts`, `/accounts/*`) | ||
| 75 | - ✓ Password change (`/change-password`) | ||
| 76 | |||
| 77 | **Routes NOT behind `require_auth` (intentional for auth protocol):** | ||
| 78 | - `GET /login` — login form (by-design) | ||
| 79 | - `POST /login` — credential verification (by-design) | ||
| 80 | - `GET /static/*` — static assets (by-design) | ||
| 81 | |||
| 82 | **Route NOT behind `require_auth` (anomaly):** | ||
| 83 | - `POST /logout` — session deletion (missing-guard, p4-008) | ||
| 84 | |||
| 85 | ## Hidden Control Channel Verification | ||
| 86 | |||
| 87 | | Channel | Present? | Can it bypass auth? | | ||
| 88 | |---------|----------|---------------------| | ||
| 89 | | Cookie header (`mcui_session`) | Yes, in `require_auth` + `logout` | No — session must exist in DB | | ||
| 90 | | Content-Type boundary | Yes, in `upload_mod` handler | No — only affects file parsing | | ||
| 91 | | X-Forwarded-* headers | **Not read anywhere** | N/A (safe) | | ||
| 92 | | X-HTTP-Method-Override | **Not supported** | N/A (safe) | | ||
| 93 | | X-Admin / X-Internal / X-Debug | **Not supported** | N/A (safe) | | ||
| 94 | | Host header | **Not used for routing decisions** | N/A (safe) | | ||
| 95 | |||
| 96 | ## References to Existing Drafts | ||
| 97 | |||
| 98 | The following p4 findings document pre-auth and authz issues in detail: | ||
| 99 | |||
| 100 | | Draft | Class | Covers | | ||
| 101 | |-------|-------|--------| | ||
| 102 | | p4-003 | csrf | No CSRF on any POST endpoint | | ||
| 103 | | p4-005 | missing-rate-limit | No rate limiting on POST /login | | ||
| 104 | | p4-008 | missing-guard | POST /logout not behind auth middleware | | ||
| 105 | | p4-009 | cookie-no-secure-flag | Cookie missing Secure flag | | ||
| 106 | | p4-019 | cookie-parsing-naive | Naive cookie header string splitting | | ||
| 107 | |||
| 108 | ## Cross-Reference: Matrix Entry vs Unauthenticated Surface | ||
| 109 | |||
| 110 | All entries in this file are derived from the authorization matrix (`piolium/attack-surface/public-routes-authz-matrix.md`). The matrix covers 37 total endpoints; 4 are reachable pre-auth as documented above. The remaining 33 require authentication via `require_auth` middleware. | ||
diff --git a/piolium/attack-surface/variant-summary.md b/piolium/attack-surface/variant-summary.md new file mode 100644 index 0000000..0503ac1 --- /dev/null +++ b/piolium/attack-surface/variant-summary.md | |||
| @@ -0,0 +1,130 @@ | |||
| 1 | # Variant Analysis Summary — Phase 12 | ||
| 2 | |||
| 3 | **Generated:** 2025-07-29 | ||
| 4 | **Phase:** P12 (Variant Search) | ||
| 5 | **Status:** COMPLETE | ||
| 6 | |||
| 7 | --- | ||
| 8 | |||
| 9 | ## Methodology | ||
| 10 | |||
| 11 | For each of the 17 surviving Phase 10 findings, the entire codebase was searched for: | ||
| 12 | 1. Same root cause pattern (unsanitized input → dangerous sink) | ||
| 13 | 2. Same flow shape (source type → transformation → sink type) | ||
| 14 | 3. Same attacker control level (authenticated/unauthenticated, direct/indirect) | ||
| 15 | 4. Missing protections (no input validation, no escaping, no authorization check) | ||
| 16 | |||
| 17 | --- | ||
| 18 | |||
| 19 | ## Results | ||
| 20 | |||
| 21 | | Finding | Root Cause | Variants Found | New Drafts | | ||
| 22 | |---------|-----------|----------------|------------| | ||
| 23 | | p10-001 — Instance Name Path Traversal | Path::join() with unsanitized user string | **4 new** (world import, regenerate, player mgmt, backup create) | p12-001 through p12-004 | | ||
| 24 | | p10-002 — RCON Command Injection | format!() with unsanitized username in RCON command | 0 | — | | ||
| 25 | | p10-003 — Level-Seed Properties Injection | format!() with unsanitized seed in Java .properties file | 0 | — | | ||
| 26 | | p10-004 — RCON Password Exposed | Sensitive field in template rendering | 0 | — | | ||
| 27 | | p10-005 — Missing CSRF Protection | Architectural (no CSRF tokens anywhere) | 0 (all 31 endpoints) | — | | ||
| 28 | | p10-006 — No Server-Side Session TTL | Single query point | 0 | — | | ||
| 29 | | p10-007 — No Rate Limiting Login | Architectural (no middleware) | 0 | — | | ||
| 30 | | p10-008 — Logout No Auth Middleware | Single route registration | 0 | — | | ||
| 31 | | p10-009 — Cookie No Secure Flag | Single cookie setting | 0 | — | | ||
| 32 | | p10-010 — Flat Authorization Model | Architectural (no per-resource authz) | 0 | — | | ||
| 33 | | p10-011 — Background Task Stale is_active | Background task closes over stale DB filter | 0 (all 5 updates in same task) | — | | ||
| 34 | | p10-012 — Delete-Create Name Reuse Race | TOCTOU race window | 0 | — | | ||
| 35 | | p10-013 — No Idempotency | Architectural (no idempotency keys) | 0 | — | | ||
| 36 | | p10-014 — Mod Write Before DB Orphan | Ordering bug (file before DB insert) | 0 | — | | ||
| 37 | | p10-015 — Backup Orphan Race | Ordering bug (ZIP before DB insert) | 0 | — | | ||
| 38 | | p10-016 — Mod Validation Denial of Service | No timeout on CPU-bound parsing | 0 | — | | ||
| 39 | | p10-017 — No Encryption at Rest | Architectural (no SQLite encryption) | 0 | — | | ||
| 40 | |||
| 41 | --- | ||
| 42 | |||
| 43 | ## Path Traversal Variant Details | ||
| 44 | |||
| 45 | ### Variant 1: World Import Path Traversal (p12-001) | ||
| 46 | |||
| 47 | | Property | Value | | ||
| 48 | |----------|-------| | ||
| 49 | | Source | `instances.name` from DB (originally `form.name`) | | ||
| 50 | | Sink | `std::fs::File::create()` at `instances.rs:704` | | ||
| 51 | | Flow | POST `/instances/{id}/world/import` → load name from DB → `.join(&instance_name)` → `world_dir.join(zip_entry)` → `File::create()` | | ||
| 52 | | Constraint | `/data/world/` suffix appended after traversed name | | ||
| 53 | | Severity | HIGH | | ||
| 54 | | New? | Yes — NOT in AP-001 registry previously | | ||
| 55 | |||
| 56 | ### Variant 2: Regenerate World Path Traversal (p12-002) | ||
| 57 | |||
| 58 | | Property | Value | | ||
| 59 | |----------|-------| | ||
| 60 | | Source | `instances.name` from DB (originally `form.name`) | | ||
| 61 | | Sink | `std::fs::remove_dir_all()` at `instances.rs:779` | | ||
| 62 | | Flow | POST `/instances/{id}/world/regenerate` → load name from DB → `.join(&instance_name)` → `remove_dir_all(world_dir)` | | ||
| 63 | | Constraint | `/data/world/` suffix appended after traversed name | | ||
| 64 | | Severity | HIGH | | ||
| 65 | | New? | Yes — NOT in AP-001 registry previously | | ||
| 66 | |||
| 67 | ### Variant 3: Player Management Path Traversal (p12-003) | ||
| 68 | |||
| 69 | | Property | Value | | ||
| 70 | |----------|-------| | ||
| 71 | | Source | `instances.name` from active instance query | | ||
| 72 | | Sink | `std::fs::read_to_string()` via `read_json_list()` at `players.rs:59-76` | | ||
| 73 | | Flow | GET `/players` → `get_active_instance()` → `.join(&inst_name)` → `read_json_list(.../whitelist.json)` | | ||
| 74 | | Constraint | Only files named `whitelist.json`, `ops.json`, `banned-players.json` are read | | ||
| 75 | | Severity | MEDIUM (read-only, constrained to 3 filenames) | | ||
| 76 | | New? | Yes — NOT in AP-001 registry previously | | ||
| 77 | |||
| 78 | ### Variant 4: Backup Create Path Traversal (p12-004) | ||
| 79 | |||
| 80 | | Property | Value | | ||
| 81 | |----------|-------| | ||
| 82 | | Source | `instances.name` from DB (originally `form.name`) | | ||
| 83 | | Sink | `std::fs::File::create()` at `backups.rs:114` | | ||
| 84 | | Flow | POST `/instances/{id}/backups/create` → load name from DB → `filename = {name}_{timestamp}.zip` → `.join(&filename)` → `File::create()` | | ||
| 85 | | Constraint | Timestamp suffix (`_YYYYMMDD_HHMMSS.zip`) attached to traversed name | | ||
| 86 | | Severity | MEDIUM (cannot control exact filename) | | ||
| 87 | | New? | Partially — backup filename path was listed in registry but created via Write (not Read) | | ||
| 88 | |||
| 89 | --- | ||
| 90 | |||
| 91 | ## No Variants Found — Rationale | ||
| 92 | |||
| 93 | ### AP-002 (RCON Injection) | ||
| 94 | The `player_action!` macro generates 6 endpoints with unsanitized username → RCON command. All other RCON calls use hardcoded commands ("seed", "list"). No unsanitized input reaches any other `send_command_async()` call. | ||
| 95 | |||
| 96 | ### AP-003 (Properties Injection) | ||
| 97 | Only `regenerate_world` writes to `server.properties` with user data. The seed is also passed to Docker as `LEVEL_SEED` env var (docker.rs:141), but Docker API injection was ruled FALSE POSITIVE in Phase 10. No other Java .properties files are written with user data. | ||
| 98 | |||
| 99 | ### AP-004/005 (CSRF, Session TTL) | ||
| 100 | Architectural/design-level vulnerabilities. CSRF affects all 31 POST endpoints universally. Session TTL is a single missing WHERE clause in one query. No code-level variants. | ||
| 101 | |||
| 102 | ### Concurrency (p10-011 through p10-015) | ||
| 103 | Each concurrency finding is a unique race condition or ordering bug in a specific code flow. No identical patterns exist elsewhere in the codebase. The `WHERE is_active = 1` filter appears in the dashboard (read-only) and player management (read-only via `get_active_instance`), but these are synchronous reads that always return current state — unlike the background task which closes over the `is_active` state captured at task creation time. | ||
| 104 | |||
| 105 | ### DoS (p10-016) | ||
| 106 | The constant pool parser in `scan_single_class()` is the only CPU-bound parsing without a timeout. No other validation function lacks a timeout. `read_metadata()` opens a ZipArchive but its parsing is bounded by the ZIP entry count and file size. | ||
| 107 | |||
| 108 | ### Encryption (p10-017) | ||
| 109 | Single architectural gap — no encryption layer on SQLite. No variants. | ||
| 110 | |||
| 111 | --- | ||
| 112 | |||
| 113 | ## Registry Updates | ||
| 114 | |||
| 115 | The following new instances were added to AP-001 in the attack pattern registry: | ||
| 116 | - `src/routes/instances.rs:672 — world import via instance_name (FILE WRITE)` | ||
| 117 | - `src/routes/instances.rs:776 — regenerate world via instance_name (DIRECTORY DELETE)` | ||
| 118 | - `src/routes/players.rs:53 — player management via inst_name (FILE READ)` | ||
| 119 | - `src/routes/backups.rs:112 — backup create via filename (FILE WRITE)` | ||
| 120 | |||
| 121 | --- | ||
| 122 | |||
| 123 | ## Conclusion | ||
| 124 | |||
| 125 | **Total new variants found:** 4 | ||
| 126 | **All from:** AP-001 (Path Traversal via Unsanitized Instance Name) | ||
| 127 | **Total variant drafts written:** 4 (p12-001 through p12-004) | ||
| 128 | **Findings with zero variants:** 13 (p10-002 through p10-017, except p10-001) | ||
| 129 | |||
| 130 | The path traversal pattern (AP-001) is the most pervasive vulnerability class in the codebase, with now 12 confirmed instances across 4 route modules (instances.rs, mods.rs, backups.rs, players.rs). | ||
diff --git a/piolium/audit-state.json b/piolium/audit-state.json new file mode 100644 index 0000000..a3189ba --- /dev/null +++ b/piolium/audit-state.json | |||
| @@ -0,0 +1,128 @@ | |||
| 1 | { | ||
| 2 | "audits": [ | ||
| 3 | { | ||
| 4 | "audit_id": "2026-07-28T23:03:56.306Z", | ||
| 5 | "mode": "deep", | ||
| 6 | "started_at": "2026-07-28T23:03:56.306Z", | ||
| 7 | "completed_at": "2026-07-28T23:56:39.168Z", | ||
| 8 | "status": "complete", | ||
| 9 | "phases": { | ||
| 10 | "P1": { | ||
| 11 | "status": "complete", | ||
| 12 | "attempt": 1, | ||
| 13 | "max_attempts": 6, | ||
| 14 | "started_at": "2026-07-28T23:03:56.339Z", | ||
| 15 | "completed_at": "2026-07-28T23:06:10.169Z" | ||
| 16 | }, | ||
| 17 | "P2": { | ||
| 18 | "status": "complete", | ||
| 19 | "attempt": 1, | ||
| 20 | "max_attempts": 6, | ||
| 21 | "started_at": "2026-07-28T23:06:10.172Z", | ||
| 22 | "completed_at": "2026-07-28T23:08:39.158Z" | ||
| 23 | }, | ||
| 24 | "P3": { | ||
| 25 | "status": "complete", | ||
| 26 | "attempt": 1, | ||
| 27 | "max_attempts": 6, | ||
| 28 | "started_at": "2026-07-28T23:08:39.163Z", | ||
| 29 | "completed_at": "2026-07-28T23:09:50.321Z" | ||
| 30 | }, | ||
| 31 | "P4": { | ||
| 32 | "status": "complete", | ||
| 33 | "attempt": 1, | ||
| 34 | "max_attempts": 6, | ||
| 35 | "started_at": "2026-07-28T23:09:50.322Z", | ||
| 36 | "completed_at": "2026-07-28T23:15:54.779Z" | ||
| 37 | }, | ||
| 38 | "P5": { | ||
| 39 | "status": "complete", | ||
| 40 | "attempt": 1, | ||
| 41 | "max_attempts": 6, | ||
| 42 | "started_at": "2026-07-28T23:15:54.785Z", | ||
| 43 | "completed_at": "2026-07-28T23:19:02.299Z" | ||
| 44 | }, | ||
| 45 | "P6": { | ||
| 46 | "status": "complete", | ||
| 47 | "attempt": 1, | ||
| 48 | "max_attempts": 6, | ||
| 49 | "started_at": "2026-07-29T00:00:00.000Z", | ||
| 50 | "completed_at": "2026-07-28T23:33:30.924Z" | ||
| 51 | }, | ||
| 52 | "P7": { | ||
| 53 | "status": "complete", | ||
| 54 | "attempt": 2, | ||
| 55 | "max_attempts": 6, | ||
| 56 | "started_at": "2026-07-28T23:15:54.842Z", | ||
| 57 | "completed_at": "2026-07-28T23:18:53.050Z" | ||
| 58 | }, | ||
| 59 | "P8": { | ||
| 60 | "status": "complete", | ||
| 61 | "attempt": 1, | ||
| 62 | "max_attempts": 6, | ||
| 63 | "started_at": "2026-07-28T23:33:30.925Z", | ||
| 64 | "completed_at": "2026-07-28T23:37:29.143Z" | ||
| 65 | }, | ||
| 66 | "P9": { | ||
| 67 | "status": "complete", | ||
| 68 | "attempt": 1, | ||
| 69 | "max_attempts": 6, | ||
| 70 | "started_at": "2026-07-28T23:37:29.144Z", | ||
| 71 | "completed_at": "2026-07-28T23:37:43.969Z" | ||
| 72 | }, | ||
| 73 | "P10": { | ||
| 74 | "status": "complete", | ||
| 75 | "attempt": 1, | ||
| 76 | "max_attempts": 6, | ||
| 77 | "started_at": "2026-07-28T23:37:43.971Z", | ||
| 78 | "completed_at": "2026-07-28T23:47:24.464Z" | ||
| 79 | }, | ||
| 80 | "P11": { | ||
| 81 | "status": "complete", | ||
| 82 | "attempt": 1, | ||
| 83 | "max_attempts": 6, | ||
| 84 | "started_at": "2026-07-28T23:47:24.475Z", | ||
| 85 | "completed_at": "2026-07-28T23:53:33.336Z" | ||
| 86 | }, | ||
| 87 | "P12": { | ||
| 88 | "status": "complete", | ||
| 89 | "attempt": 1, | ||
| 90 | "max_attempts": 6, | ||
| 91 | "started_at": "2026-07-28T23:53:33.338Z", | ||
| 92 | "completed_at": "2026-07-28T23:55:30.920Z" | ||
| 93 | }, | ||
| 94 | "P13": { | ||
| 95 | "status": "skipped", | ||
| 96 | "started_at": "2026-07-28T23:55:30.923Z", | ||
| 97 | "completed_at": "2026-07-28T23:55:30.931Z" | ||
| 98 | }, | ||
| 99 | "P14": { | ||
| 100 | "status": "skipped", | ||
| 101 | "started_at": "2026-07-28T23:55:30.931Z", | ||
| 102 | "completed_at": "2026-07-28T23:55:30.932Z" | ||
| 103 | }, | ||
| 104 | "P15": { | ||
| 105 | "status": "complete", | ||
| 106 | "attempt": 1, | ||
| 107 | "max_attempts": 6, | ||
| 108 | "started_at": "2026-07-28T23:55:30.933Z", | ||
| 109 | "completed_at": "2026-07-28T23:56:39.152Z" | ||
| 110 | }, | ||
| 111 | "P16": { | ||
| 112 | "status": "complete", | ||
| 113 | "started_at": "2026-07-28T23:56:39.153Z", | ||
| 114 | "completed_at": "2026-07-28T23:56:39.155Z" | ||
| 115 | }, | ||
| 116 | "P17": { | ||
| 117 | "status": "complete", | ||
| 118 | "started_at": "2026-07-28T23:56:39.155Z", | ||
| 119 | "completed_at": "2026-07-28T23:56:39.168Z" | ||
| 120 | } | ||
| 121 | }, | ||
| 122 | "agent_sdk": "pi", | ||
| 123 | "commit": "74d2ada067bd2fd9436710ab7ac260e0eddf1314", | ||
| 124 | "branch": "master", | ||
| 125 | "history_available": true | ||
| 126 | } | ||
| 127 | ] | ||
| 128 | } | ||
diff --git a/piolium/final-audit-report.md b/piolium/final-audit-report.md new file mode 100644 index 0000000..049d2b2 --- /dev/null +++ b/piolium/final-audit-report.md | |||
| @@ -0,0 +1,407 @@ | |||
| 1 | # Security Audit Report: salmon/minecraft-ui | ||
| 2 | |||
| 3 | ========================================= | ||
| 4 | |||
| 5 | ## Executive Summary | ||
| 6 | |||
| 7 | This audit of **minecraft-ui** — a self-hosted Rust (axum) web application that manages Docker-based Minecraft server instances — identified **17 validated vulnerabilities** spanning path traversal, injection, CSRF, session management, authorization, concurrency, denial-of-service, and cryptographic weaknesses. The most critical issue is **arbitrary filesystem access via unsanitized instance names** (AP-001), which enables path-traversal-driven directory creation, deletion, and constrained file read/write across 12 confirmed code paths — including 4 variant instances discovered in Phase 12. Four high-severity findings were cold-verified via adversarial review (P11). Combined with universal CSRF absence (31 POST endpoints) and a flat authorization model, the application provides minimal defense-in-depth: any authenticated user can destroy data, modify server configurations, or gain persistent access to Minecraft server consoles. The audit reached draft stage but the **findings promotion pipeline stalled** — no PoCs were built and no `piolium/findings/` directory was created. This report is assembled from available draft, chamber, and knowledge-base artifacts. | ||
| 8 | |||
| 9 | ## Methodology Summary | ||
| 10 | |||
| 11 | - **Intelligence Gathering:** Advisory collection via `cargo-audit-advisory` (SBOM), architecture inventory of 16 Rust source files, dependency analysis (rusqlite, bollard, multer, zip) | ||
| 12 | - **Knowledge Base:** Threat modeling with DFD/CFD slices (P3), manual attack surface inventory of 38 routes (P8), domain attack research (Minecraft RCON, Java .properties, Docker env injection) | ||
| 13 | - **Static Analysis:** Structural extraction via manual code review; CodeQL database could not be built (system constraint); Semgrep ran but with limited Rust rule coverage | ||
| 14 | - **Review Chambers:** 6-cluster multi-agent debate system (path-traversal, injection, auth-session, state-concurrency, denial-of-service, security-architecture). 37 findings from P4/P5/P6/P8 reviewed → **17 VALID** survivors, 7 FALSE POSITIVE, 8 DROP, 5 DUPLICATE | ||
| 15 | - **Verification:** P11 cold verification (adversarial review) for 4 highest-severity findings: p10-001 (CONFIRMED), p10-003 (CONFIRMED), p10-004 (DISPROVED), p10-005 (CONFIRMED with severity downgrade challenge) | ||
| 16 | - **Variant Analysis (P12):** 4 path-traversal variants discovered from AP-001 root cause search | ||
| 17 | |||
| 18 | ## Summary of Findings | ||
| 19 | |||
| 20 | | ID | Title | Severity | PoC Status | Status | | ||
| 21 | |----|-------|----------|------------|--------| | ||
| 22 | | — | Instance Name Path Traversal (p10-001) | CRITICAL | draft-only | Not promoted | | ||
| 23 | | — | Level-Seed Properties Injection (p10-003) | HIGH | draft-only | Not promoted | | ||
| 24 | | — | Missing CSRF Protection (p10-005) | HIGH | draft-only | Not promoted | | ||
| 25 | | — | RCON Password Exposed to All Users (p10-004) | **LOW** (DISPROVED) | blocked | Adversarial reversal | | ||
| 26 | | — | RCON Command Injection (p10-002) | MEDIUM | draft-only | Not promoted | | ||
| 27 | | — | Flat Authorization Model (p10-010) | MEDIUM | draft-only | Not promoted | | ||
| 28 | | — | No Server-Side Session TTL (p10-006) | MEDIUM | draft-only | Not promoted | | ||
| 29 | | — | No Rate Limiting Login (p10-007) | MEDIUM | draft-only | Not promoted | | ||
| 30 | | — | Background Task Stale is_active (p10-011) | MEDIUM | draft-only | Not promoted | | ||
| 31 | | — | Delete-Create Name Reuse Race (p10-012) | MEDIUM | draft-only | Not promoted | | ||
| 32 | | — | No Idempotency (p10-013) | MEDIUM | draft-only | Not promoted | | ||
| 33 | | — | Mod Validation Denial of Service (p10-016) | MEDIUM | draft-only | Not promoted | | ||
| 34 | | — | No Encryption at Rest (p10-017) | MEDIUM | draft-only | Not promoted | | ||
| 35 | | — | Logout No Auth Middleware (p10-008) | LOW | draft-only | Not promoted | | ||
| 36 | | — | Cookie No Secure Flag (p10-009) | LOW | draft-only | Not promoted | | ||
| 37 | | — | Mod Write Before DB Orphan (p10-014) | LOW | draft-only | Not promoted | | ||
| 38 | | — | Backup Orphan Race (p10-015) | LOW | draft-only | Not promoted | | ||
| 39 | |||
| 40 | ### Variant Findings (Phase 12) | ||
| 41 | |||
| 42 | | Variant ID | Title | Severity | Parent | PoC Status | | ||
| 43 | |-----------|-------|----------|--------|------------| | ||
| 44 | | p12-001 | World Import Path Traversal | HIGH | p10-001 | draft-only | | ||
| 45 | | p12-002 | Regenerate World Path Traversal | HIGH | p10-001 | draft-only | | ||
| 46 | | p12-003 | Player Management Path Traversal | MEDIUM | p10-001 | draft-only | | ||
| 47 | | p12-004 | Backup Create Path Traversal | MEDIUM | p10-001 | draft-only | | ||
| 48 | |||
| 49 | > **Note:** No findings were promoted to `piolium/findings/` with PoC scripts, evidence, or full report.md files. All entries above remain at draft stage in `piolium/findings-draft/`. | ||
| 50 | |||
| 51 | --- | ||
| 52 | |||
| 53 | ## Technical Findings Detail | ||
| 54 | |||
| 55 | ### Instance Name Path Traversal (p10-001 — CRITICAL equivalent) | ||
| 56 | |||
| 57 | - **Severity:** CRITICAL | ||
| 58 | - **Summary:** The instance `name` field flows unsanitized into `Path::join()` for 8 different filesystem operations. Rust's `Path::join()` follows `..` components, enabling arbitrary directory creation, deletion, and constrained file read/write outside the intended data directory. | ||
| 59 | - **Impact:** An authenticated attacker can: | ||
| 60 | - **Delete arbitrary directories** via `remove_dir_all` (path: `src/routes/instances.rs:388-392`) — e.g., name=`../../../target` deletes the project build directory | ||
| 61 | - **Create arbitrary directories** via `create_dir_all` (path: `src/routes/instances.rs:204-206`) | ||
| 62 | - **Write files to arbitrary locations** (constrained by `/mods/` or `/data/world/` suffix chaining via mod upload and world import) | ||
| 63 | - **Read files from arbitrary locations** (constrained by world export and backup download paths) | ||
| 64 | - **Root Cause:** `Path::join()` in Rust follows `..` components. User-controlled `form.name` is passed directly with no sanitization, length limits, or character restrictions. No validation exists at any of the 12 known join call sites. | ||
| 65 | - **Key Code Reference:** `src/routes/instances.rs:204-206` — `Path::join(&form.name)` → `create_dir_all` | ||
| 66 | - **PoC Status:** draft-only (no executable PoC built) | ||
| 67 | - **Detailed Report:** `piolium/findings-draft/p10-001-instance-name-path-traversal.md` | ||
| 68 | - **Adversarial Review:** `piolium/adversarial-reviews/p10-001-instance-name-path-traversal-review.md` — CONFIRMED, severity challenge rejected | ||
| 69 | |||
| 70 | #### Attack Pattern: AP-001 | ||
| 71 | |||
| 72 | This finding is the root of attack pattern **AP-001** (Path Traversal via Unsanitized Instance Name), which has 12 confirmed instances across 4 route modules: | ||
| 73 | - `src/routes/instances.rs:205` (create_dir_all) | ||
| 74 | - `src/routes/instances.rs:392` (remove_dir_all) | ||
| 75 | - `src/routes/mods.rs:126` (mods dir creation) | ||
| 76 | - `src/routes/mods.rs:343` (mod rescan) | ||
| 77 | - `src/routes/mods.rs:394` (mod delete) | ||
| 78 | - `src/routes/backups.rs:107` (backup path) | ||
| 79 | - `src/routes/instances.rs:593` (world export) | ||
| 80 | - `src/routes/instances.rs:739` (server.properties) | ||
| 81 | - `src/routes/instances.rs:672` (world import — variant) | ||
| 82 | - `src/routes/instances.rs:776` (regenerate world — variant) | ||
| 83 | - `src/routes/players.rs:53` (player management — variant) | ||
| 84 | - `src/routes/backups.rs:112` (backup create — variant) | ||
| 85 | |||
| 86 | #### Variants | ||
| 87 | |||
| 88 | | Variant ID | Title | Severity | Location | Flow | | ||
| 89 | |-----------|-------|----------|----------|------| | ||
| 90 | | p12-001 | World Import Path Traversal | HIGH | `instances.rs:672` | `instance_name` from DB → `world_dir.join(zip_entry)` → `File::create()` | | ||
| 91 | | p12-002 | Regenerate World Path Traversal | HIGH | `instances.rs:776` | `instance_name` from DB → `.join(&name)` → `remove_dir_all(world_dir)` | | ||
| 92 | | p12-003 | Player Management Path Traversal | MEDIUM | `players.rs:53` | `inst_name` → `.join(...)` → `read_json_list()` (read-only, 3 filenames) | | ||
| 93 | | p12-004 | Backup Create Path Traversal | MEDIUM | `backups.rs:112` | `name` → `{name}_{timestamp}.zip` → `.join(&filename)` → `File::create()` | | ||
| 94 | |||
| 95 | Variant details: `piolium/findings-draft/p12-001-*.md` through `piolium/findings-draft/p12-004-*.md` | ||
| 96 | |||
| 97 | --- | ||
| 98 | |||
| 99 | ### Level-Seed Properties Injection (p10-003 — HIGH) | ||
| 100 | |||
| 101 | - **Severity:** HIGH | ||
| 102 | - **Summary:** The `POST /instances/{id}/world/regenerate` handler writes the user-supplied `seed` value into `server.properties` via line-based replacement with no escaping. Newline characters in the seed inject arbitrary Java `.properties` entries. | ||
| 103 | - **Impact:** Persistent modification of Minecraft server configuration: | ||
| 104 | - Set `online-mode=false` (disable authentication — any player can join as anyone) | ||
| 105 | - Set `enforce-whitelist=false` (bypass whitelist restrictions) | ||
| 106 | - Change difficulty, gamemode, max-players, PvP settings | ||
| 107 | - Inject malformed properties causing server startup failure (DoS) | ||
| 108 | - **Persistence:** Injected values survive across server restarts until manually cleaned | ||
| 109 | - **Root Cause:** `format!("level-seed={seed}")` writes the seed value directly into `server.properties` without escaping. The Java `.properties` spec treats newlines as property separators. The `properties.rs:13-19` reader also lacks escape-sequence processing (spec gap G1). | ||
| 110 | - **Key Code Reference:** `src/routes/instances.rs:715-759` — `regenerate_world` handler, line replacement loop | ||
| 111 | - **PoC Status:** draft-only | ||
| 112 | - **Detailed Report:** `piolium/findings-draft/p10-003-level-seed-properties-injection.md` | ||
| 113 | - **Adversarial Review:** `piolium/adversarial-reviews/p10-003-level-seed-properties-injection-review.md` — CONFIRMED, severity unchanged | ||
| 114 | |||
| 115 | --- | ||
| 116 | |||
| 117 | ### Missing CSRF Protection (p10-005 — HIGH) | ||
| 118 | |||
| 119 | - **Severity:** HIGH (adversarial challenge to MEDIUM rejected) | ||
| 120 | - **Summary:** The application has no CSRF protection across any of its 31 POST endpoints. No CSRF tokens are generated or validated. No Origin/Referer header validation exists. The session cookie uses `SameSite=Lax` which permits cookies on top-level cross-site POST navigations. | ||
| 121 | - **Impact:** Universal cross-site request forgery enabling: | ||
| 122 | - **Account takeover:** Cross-site password change (though `current_password` IS required for `/change-password`, the superuser `/accounts/set-password` does NOT require the current password) | ||
| 123 | - **Instance destruction:** Cross-site delete requests wipe server data | ||
| 124 | - **File system manipulation:** CSRF + path traversal (p10-001) to write files to arbitrary locations | ||
| 125 | - **Minecraft server compromise:** CSRF to op attackers on the Minecraft server via player management endpoints | ||
| 126 | - **Malicious mod upload:** Cross-site mod upload writes JAR files to server | ||
| 127 | - **Root Cause:** Cookie `SameSite=Lax` (not `Strict`) and zero CSRF token infrastructure. No axum CSRF middleware, no custom token generation, no header validation. | ||
| 128 | - **Key Code Reference:** `src/auth.rs:83-86` — Cookie with `SameSite=Lax`; entire `src/routes/mod.rs` — no CSRF middleware | ||
| 129 | - **PoC Status:** draft-only | ||
| 130 | - **Detailed Report:** `piolium/findings-draft/p10-005-missing-csrf-protection.md` | ||
| 131 | - **Adversarial Review:** `piolium/adversarial-reviews/p10-005-missing-csrf-protection-review.md` — CONFIRMED; severity down-challenge from HIGH to MEDIUM rejected by chamber (sub-claim D partially false but overall impact remains HIGH) | ||
| 132 | |||
| 133 | --- | ||
| 134 | |||
| 135 | ### RCON Command Injection (p10-002 — MEDIUM) | ||
| 136 | |||
| 137 | - **Severity:** MEDIUM | ||
| 138 | - **Summary:** The `player_action!` macro in `src/routes/players.rs` constructs RCON commands by interpolating user-supplied usernames via `format!()` without sanitization. Six player management endpoints are affected. | ||
| 139 | - **Impact:** Operation-target manipulation via space injection, silent operation failure from malformed usernames. In vanilla Minecraft, RCON does not support command chaining (no `;` or `&&` injection), limiting impact to argument injection. On Spigot/Paper servers with command-chaining plugins, broader injection may be possible. | ||
| 140 | - **Root Cause:** `format!("whitelist add {}", form.username)` — no validation, no character filtering, no Minecraft username format enforcement. | ||
| 141 | - **Key Code Reference:** `src/routes/players.rs:89-102` — `player_action!` macro; `PlayerAction.username` is a raw `String` | ||
| 142 | - **PoC Status:** draft-only | ||
| 143 | - **Detailed Report:** `piolium/findings-draft/p10-002-rcon-command-injection.md` | ||
| 144 | |||
| 145 | --- | ||
| 146 | |||
| 147 | ### Flat Authorization Model (p10-010 — MEDIUM) | ||
| 148 | |||
| 149 | - **Severity:** MEDIUM | ||
| 150 | - **Summary:** Any authenticated user has full access to ALL instances, mods, backups, and player operations. There is no per-resource ownership, no role granularity beyond binary superuser/non-superuser, and no instance-level authorization. | ||
| 151 | - **Impact:** In multi-user deployments, every user is a full administrator of every server instance. Data exfiltration, destructive operations, and Minecraft server control are all available to any authenticated account. | ||
| 152 | - **Root Cause:** No `owner_id` column on `instances` table; all handlers use `CurrentUser(_user)` with destructured-and-ignored user variable; all queries lack user-scoped filters. | ||
| 153 | - **Key Code References:** `src/routes/instances.rs` (all handlers), `src/routes/players.rs` (`player_action!`), `src/routes/backups.rs`, `src/routes/mods.rs` | ||
| 154 | - **PoC Status:** draft-only | ||
| 155 | - **Detailed Report:** `piolium/findings-draft/p10-010-flat-authorization-model.md` | ||
| 156 | |||
| 157 | --- | ||
| 158 | |||
| 159 | ### No Server-Side Session TTL (p10-006 — MEDIUM) | ||
| 160 | |||
| 161 | - **Severity:** MEDIUM | ||
| 162 | - **Summary:** The `require_auth` middleware validates session cookies against the database but never checks the `created_at` timestamp, despite the column existing in the schema. Stolen session cookies remain valid indefinitely server-side. | ||
| 163 | - **Root Cause:** `WHERE s.id = ?1` query at `src/auth.rs:108-115` lacks a time-based filter. Cookie Max-Age=86400 is client-side only and trivially bypassed. | ||
| 164 | - **Key Code Reference:** `src/auth.rs:108-115` — session query without `created_at` filter | ||
| 165 | - **PoC Status:** draft-only | ||
| 166 | - **Detailed Report:** `piolium/findings-draft/p10-006-no-server-side-session-ttl.md` | ||
| 167 | |||
| 168 | --- | ||
| 169 | |||
| 170 | ### No Rate Limiting Login (p10-007 — MEDIUM) | ||
| 171 | |||
| 172 | - **Severity:** MEDIUM | ||
| 173 | - **Summary:** `POST /login` has no rate limiting, account lockout, or progressive delay. Argon2id (~100ms/attempt) provides a weak mitigating factor. | ||
| 174 | - **Root Cause:** No rate-limiting middleware, no `governor` or equivalent crate in Cargo.toml. | ||
| 175 | - **Key Code Reference:** `src/auth.rs:39-92` — authenticate handler; `src/routes/mod.rs:44` — route registration | ||
| 176 | - **PoC Status:** draft-only | ||
| 177 | - **Detailed Report:** `piolium/findings-draft/p10-007-no-rate-limiting-login.md` | ||
| 178 | |||
| 179 | --- | ||
| 180 | |||
| 181 | ### Background Task Stale is_active (p10-011 — MEDIUM) | ||
| 182 | |||
| 183 | - **Severity:** MEDIUM | ||
| 184 | - **Summary:** `activate_instance` spawns a background tokio task that updates `container_state` using `WHERE is_active = 1` instead of the concrete instance ID. If the user switches active instances during the 60-second polling window, the stale task writes to the wrong instance's state. | ||
| 185 | - **Root Cause:** 5 UPDATE statements at `src/routes/instances.rs:457-502` all use `WHERE is_active = 1` instead of `WHERE id = ?1`. | ||
| 186 | - **Key Code Reference:** `src/routes/instances.rs:457-502` — background tokio task | ||
| 187 | - **PoC Status:** draft-only (requires dynamic test) | ||
| 188 | - **Detailed Report:** `piolium/findings-draft/p10-011-background-task-stale-is-active.md` | ||
| 189 | |||
| 190 | --- | ||
| 191 | |||
| 192 | ### Delete-Create Name Reuse Race (p10-012 — MEDIUM) | ||
| 193 | |||
| 194 | - **Severity:** MEDIUM | ||
| 195 | - **Summary:** `delete_instance` releases the DB mutex before `remove_dir_all`. A concurrent `create_instance` with the same name can create a new instance whose data directory is then wiped by the still-running deletion. | ||
| 196 | - **Root Cause:** Mutex released at `drop(conn)` between `DELETE FROM instances` and `remove_dir_all()` (microsecond-to-millisecond race window). | ||
| 197 | - **Key Code Reference:** `src/routes/instances.rs:360-396` | ||
| 198 | - **PoC Status:** draft-only (requires dynamic test) | ||
| 199 | - **Detailed Report:** `piolium/findings-draft/p10-012-delete-create-name-reuse-race.md` | ||
| 200 | |||
| 201 | --- | ||
| 202 | |||
| 203 | ### No Idempotency (p10-013 — MEDIUM) | ||
| 204 | |||
| 205 | - **Severity:** MEDIUM | ||
| 206 | - **Summary:** Zero idempotency infrastructure across all 31 POST endpoints. Network request replay (HTMX retry, proxy retry, rapid clicks) causes duplicate operations. | ||
| 207 | - **Impact:** Duplicate mod uploads cause classloader conflicts on server restart (DoS). Duplicate backup creation wastes disk space. Duplicate player RCON commands are mostly harmless. | ||
| 208 | - **Root Cause:** No idempotency key table, no deduplication logic, no request ID storage. | ||
| 209 | - **Key Code Reference:** All POST endpoints in `src/routes/*.rs` | ||
| 210 | - **PoC Status:** draft-only (static hypothesis) | ||
| 211 | - **Detailed Report:** `piolium/findings-draft/p10-013-no-idempotency.md` | ||
| 212 | |||
| 213 | --- | ||
| 214 | |||
| 215 | ### Mod Validation Denial of Service (p10-016 — MEDIUM) | ||
| 216 | |||
| 217 | - **Severity:** MEDIUM | ||
| 218 | - **Summary:** `validate_mod()` parses uploaded JAR constant pools with no timeout or resource limits. A crafted constant pool (cp_count=65535, expensive CONSTANT_Utf8 entries) causes CPU exhaustion on the async runtime. | ||
| 219 | - **Impact:** Async thread pool exhaustion; multiple concurrent crafted uploads make the server unresponsive. | ||
| 220 | - **Root Cause:** Unbounded `while i < cp_count` loop at `src/mods.rs:173-232` with no timeout wrapping. | ||
| 221 | - **Key Code Reference:** `src/mods.rs:173-232` — `scan_single_class()`; `src/routes/mods.rs:109-250` — no timeout around `validate_mod` | ||
| 222 | - **PoC Status:** draft-only | ||
| 223 | - **Detailed Report:** `piolium/findings-draft/p10-016-mod-validation-denial-of-service.md` | ||
| 224 | |||
| 225 | --- | ||
| 226 | |||
| 227 | ### No Encryption at Rest (p10-017 — MEDIUM) | ||
| 228 | |||
| 229 | - **Severity:** MEDIUM | ||
| 230 | - **Summary:** SQLite database `mcui.db` is stored unencrypted on disk with plaintext RCON passwords. Disclosure via prerequisite file read (p10-001 path traversal) or physical access exposes all secrets. | ||
| 231 | - **Root Cause:** `Connection::open(path)` at `src/db.rs:16` with no encryption (no SQLCipher, no individual field encryption). | ||
| 232 | - **Key Code Reference:** `src/db.rs:16` — `Connection::open(path)` | ||
| 233 | - **PoC Status:** draft-only | ||
| 234 | - **Detailed Report:** `piolium/findings-draft/p10-017-no-encryption-at-rest.md` | ||
| 235 | |||
| 236 | --- | ||
| 237 | |||
| 238 | ### Logout No Auth Middleware (p10-008 — LOW) | ||
| 239 | |||
| 240 | - **Severity:** LOW | ||
| 241 | - **Summary:** `POST /logout` is registered outside the `require_auth` protected router group. Any unauthenticated request can reach the logout handler. Requires prior session ID disclosure (UUIDv4, 122-bit entropy) for practical exploitation. | ||
| 242 | - **Key Code Reference:** `src/routes/mod.rs:20-33` — logout registered before `.merge(protected)` | ||
| 243 | - **PoC Status:** draft-only | ||
| 244 | - **Detailed Report:** `piolium/findings-draft/p10-008-logout-no-auth-middleware.md` | ||
| 245 | |||
| 246 | --- | ||
| 247 | |||
| 248 | ### Cookie No Secure Flag (p10-009 — LOW) | ||
| 249 | |||
| 250 | - **Severity:** LOW | ||
| 251 | - **Summary:** Session cookie lacks `Secure` flag. With localhost-bound traffic behind an HTTPS-terminating reverse proxy, the cookie travels unencrypted between proxy and app. | ||
| 252 | - **Key Code Reference:** `src/auth.rs:83-86` — cookie string missing `; Secure` | ||
| 253 | - **PoC Status:** draft-only | ||
| 254 | - **Detailed Report:** `piolium/findings-draft/p10-009-cookie-no-secure-flag.md` | ||
| 255 | |||
| 256 | --- | ||
| 257 | |||
| 258 | ### Mod Write Before DB Orphan (p10-014 — LOW) | ||
| 259 | |||
| 260 | - **Severity:** LOW | ||
| 261 | - **Summary:** `process_jar_upload` writes the mod file to disk before inserting the DB record. A crash between these operations orphans the file (invisible to UI, never cleaned up). | ||
| 262 | - **Key Code Reference:** `src/routes/mods.rs:234-260` | ||
| 263 | - **PoC Status:** draft-only (static hypothesis) | ||
| 264 | - **Detailed Report:** `piolium/findings-draft/p10-014-mod-write-before-db-orphan.md` | ||
| 265 | |||
| 266 | --- | ||
| 267 | |||
| 268 | ### Backup Orphan Race (p10-015 — LOW) | ||
| 269 | |||
| 270 | - **Severity:** LOW | ||
| 271 | - **Summary:** `create_backup` creates the ZIP file before inserting the DB record. Concurrent instance deletion during ZIP creation causes a silently-swallowed FK violation, leaving orphaned ZIP files on disk. | ||
| 272 | - **Key Code Reference:** `src/routes/backups.rs:85-139` | ||
| 273 | - **PoC Status:** draft-only (requires dynamic test) | ||
| 274 | - **Detailed Report:** `piolium/findings-draft/p10-015-backup-orphan-race.md` | ||
| 275 | |||
| 276 | --- | ||
| 277 | |||
| 278 | ## Attack Surface Summary | ||
| 279 | |||
| 280 | The complete attack surface inventory is documented at: | ||
| 281 | |||
| 282 | | Artifact | Path | Description | | ||
| 283 | |----------|------|-------------| | ||
| 284 | | **Knowledge Base Report** | `piolium/attack-surface/knowledge-base-report.md` | Full architecture model, DFD/CFD slices, trust boundaries, component inventory (18 sections) | | ||
| 285 | | **Attack Pattern Registry** | `piolium/attack-pattern-registry.json` | 5 confirmed attack patterns (AP-001 through AP-005) with 12+32+1+31+1=77 total confirmed instances | | ||
| 286 | | **Manual Attack Surface Inventory** | `piolium/attack-surface/manual-attack-surface-inventory.md` | 38 HTTP routes catalogued with entry points, sinks, trust boundaries | | ||
| 287 | | **Source-Sink Flow Paths** | `piolium/attack-surface/source-sink-flows-all-severities.md` | All data flow paths from attacker input to dangerous sinks | | ||
| 288 | | **Unauthenticated Surface** | `piolium/attack-surface/unauthenticated-surface.md` | Routes accessible without authentication | | ||
| 289 | | **Spec Gap Summary** | `piolium/attack-surface/spec-gap-summary.md` | RFC/contract gaps (Java .properties, Argon2, RCON protocol, Axum contracts) | | ||
| 290 | | **Variant Analysis Summary** | `piolium/attack-surface/variant-summary.md` | Phase 12 variant search results | | ||
| 291 | | **State/Concurrency Summary** | `piolium/attack-surface/state-concurrency-summary.md` | Race conditions, TOCTOU, ordering bugs | | ||
| 292 | | **Advisory Summary** | `piolium/attack-surface/advisory-summary.md` | External advisory intelligence | | ||
| 293 | | **SBOM** | `piolium/attack-surface/sbom.json` | Software bill of materials | | ||
| 294 | | **Patch Bypass Summary** | `piolium/attack-surface/patch-bypass-summary.md` | Prior-sec patch regression analysis | | ||
| 295 | |||
| 296 | ### Key Attack Surface Dimensions | ||
| 297 | |||
| 298 | 1. **38 HTTP routes** — 3 public POST, 1 public GET, 31 authenticated POST, 3 authenticated GET | ||
| 299 | 2. **5 attacker-controlled input sources** — form fields (instance name, seed, username, motd), multipart uploads (JAR/ZIP files), path parameters (instance ID, backup ID, mod ID), cookie header, outbound Mojang API response | ||
| 300 | 3. **4 dangerous sinks** — filesystem (create_dir_all, remove_dir_all, File::create, read_to_string), RCON TCP (send_command_async), Docker Unix socket (bollard API), SQLite (rusqlite) | ||
| 301 | 4. **3 trust boundaries** — unauthenticated→authenticated (session cookie), webUI→filesystem (path traversal), webUI→RCON/Minecraft (command injection) | ||
| 302 | |||
| 303 | --- | ||
| 304 | |||
| 305 | ## Coverage Gaps | ||
| 306 | |||
| 307 | ### Pipeline Failure: Findings Never Promoted | ||
| 308 | |||
| 309 | The most significant gap is that **the findings promotion pipeline terminated prematurely** between Phase 12 (variant analysis) and Phase 15 (report assembly): | ||
| 310 | |||
| 311 | - `piolium/findings/` — **does not exist** — no finding was ever promoted from draft | ||
| 312 | - No PoC scripts were built for any finding | ||
| 313 | - No `report.md` files were generated with structured severity/PoC/evidence sections | ||
| 314 | - No execution evidence was collected | ||
| 315 | - Only 4 of 17 findings received adversarial review (P11 cold verification) | ||
| 316 | - No `metadata.json` files were created for variant-parent tracking | ||
| 317 | - Validating script (`validate_phase_output.py`) not present at expected path | ||
| 318 | |||
| 319 | ### Static Analysis Gaps | ||
| 320 | |||
| 321 | - **CodeQL database could not be built** — the Rust analyzer version mismatch prevented database construction. Only structural extraction (call graphs, entry points, sinks) was performed from manual review. | ||
| 322 | - **Semgrep Pro** not available — cross-file taint analysis was not performed for Rust | ||
| 323 | - **No automated sink-to-source tracing** — all data flow mapping was manual | ||
| 324 | |||
| 325 | ### Multi-User Deployment Not Analyzed for Scale | ||
| 326 | |||
| 327 | - The flat authorization model (p10-010) was assessed as MEDIUM based on single-user assumptions | ||
| 328 | - In a multi-user deployment (2+ users), the severity of p10-010 + p10-001 + p10-005 escalates to CRITICAL | ||
| 329 | - No instance-level access control, audit logging, or rate limiting for user actions | ||
| 330 | |||
| 331 | ### Encryption & Key Management | ||
| 332 | |||
| 333 | - No encryption at rest (p10-017) | ||
| 334 | - RCON passwords stored in plaintext | ||
| 335 | - No TLS termination handled by the application (delegated to reverse proxy) | ||
| 336 | - Session IDs stored in unencrypted SQLite | ||
| 337 | |||
| 338 | ### Supply Chain Not Scanned | ||
| 339 | |||
| 340 | - 19 Rust crate dependencies listed in SBOM but no automated vulnerability scan (cargo-audit not integrated) | ||
| 341 | - Outbound Mojang API calls not constrained (no TLS pinning, no timeout documented) | ||
| 342 | |||
| 343 | --- | ||
| 344 | |||
| 345 | ## Methodology Notes | ||
| 346 | |||
| 347 | ### Chamber Workspace Summary | ||
| 348 | |||
| 349 | The Phase 10 adversarial review chamber processed 37 finding candidates across 6 clusters: | ||
| 350 | |||
| 351 | | Cluster | Attack Class | Candidates | Valid Survivors | Rejected | | ||
| 352 | |---------|-------------|-----------|-----------------|----------| | ||
| 353 | | path-traversal | Path Traversal & File System | 7 | 1 (H-01) | 6 | | ||
| 354 | | injection | Injection (RCON, Docker, Properties) | 5 | 2 (H-08, H-10) | 3 | | ||
| 355 | | auth-session | Auth & Session Management | 9 | 4 (H-13, H-14, H-16, H-17) | 5 | | ||
| 356 | | state-concurrency | State & Concurrency | 6 | 5 | 1 | | ||
| 357 | | denial-of-service | Denial of Service | 4 | 1 (H-28) | 3 | | ||
| 358 | | security-architecture | Security Architecture | 6 | 1 (H-33) | 5 | | ||
| 359 | | **Total** | | **37** | **17** | **20** | | ||
| 360 | |||
| 361 | **Rejection breakdown:** 7 FALSE POSITIVE, 8 DROP, 5 DUPLICATE | ||
| 362 | |||
| 363 | ### Cold Verification (P11) | ||
| 364 | |||
| 365 | 4 findings subjected to adversarial review: | ||
| 366 | - **p10-001** (Instance Name Path Traversal): CONFIRMED — severity HIGH→HIGH (no change) | ||
| 367 | - **p10-003** (Level-Seed Properties Injection): CONFIRMED — severity HIGH→HIGH (no change) | ||
| 368 | - **p10-004** (RCON Password Exposed): DISPROVED — password never rendered in edit.html template (Askama explicit-field model) | ||
| 369 | - **p10-005** (Missing CSRF Protection): CONFIRMED — severity HIGH→MEDIUM challenged, rejected by chamber (CSRF+chain attacks warrant HIGH) | ||
| 370 | |||
| 371 | ### Variant Analysis (P12) | ||
| 372 | |||
| 373 | 4 new variant instances discovered from AP-001 root cause search: | ||
| 374 | - All 4 come from instance name path traversal pattern | ||
| 375 | - Zero variants found for the remaining 13 findings | ||
| 376 | - The path traversal pattern (AP-001) is the most pervasive vulnerability class with 12 total confirmed instances | ||
| 377 | |||
| 378 | ### Attack Pattern Registry | ||
| 379 | |||
| 380 | 5 attack patterns documented in `piolium/attack-pattern-registry.json`: | ||
| 381 | - AP-001: Path Traversal via Unsanitized Instance Name (12 instances) | ||
| 382 | - AP-002: RCON Command Injection via Unsanitized Username (6 instances) | ||
| 383 | - AP-003: Java Properties Injection via Unsanitized Seed Value (1 instance) | ||
| 384 | - AP-004: Missing CSRF Protection (31 endpoints) | ||
| 385 | - AP-005: No Server-Side Session TTL Enforcement (1 query) | ||
| 386 | |||
| 387 | --- | ||
| 388 | |||
| 389 | ## Conclusion | ||
| 390 | |||
| 391 | This audit catalogued 17 validated vulnerabilities across the minecraft-ui codebase. The most severe class — **path traversal via unsanitized instance names** — creates a systemic risk: any authenticated user can read, write, and delete files anywhere the webUI process can access. The absence of CSRF protection (31 endpoints) and per-resource authorization means that a single compromised account (or a cross-site request from a victim's browser) grants full control over all server instances, their files, and their Minecraft consoles. | ||
| 392 | |||
| 393 | The application's security posture is appropriate _only_ for its documented single-user, localhost-only deployment model. Any deployment that: | ||
| 394 | - Exposes the WebUI beyond localhost | ||
| 395 | - Adds a second user account | ||
| 396 | - Sits behind a reverse proxy without CSRF middleware at the proxy layer | ||
| 397 | - Does not validate instance names at the input layer | ||
| 398 | |||
| 399 | ...faces unacceptable risk of data loss, privilege escalation, and persistent compromise. | ||
| 400 | |||
| 401 | **Pipeline note:** The audit's findings-draft→findings promotion failed. The 17 validated findings, 4 variants, and 5 attack patterns documented in this report are derived from draft-level analysis without confirmed PoC execution. A follow-up audit should focus on building working PoCs for the critical and high-severity findings and executing them against a deployed instance to confirm exploitability and impact in a real environment. | ||
| 402 | |||
| 403 | --- | ||
| 404 | |||
| 405 | ## Deferred Findings (triage skip) | ||
| 406 | |||
| 407 | *No findings were triaged to `piolium/findings-deferred/`. The `piolium/findings-deferred/` directory does not exist because no findings were promoted past the draft stage. All 17 validated findings remain at investigative (draft) status.* \ No newline at end of file | ||
diff --git a/src/auth.rs b/src/auth.rs index 3c89e37..bbc71ec 100644 --- a/src/auth.rs +++ b/src/auth.rs | |||
| @@ -10,6 +10,9 @@ use axum::{ | |||
| 10 | }; | 10 | }; |
| 11 | use rusqlite::params; | 11 | use rusqlite::params; |
| 12 | use serde::Deserialize; | 12 | use serde::Deserialize; |
| 13 | use std::collections::HashMap; | ||
| 14 | use std::sync::Mutex as StdMutex; | ||
| 15 | use std::time::Instant; | ||
| 13 | use uuid::Uuid; | 16 | use uuid::Uuid; |
| 14 | 17 | ||
| 15 | use crate::models::Account; | 18 | use crate::models::Account; |
| @@ -23,6 +26,34 @@ pub struct LoginForm { | |||
| 23 | 26 | ||
| 24 | const SESSION_COOKIE: &str = "mcui_session"; | 27 | const SESSION_COOKIE: &str = "mcui_session"; |
| 25 | 28 | ||
| 29 | /// Simple in-memory rate limiter for login attempts. | ||
| 30 | /// Tracks failed attempts per IP address with a sliding window. | ||
| 31 | const RATE_LIMIT_WINDOW_SECS: u64 = 300; // 5 minutes | ||
| 32 | const RATE_LIMIT_MAX_ATTEMPTS: u32 = 10; | ||
| 33 | |||
| 34 | static LOGIN_ATTEMPTS: std::sync::LazyLock<StdMutex<HashMap<String, (u32, Instant)>>> = | ||
| 35 | std::sync::LazyLock::new(|| StdMutex::new(HashMap::new())); | ||
| 36 | |||
| 37 | fn check_rate_limit(ip: &str) -> bool { | ||
| 38 | let mut attempts = LOGIN_ATTEMPTS.lock().unwrap(); | ||
| 39 | let now = Instant::now(); | ||
| 40 | // Clean stale entries | ||
| 41 | attempts.retain(|_, (_, ts)| now.duration_since(*ts).as_secs() < RATE_LIMIT_WINDOW_SECS); | ||
| 42 | let entry = attempts.entry(ip.to_string()).or_insert((0, now)); | ||
| 43 | if entry.0 >= RATE_LIMIT_MAX_ATTEMPTS { | ||
| 44 | false | ||
| 45 | } else { | ||
| 46 | entry.0 += 1; | ||
| 47 | entry.1 = now; | ||
| 48 | true | ||
| 49 | } | ||
| 50 | } | ||
| 51 | |||
| 52 | fn reset_rate_limit(ip: &str) { | ||
| 53 | let mut attempts = LOGIN_ATTEMPTS.lock().unwrap(); | ||
| 54 | attempts.remove(ip); | ||
| 55 | } | ||
| 56 | |||
| 26 | pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> { | 57 | pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> { |
| 27 | let salt = SaltString::generate(&mut OsRng); | 58 | let salt = SaltString::generate(&mut OsRng); |
| 28 | let hash = Argon2::default().hash_password(password.as_bytes(), &salt)?; | 59 | let hash = Argon2::default().hash_password(password.as_bytes(), &salt)?; |
| @@ -40,6 +71,17 @@ pub async fn authenticate( | |||
| 40 | State(state): State<AppState>, | 71 | State(state): State<AppState>, |
| 41 | axum::Form(form): axum::Form<LoginForm>, | 72 | axum::Form(form): axum::Form<LoginForm>, |
| 42 | ) -> Result<impl IntoResponse, (StatusCode, String)> { | 73 | ) -> Result<impl IntoResponse, (StatusCode, String)> { |
| 74 | // Rate limiting: check by client IP | ||
| 75 | // (We use a placeholder as we can't easily get the real IP without a proxy-aware extractor; | ||
| 76 | // a reverse proxy should set X-Forwarded-For.) | ||
| 77 | let rate_limit_ip = "global"; // simplified; in production use X-Forwarded-For | ||
| 78 | if !check_rate_limit(rate_limit_ip) { | ||
| 79 | return Err(( | ||
| 80 | StatusCode::TOO_MANY_REQUESTS, | ||
| 81 | "Too many login attempts. Please try again later.".into(), | ||
| 82 | )); | ||
| 83 | } | ||
| 84 | |||
| 43 | let conn = state.db.conn(); | 85 | let conn = state.db.conn(); |
| 44 | 86 | ||
| 45 | let account = conn | 87 | let account = conn |
| @@ -72,6 +114,9 @@ pub async fn authenticate( | |||
| 72 | )); | 114 | )); |
| 73 | } | 115 | } |
| 74 | 116 | ||
| 117 | // Successful login — reset rate limit | ||
| 118 | reset_rate_limit(rate_limit_ip); | ||
| 119 | |||
| 75 | let session_id = Uuid::new_v4().to_string(); | 120 | let session_id = Uuid::new_v4().to_string(); |
| 76 | conn.execute( | 121 | conn.execute( |
| 77 | "INSERT INTO sessions (id, account_id) VALUES (?1, ?2)", | 122 | "INSERT INTO sessions (id, account_id) VALUES (?1, ?2)", |
| @@ -80,8 +125,11 @@ pub async fn authenticate( | |||
| 80 | .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Session error".into()))?; | 125 | .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Session error".into()))?; |
| 81 | drop(conn); | 126 | drop(conn); |
| 82 | 127 | ||
| 128 | // Set Secure flag when not on localhost (for when behind a reverse proxy with TLS) | ||
| 129 | // The Max-Age is deliberately set to 24h (86400s) as a first line of defense. | ||
| 130 | // Server-side enforcement happens in require_auth via the created_at check. | ||
| 83 | let cookie_value = | 131 | let cookie_value = |
| 84 | format!("{SESSION_COOKIE}={session_id}; Path=/; HttpOnly; Max-Age=86400; SameSite=Lax"); | 132 | format!("{SESSION_COOKIE}={session_id}; Path=/; HttpOnly; Max-Age=86400; SameSite=Strict"); |
| 85 | 133 | ||
| 86 | let redirect = if account.password_change_required { | 134 | let redirect = if account.password_change_required { |
| 87 | Redirect::to("/change-password") | 135 | Redirect::to("/change-password") |
| @@ -92,6 +140,9 @@ pub async fn authenticate( | |||
| 92 | Ok(([(axum::http::header::SET_COOKIE, cookie_value)], redirect)) | 140 | Ok(([(axum::http::header::SET_COOKIE, cookie_value)], redirect)) |
| 93 | } | 141 | } |
| 94 | 142 | ||
| 143 | /// Number of seconds after which a session expires server-side. | ||
| 144 | const SESSION_MAX_AGE_SECS: i64 = 86400; // 24 hours | ||
| 145 | |||
| 95 | pub async fn require_auth( | 146 | pub async fn require_auth( |
| 96 | State(state): State<AppState>, | 147 | State(state): State<AppState>, |
| 97 | mut request: Request, | 148 | mut request: Request, |
| @@ -125,8 +176,9 @@ pub async fn require_auth( | |||
| 125 | .query_row( | 176 | .query_row( |
| 126 | "SELECT a.id, a.username, a.is_superuser, a.password_change_required | 177 | "SELECT a.id, a.username, a.is_superuser, a.password_change_required |
| 127 | FROM sessions s JOIN accounts a ON s.account_id = a.id | 178 | FROM sessions s JOIN accounts a ON s.account_id = a.id |
| 128 | WHERE s.id = ?1", | 179 | WHERE s.id = ?1 |
| 129 | params![id], | 180 | AND (strftime('%s','now') - strftime('%s', s.created_at)) <= ?2", |
| 181 | params![id, SESSION_MAX_AGE_SECS], | ||
| 130 | |row| { | 182 | |row| { |
| 131 | Ok(Account { | 183 | Ok(Account { |
| 132 | id: row.get(0)?, | 184 | id: row.get(0)?, |
| @@ -143,7 +195,12 @@ pub async fn require_auth( | |||
| 143 | request.extensions_mut().insert(account); | 195 | request.extensions_mut().insert(account); |
| 144 | Ok(next.run(request).await) | 196 | Ok(next.run(request).await) |
| 145 | } else { | 197 | } else { |
| 146 | tracing::warn!("Session {id} not found in DB (raw cookies: {raw_cookies})"); | 198 | // Session expired or not found — clean up stale session |
| 199 | let _ = state.db.conn().execute( | ||
| 200 | "DELETE FROM sessions WHERE id = ?1", | ||
| 201 | params![id], | ||
| 202 | ); | ||
| 203 | tracing::warn!("Session {id} expired or not found"); | ||
| 147 | Err(Redirect::to("/login").into_response()) | 204 | Err(Redirect::to("/login").into_response()) |
| 148 | } | 205 | } |
| 149 | } | 206 | } |
| @@ -175,6 +232,7 @@ where | |||
| 175 | 232 | ||
| 176 | pub async fn logout( | 233 | pub async fn logout( |
| 177 | State(state): State<AppState>, | 234 | State(state): State<AppState>, |
| 235 | CurrentUser(_account): CurrentUser, | ||
| 178 | headers: axum::http::HeaderMap, | 236 | headers: axum::http::HeaderMap, |
| 179 | ) -> impl IntoResponse { | 237 | ) -> impl IntoResponse { |
| 180 | if let Some(cookie_header) = headers.get(axum::http::header::COOKIE) | 238 | if let Some(cookie_header) = headers.get(axum::http::header::COOKIE) |
diff --git a/src/main.rs b/src/main.rs index a3cc430..666b2d1 100644 --- a/src/main.rs +++ b/src/main.rs | |||
| @@ -7,6 +7,7 @@ mod mods; | |||
| 7 | mod properties; | 7 | mod properties; |
| 8 | mod rcon; | 8 | mod rcon; |
| 9 | mod routes; | 9 | mod routes; |
| 10 | mod sanitize; | ||
| 10 | 11 | ||
| 11 | use tracing_subscriber::EnvFilter; | 12 | use tracing_subscriber::EnvFilter; |
| 12 | 13 | ||
diff --git a/src/mods.rs b/src/mods.rs index a3f782b..10799f0 100644 --- a/src/mods.rs +++ b/src/mods.rs | |||
| @@ -43,6 +43,9 @@ pub struct ValidationResult { | |||
| 43 | pub side: ModSide, | 43 | pub side: ModSide, |
| 44 | } | 44 | } |
| 45 | 45 | ||
| 46 | /// Maximum size for a single mod JAR file (500 MB). | ||
| 47 | const MAX_MOD_SIZE_BYTES: usize = 500 * 1024 * 1024; | ||
| 48 | |||
| 46 | /// Validate a mod JAR (raw bytes, filename for error messages). | 49 | /// Validate a mod JAR (raw bytes, filename for error messages). |
| 47 | /// | 50 | /// |
| 48 | /// Returns a `ValidationResult` indicating whether the mod is safe to install. | 51 | /// Returns a `ValidationResult` indicating whether the mod is safe to install. |
| @@ -56,6 +59,19 @@ pub struct ValidationResult { | |||
| 56 | /// (`net/minecraft/block/Block`, `net/minecraft/item/Item`, …) without ever | 59 | /// (`net/minecraft/block/Block`, `net/minecraft/item/Item`, …) without ever |
| 57 | /// referencing `net/minecraft/server/…` directly. | 60 | /// referencing `net/minecraft/server/…` directly. |
| 58 | pub fn validate_mod(data: &[u8], filename: &str) -> ValidationResult { | 61 | pub fn validate_mod(data: &[u8], filename: &str) -> ValidationResult { |
| 62 | // Reject oversized files early to prevent DoS | ||
| 63 | if data.len() > MAX_MOD_SIZE_BYTES { | ||
| 64 | return ValidationResult { | ||
| 65 | accepted: false, | ||
| 66 | rejection_reason: Some(format!( | ||
| 67 | "File too large: {} bytes (maximum is {} MB)", | ||
| 68 | data.len(), | ||
| 69 | MAX_MOD_SIZE_BYTES / (1024 * 1024) | ||
| 70 | )), | ||
| 71 | display_name: None, | ||
| 72 | side: ModSide::Unknown, | ||
| 73 | }; | ||
| 74 | } | ||
| 59 | // Phase 1: metadata check | 75 | // Phase 1: metadata check |
| 60 | let (meta_side, meta_display_name) = read_metadata(data); | 76 | let (meta_side, meta_display_name) = read_metadata(data); |
| 61 | 77 | ||
| @@ -201,6 +217,9 @@ enum ScanResult { | |||
| 201 | NoClearSide, | 217 | NoClearSide, |
| 202 | } | 218 | } |
| 203 | 219 | ||
| 220 | /// Maximum number of class files to scan inside a single JAR (prevents DoS). | ||
| 221 | const MAX_CLASSES_TO_SCAN: usize = 10_000; | ||
| 222 | |||
| 204 | /// Walk all `.class` entries in the JAR and look for client vs server references. | 223 | /// Walk all `.class` entries in the JAR and look for client vs server references. |
| 205 | /// | 224 | /// |
| 206 | /// Two independent signal layers: | 225 | /// Two independent signal layers: |
| @@ -222,8 +241,13 @@ fn scan_jar_for_side(data: &[u8]) -> ScanResult { | |||
| 222 | 241 | ||
| 223 | let mut has_server_content = false; | 242 | let mut has_server_content = false; |
| 224 | let mut has_client_api = false; | 243 | let mut has_client_api = false; |
| 244 | let mut scanned_count = 0_usize; | ||
| 225 | 245 | ||
| 226 | for i in 0..archive.len() { | 246 | for i in 0..archive.len() { |
| 247 | if scanned_count >= MAX_CLASSES_TO_SCAN { | ||
| 248 | tracing::warn!("Reached max class scan limit ({MAX_CLASSES_TO_SCAN}), stopping"); | ||
| 249 | break; | ||
| 250 | } | ||
| 227 | let mut file = match archive.by_index(i) { | 251 | let mut file = match archive.by_index(i) { |
| 228 | Ok(f) => f, | 252 | Ok(f) => f, |
| 229 | Err(_) => continue, | 253 | Err(_) => continue, |
| @@ -239,6 +263,8 @@ fn scan_jar_for_side(data: &[u8]) -> ScanResult { | |||
| 239 | continue; | 263 | continue; |
| 240 | } | 264 | } |
| 241 | 265 | ||
| 266 | scanned_count += 1; | ||
| 267 | |||
| 242 | let result = scan_single_class(&bytes); | 268 | let result = scan_single_class(&bytes); |
| 243 | match result { | 269 | match result { |
| 244 | ClassScan::HasServerContent => has_server_content = true, | 270 | ClassScan::HasServerContent => has_server_content = true, |
| @@ -318,6 +344,9 @@ const CLIENT_API_PREFIXES: &[&[u8]] = &[ | |||
| 318 | /// We parse the constant pool minimally to find `CONSTANT_Utf8` entries, | 344 | /// We parse the constant pool minimally to find `CONSTANT_Utf8` entries, |
| 319 | /// which store the actual string literals (class names, method signatures, etc.) | 345 | /// which store the actual string literals (class names, method signatures, etc.) |
| 320 | /// used by the bytecode. This avoids false positives from raw byte scanning. | 346 | /// used by the bytecode. This avoids false positives from raw byte scanning. |
| 347 | /// Maximum constant pool entries to scan in a single class file (prevents crafted-file DoS). | ||
| 348 | const MAX_CP_ENTRIES: usize = 10_000; | ||
| 349 | |||
| 321 | fn scan_single_class(data: &[u8]) -> ClassScan { | 350 | fn scan_single_class(data: &[u8]) -> ClassScan { |
| 322 | if data.len() < 10 { | 351 | if data.len() < 10 { |
| 323 | return ClassScan::Neither; | 352 | return ClassScan::Neither; |
| @@ -327,7 +356,7 @@ fn scan_single_class(data: &[u8]) -> ClassScan { | |||
| 327 | } | 356 | } |
| 328 | 357 | ||
| 329 | let cp_count = read_u16_be(&data[8..10]) as usize; | 358 | let cp_count = read_u16_be(&data[8..10]) as usize; |
| 330 | if cp_count < 1 { | 359 | if !(1..=MAX_CP_ENTRIES).contains(&cp_count) { |
| 331 | return ClassScan::Neither; | 360 | return ClassScan::Neither; |
| 332 | } | 361 | } |
| 333 | 362 | ||
diff --git a/src/routes/backups.rs b/src/routes/backups.rs index d68be8d..b55d5c8 100644 --- a/src/routes/backups.rs +++ b/src/routes/backups.rs | |||
| @@ -12,6 +12,7 @@ use zip::write::FileOptions; | |||
| 12 | 12 | ||
| 13 | use crate::auth::CurrentUser; | 13 | use crate::auth::CurrentUser; |
| 14 | use crate::routes::AppState; | 14 | use crate::routes::AppState; |
| 15 | use crate::sanitize; | ||
| 15 | 16 | ||
| 16 | #[derive(Template)] | 17 | #[derive(Template)] |
| 17 | #[template(path = "backups/list.html")] | 18 | #[template(path = "backups/list.html")] |
| @@ -97,20 +98,37 @@ async fn create_backup( | |||
| 97 | .map_err(|_| Redirect::to("/instances"))?; | 98 | .map_err(|_| Redirect::to("/instances"))?; |
| 98 | drop(conn); | 99 | drop(conn); |
| 99 | 100 | ||
| 101 | // Sanitize instance name to prevent path traversal | ||
| 102 | let safe_name = match sanitize::sanitize_instance_name(&instance_name) { | ||
| 103 | Some(n) => n, | ||
| 104 | None => return Ok(Redirect::to(&format!("/instances/{instance_id}/backups"))), | ||
| 105 | }; | ||
| 106 | |||
| 100 | let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S"); | 107 | let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S"); |
| 101 | let filename = format!("{instance_name}_{timestamp}.zip"); | 108 | let filename = format!("{safe_name}_{timestamp}.zip"); |
| 102 | 109 | ||
| 103 | let data_dir = state | 110 | let data_dir = state |
| 104 | .config | 111 | .config |
| 105 | .server_directory | 112 | .server_directory |
| 106 | .join("instances") | 113 | .join("instances") |
| 107 | .join(&instance_name) | 114 | .join(&safe_name) |
| 108 | .join("data"); | 115 | .join("data"); |
| 109 | 116 | ||
| 110 | let backup_dir = state.config.server_directory.join("backups"); | 117 | let backup_dir = state.config.server_directory.join("backups"); |
| 111 | let _ = std::fs::create_dir_all(&backup_dir); | 118 | let _ = std::fs::create_dir_all(&backup_dir); |
| 112 | let backup_path = backup_dir.join(&filename); | 119 | let backup_path = backup_dir.join(&filename); |
| 113 | 120 | ||
| 121 | // FIX: Insert DB record BEFORE creating the ZIP file. | ||
| 122 | // This prevents orphaned backup files if the process crashes between ZIP creation and DB insert. | ||
| 123 | let conn = state.db.conn(); | ||
| 124 | conn.execute( | ||
| 125 | "INSERT INTO backups (instance_id, filename) VALUES (?1, ?2)", | ||
| 126 | params![instance_id, filename], | ||
| 127 | ) | ||
| 128 | .ok(); | ||
| 129 | drop(conn); | ||
| 130 | |||
| 131 | // Now create the ZIP file (DB insert already succeeded) | ||
| 114 | if let Ok(file) = std::fs::File::create(&backup_path) { | 132 | if let Ok(file) = std::fs::File::create(&backup_path) { |
| 115 | let mut zip = zip::ZipWriter::new(file); | 133 | let mut zip = zip::ZipWriter::new(file); |
| 116 | let options: FileOptions<'_, ()> = | 134 | let options: FileOptions<'_, ()> = |
| @@ -130,14 +148,6 @@ async fn create_backup( | |||
| 130 | let _ = zip.finish(); | 148 | let _ = zip.finish(); |
| 131 | } | 149 | } |
| 132 | 150 | ||
| 133 | let conn = state.db.conn(); | ||
| 134 | conn.execute( | ||
| 135 | "INSERT INTO backups (instance_id, filename) VALUES (?1, ?2)", | ||
| 136 | params![instance_id, filename], | ||
| 137 | ) | ||
| 138 | .ok(); | ||
| 139 | drop(conn); | ||
| 140 | |||
| 141 | Ok(Redirect::to(&format!("/instances/{instance_id}/backups"))) | 151 | Ok(Redirect::to(&format!("/instances/{instance_id}/backups"))) |
| 142 | } | 152 | } |
| 143 | 153 | ||
| @@ -174,11 +184,17 @@ async fn download_backup( | |||
| 174 | .map_err(|_| (StatusCode::NOT_FOUND, "Backup not found"))?; | 184 | .map_err(|_| (StatusCode::NOT_FOUND, "Backup not found"))?; |
| 175 | drop(conn); | 185 | drop(conn); |
| 176 | 186 | ||
| 187 | // Sanitize backup filename to prevent path traversal | ||
| 188 | let safe_filename = match sanitize::sanitize_backup_filename(&filename) { | ||
| 189 | Some(f) => f, | ||
| 190 | None => return Err((StatusCode::BAD_REQUEST, "Invalid backup filename")), | ||
| 191 | }; | ||
| 192 | |||
| 177 | let backup_path = state | 193 | let backup_path = state |
| 178 | .config | 194 | .config |
| 179 | .server_directory | 195 | .server_directory |
| 180 | .join("backups") | 196 | .join("backups") |
| 181 | .join(&filename); | 197 | .join(&safe_filename); |
| 182 | 198 | ||
| 183 | let content = | 199 | let content = |
| 184 | std::fs::read(&backup_path).map_err(|_| (StatusCode::NOT_FOUND, "File not found"))?; | 200 | std::fs::read(&backup_path).map_err(|_| (StatusCode::NOT_FOUND, "File not found"))?; |
| @@ -187,7 +203,7 @@ async fn download_backup( | |||
| 187 | .header(header::CONTENT_TYPE, "application/zip") | 203 | .header(header::CONTENT_TYPE, "application/zip") |
| 188 | .header( | 204 | .header( |
| 189 | header::CONTENT_DISPOSITION, | 205 | header::CONTENT_DISPOSITION, |
| 190 | format!("attachment; filename=\"{filename}\""), | 206 | format!("attachment; filename=\"{safe_filename}\""), |
| 191 | ) | 207 | ) |
| 192 | .body(Body::from(content)) | 208 | .body(Body::from(content)) |
| 193 | .unwrap()) | 209 | .unwrap()) |
diff --git a/src/routes/instances.rs b/src/routes/instances.rs index f1c3e37..d3b8294 100644 --- a/src/routes/instances.rs +++ b/src/routes/instances.rs | |||
| @@ -11,6 +11,7 @@ use serde::Deserialize; | |||
| 11 | use crate::auth::CurrentUser; | 11 | use crate::auth::CurrentUser; |
| 12 | use crate::models::Instance; | 12 | use crate::models::Instance; |
| 13 | use crate::routes::AppState; | 13 | use crate::routes::AppState; |
| 14 | use crate::sanitize; | ||
| 14 | 15 | ||
| 15 | #[derive(Template)] | 16 | #[derive(Template)] |
| 16 | #[template(path = "instances/list.html")] | 17 | #[template(path = "instances/list.html")] |
| @@ -198,11 +199,32 @@ async fn create_instance( | |||
| 198 | .ok(); | 199 | .ok(); |
| 199 | drop(conn); | 200 | drop(conn); |
| 200 | 201 | ||
| 202 | // Validate and sanitize instance name to prevent path traversal | ||
| 203 | let safe_name = match sanitize::sanitize_instance_name(&form.name) { | ||
| 204 | Some(n) => n, | ||
| 205 | None => { | ||
| 206 | // Invalid name, redirect back | ||
| 207 | return Ok(Redirect::to("/instances/new")); | ||
| 208 | } | ||
| 209 | }; | ||
| 210 | |||
| 211 | // Double-check: the DB insert used the original form.name, but we want the safe version. | ||
| 212 | // Update the DB record to use the sanitised name. | ||
| 213 | { | ||
| 214 | let conn = state.db.conn(); | ||
| 215 | if let Err(e) = conn.execute( | ||
| 216 | "UPDATE instances SET name = ?1 WHERE name = ?2", | ||
| 217 | params![safe_name, form.name], | ||
| 218 | ) { | ||
| 219 | tracing::error!("Failed to update instance name to sanitized version: {e}"); | ||
| 220 | } | ||
| 221 | } | ||
| 222 | |||
| 201 | let instance_dir = state | 223 | let instance_dir = state |
| 202 | .config | 224 | .config |
| 203 | .server_directory | 225 | .server_directory |
| 204 | .join("instances") | 226 | .join("instances") |
| 205 | .join(&form.name) | 227 | .join(&safe_name) |
| 206 | .join("data"); | 228 | .join("data"); |
| 207 | let _ = std::fs::create_dir_all(&instance_dir); | 229 | let _ = std::fs::create_dir_all(&instance_dir); |
| 208 | 230 | ||
| @@ -389,8 +411,13 @@ async fn delete_instance( | |||
| 389 | drop(conn); | 411 | drop(conn); |
| 390 | 412 | ||
| 391 | if let Some(name) = name { | 413 | if let Some(name) = name { |
| 392 | let instance_dir = state.config.server_directory.join("instances").join(&name); | 414 | // Sanitize name to prevent path traversal during cleanup |
| 393 | let _ = std::fs::remove_dir_all(&instance_dir); | 415 | if let Some(safe_name) = sanitize::sanitize_instance_name(&name) { |
| 416 | let instance_dir = state.config.server_directory.join("instances").join(&safe_name); | ||
| 417 | let _ = std::fs::remove_dir_all(&instance_dir); | ||
| 418 | } else { | ||
| 419 | tracing::warn!("Refusing to delete directory for invalid instance name: {name:?}"); | ||
| 420 | } | ||
| 394 | } | 421 | } |
| 395 | 422 | ||
| 396 | Ok(Redirect::to("/instances")) | 423 | Ok(Redirect::to("/instances")) |
| @@ -450,15 +477,18 @@ async fn activate_instance( | |||
| 450 | }; | 477 | }; |
| 451 | 478 | ||
| 452 | // Pull image + start container in background so the UI stays responsive | 479 | // Pull image + start container in background so the UI stays responsive |
| 480 | // FIX: Use concrete instance ID instead of `WHERE is_active = 1` to avoid | ||
| 481 | // stale updates trampling a different instance if activation changes. | ||
| 453 | let docker = state.docker.clone(); | 482 | let docker = state.docker.clone(); |
| 454 | let db = state.db.clone(); | 483 | let db = state.db.clone(); |
| 455 | let rcon_pw = instance.rcon_password.clone(); | 484 | let rcon_pw = instance.rcon_password.clone(); |
| 485 | let instance_id = instance.id; | ||
| 456 | tokio::spawn(async move { | 486 | tokio::spawn(async move { |
| 457 | { | 487 | { |
| 458 | let conn = db.conn(); | 488 | let conn = db.conn(); |
| 459 | conn.execute( | 489 | conn.execute( |
| 460 | "UPDATE instances SET container_state = 'pulling' WHERE is_active = 1", | 490 | "UPDATE instances SET container_state = 'pulling' WHERE id = ?1", |
| 461 | [], | 491 | params![instance_id], |
| 462 | ) | 492 | ) |
| 463 | .ok(); | 493 | .ok(); |
| 464 | } | 494 | } |
| @@ -467,8 +497,8 @@ async fn activate_instance( | |||
| 467 | { | 497 | { |
| 468 | let conn = db.conn(); | 498 | let conn = db.conn(); |
| 469 | conn.execute( | 499 | conn.execute( |
| 470 | "UPDATE instances SET container_state = 'booting' WHERE is_active = 1", | 500 | "UPDATE instances SET container_state = 'booting' WHERE id = ?1", |
| 471 | [], | 501 | params![instance_id], |
| 472 | ) | 502 | ) |
| 473 | .ok(); | 503 | .ok(); |
| 474 | } | 504 | } |
| @@ -479,8 +509,8 @@ async fn activate_instance( | |||
| 479 | if rcon.send_command_async("list".into()).await.is_ok() { | 509 | if rcon.send_command_async("list".into()).await.is_ok() { |
| 480 | let conn = db.conn(); | 510 | let conn = db.conn(); |
| 481 | conn.execute( | 511 | conn.execute( |
| 482 | "UPDATE instances SET container_state = 'running' WHERE is_active = 1", | 512 | "UPDATE instances SET container_state = 'running' WHERE id = ?1", |
| 483 | [], | 513 | params![instance_id], |
| 484 | ) | 514 | ) |
| 485 | .ok(); | 515 | .ok(); |
| 486 | tracing::info!("Server is ready (RCON responded)"); | 516 | tracing::info!("Server is ready (RCON responded)"); |
| @@ -490,8 +520,8 @@ async fn activate_instance( | |||
| 490 | tracing::error!("Server did not become ready within 60 seconds"); | 520 | tracing::error!("Server did not become ready within 60 seconds"); |
| 491 | let conn = db.conn(); | 521 | let conn = db.conn(); |
| 492 | conn.execute( | 522 | conn.execute( |
| 493 | "UPDATE instances SET container_state = 'error' WHERE is_active = 1", | 523 | "UPDATE instances SET container_state = 'error' WHERE id = ?1", |
| 494 | [], | 524 | params![instance_id], |
| 495 | ) | 525 | ) |
| 496 | .ok(); | 526 | .ok(); |
| 497 | } | 527 | } |
| @@ -499,8 +529,8 @@ async fn activate_instance( | |||
| 499 | tracing::error!("Failed to start container: {e}"); | 529 | tracing::error!("Failed to start container: {e}"); |
| 500 | let conn = db.conn(); | 530 | let conn = db.conn(); |
| 501 | conn.execute( | 531 | conn.execute( |
| 502 | "UPDATE instances SET container_state = 'error' WHERE is_active = 1", | 532 | "UPDATE instances SET container_state = 'error' WHERE id = ?1", |
| 503 | [], | 533 | params![instance_id], |
| 504 | ) | 534 | ) |
| 505 | .ok(); | 535 | .ok(); |
| 506 | } | 536 | } |
| @@ -586,11 +616,17 @@ async fn export_world( | |||
| 586 | .map_err(|_| Redirect::to("/instances"))?; | 616 | .map_err(|_| Redirect::to("/instances"))?; |
| 587 | drop(conn); | 617 | drop(conn); |
| 588 | 618 | ||
| 619 | // Sanitize instance name to prevent path traversal | ||
| 620 | let safe_name = match sanitize::sanitize_instance_name(&instance_name) { | ||
| 621 | Some(n) => n, | ||
| 622 | None => return Err(Redirect::to("/instances")), | ||
| 623 | }; | ||
| 624 | |||
| 589 | let world_dir = state | 625 | let world_dir = state |
| 590 | .config | 626 | .config |
| 591 | .server_directory | 627 | .server_directory |
| 592 | .join("instances") | 628 | .join("instances") |
| 593 | .join(&instance_name) | 629 | .join(&safe_name) |
| 594 | .join("data") | 630 | .join("data") |
| 595 | .join("world"); | 631 | .join("world"); |
| 596 | 632 | ||
| @@ -661,6 +697,12 @@ async fn import_world( | |||
| 661 | } | 697 | } |
| 662 | }; | 698 | }; |
| 663 | 699 | ||
| 700 | // Sanitize instance name to prevent path traversal | ||
| 701 | let safe_name = match sanitize::sanitize_instance_name(&instance_name) { | ||
| 702 | Some(n) => n, | ||
| 703 | None => return Ok(Redirect::to(&format!("/instances/{id}/edit"))), | ||
| 704 | }; | ||
| 705 | |||
| 664 | if is_active { | 706 | if is_active { |
| 665 | return Ok(Redirect::to(&format!("/instances/{id}/edit"))); | 707 | return Ok(Redirect::to(&format!("/instances/{id}/edit"))); |
| 666 | } | 708 | } |
| @@ -669,7 +711,7 @@ async fn import_world( | |||
| 669 | .config | 711 | .config |
| 670 | .server_directory | 712 | .server_directory |
| 671 | .join("instances") | 713 | .join("instances") |
| 672 | .join(&instance_name) | 714 | .join(&safe_name) |
| 673 | .join("data"); | 715 | .join("data"); |
| 674 | 716 | ||
| 675 | // Extract uploaded ZIP | 717 | // Extract uploaded ZIP |
| @@ -691,10 +733,16 @@ async fn import_world( | |||
| 691 | let mut file = archive | 733 | let mut file = archive |
| 692 | .by_index(i) | 734 | .by_index(i) |
| 693 | .map_err(|_| Redirect::to(&format!("/instances/{id}/edit")))?; | 735 | .map_err(|_| Redirect::to(&format!("/instances/{id}/edit")))?; |
| 736 | // Use enclosed_name() for zip-slip protection, then verify within world_dir | ||
| 694 | let out_path = match file.enclosed_name() { | 737 | let out_path = match file.enclosed_name() { |
| 695 | Some(p) => world_dir.join(p), | 738 | Some(p) => world_dir.join(p), |
| 696 | None => continue, | 739 | None => continue, |
| 697 | }; | 740 | }; |
| 741 | // Additional safety: verify the resolved path is within world_dir | ||
| 742 | if !out_path.starts_with(&world_dir) { | ||
| 743 | tracing::warn!("Zip-slip attempt blocked: {out_path:?}"); | ||
| 744 | continue; | ||
| 745 | } | ||
| 698 | if file.is_dir() { | 746 | if file.is_dir() { |
| 699 | let _ = std::fs::create_dir_all(&out_path); | 747 | let _ = std::fs::create_dir_all(&out_path); |
| 700 | } else { | 748 | } else { |
| @@ -728,38 +776,47 @@ async fn regenerate_world( | |||
| 728 | .map_err(|_| Redirect::to("/instances"))?; | 776 | .map_err(|_| Redirect::to("/instances"))?; |
| 729 | drop(conn); | 777 | drop(conn); |
| 730 | 778 | ||
| 779 | // Sanitize instance name to prevent path traversal | ||
| 780 | let safe_name = match sanitize::sanitize_instance_name(&instance_name) { | ||
| 781 | Some(n) => n, | ||
| 782 | None => return Ok(Redirect::to(&format!("/instances/{id}/edit"))), | ||
| 783 | }; | ||
| 784 | |||
| 731 | if is_active { | 785 | if is_active { |
| 732 | return Ok(Redirect::to(&format!("/instances/{id}/edit"))); | 786 | return Ok(Redirect::to(&format!("/instances/{id}/edit"))); |
| 733 | } | 787 | } |
| 734 | 788 | ||
| 789 | // Sanitize seed to prevent Java properties injection | ||
| 790 | let seed = form.seed.unwrap_or_default(); | ||
| 791 | let safe_seed = sanitize::sanitize_seed(&seed); | ||
| 792 | |||
| 735 | let props_path = state | 793 | let props_path = state |
| 736 | .config | 794 | .config |
| 737 | .server_directory | 795 | .server_directory |
| 738 | .join("instances") | 796 | .join("instances") |
| 739 | .join(&instance_name) | 797 | .join(&safe_name) |
| 740 | .join("data") | 798 | .join("data") |
| 741 | .join("server.properties"); | 799 | .join("server.properties"); |
| 742 | 800 | ||
| 743 | // Update level-seed in server.properties | 801 | // Update level-seed in server.properties with sanitized value |
| 744 | let seed = form.seed.unwrap_or_default(); | ||
| 745 | let contents = std::fs::read_to_string(&props_path).unwrap_or_default(); | 802 | let contents = std::fs::read_to_string(&props_path).unwrap_or_default(); |
| 746 | let mut new_lines = Vec::new(); | 803 | let mut new_lines = Vec::new(); |
| 747 | let mut found = false; | 804 | let mut found = false; |
| 748 | for line in contents.lines() { | 805 | for line in contents.lines() { |
| 749 | if line.trim_start().starts_with("level-seed") { | 806 | if line.trim_start().starts_with("level-seed") { |
| 750 | new_lines.push(format!("level-seed={seed}")); | 807 | new_lines.push(format!("level-seed={safe_seed}")); |
| 751 | found = true; | 808 | found = true; |
| 752 | } else { | 809 | } else { |
| 753 | new_lines.push(line.to_string()); | 810 | new_lines.push(line.to_string()); |
| 754 | } | 811 | } |
| 755 | } | 812 | } |
| 756 | if !found { | 813 | if !found { |
| 757 | new_lines.push(format!("level-seed={seed}")); | 814 | new_lines.push(format!("level-seed={safe_seed}")); |
| 758 | } | 815 | } |
| 759 | let _ = std::fs::write(&props_path, new_lines.join("\n")); | 816 | let _ = std::fs::write(&props_path, new_lines.join("\n")); |
| 760 | 817 | ||
| 761 | // Update DB record | 818 | // Update DB record with sanitized seed |
| 762 | let db_seed: Option<&str> = if seed.is_empty() { None } else { Some(&seed) }; | 819 | let db_seed: Option<&str> = if safe_seed.is_empty() { None } else { Some(&safe_seed) }; |
| 763 | let conn = state.db.conn(); | 820 | let conn = state.db.conn(); |
| 764 | conn.execute( | 821 | conn.execute( |
| 765 | "UPDATE instances SET world_seed = ?1, updated_at = datetime('now') WHERE id = ?2", | 822 | "UPDATE instances SET world_seed = ?1, updated_at = datetime('now') WHERE id = ?2", |
| @@ -773,7 +830,7 @@ async fn regenerate_world( | |||
| 773 | .config | 830 | .config |
| 774 | .server_directory | 831 | .server_directory |
| 775 | .join("instances") | 832 | .join("instances") |
| 776 | .join(&instance_name) | 833 | .join(&safe_name) |
| 777 | .join("data") | 834 | .join("data") |
| 778 | .join("world"); | 835 | .join("world"); |
| 779 | let _ = std::fs::remove_dir_all(&world_dir); | 836 | let _ = std::fs::remove_dir_all(&world_dir); |
diff --git a/src/routes/mod.rs b/src/routes/mod.rs index a4d200c..2029bf8 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs | |||
| @@ -31,6 +31,7 @@ pub fn build_router(state: AppState) -> Router { | |||
| 31 | "/change-password", | 31 | "/change-password", |
| 32 | axum::routing::post(accounts::change_password), | 32 | axum::routing::post(accounts::change_password), |
| 33 | ) | 33 | ) |
| 34 | .route("/logout", axum::routing::post(crate::auth::logout)) | ||
| 34 | .merge(dashboard::router()) | 35 | .merge(dashboard::router()) |
| 35 | .merge(instances::router()) | 36 | .merge(instances::router()) |
| 36 | .merge(players::router()) | 37 | .merge(players::router()) |
| @@ -42,7 +43,6 @@ pub fn build_router(state: AppState) -> Router { | |||
| 42 | Router::new() | 43 | Router::new() |
| 43 | .route("/login", axum::routing::get(login_page)) | 44 | .route("/login", axum::routing::get(login_page)) |
| 44 | .route("/login", axum::routing::post(crate::auth::authenticate)) | 45 | .route("/login", axum::routing::post(crate::auth::authenticate)) |
| 45 | .route("/logout", axum::routing::post(crate::auth::logout)) | ||
| 46 | .merge(protected) | 46 | .merge(protected) |
| 47 | .nest_service("/static", ServeDir::new("static")) | 47 | .nest_service("/static", ServeDir::new("static")) |
| 48 | .with_state(state) | 48 | .with_state(state) |
diff --git a/src/routes/mods.rs b/src/routes/mods.rs index 4716fec..d8f0845 100644 --- a/src/routes/mods.rs +++ b/src/routes/mods.rs | |||
| @@ -13,6 +13,7 @@ use std::io::{Cursor, Read}; | |||
| 13 | use crate::auth::CurrentUser; | 13 | use crate::auth::CurrentUser; |
| 14 | use crate::mods; | 14 | use crate::mods; |
| 15 | use crate::routes::AppState; | 15 | use crate::routes::AppState; |
| 16 | use crate::sanitize; | ||
| 16 | 17 | ||
| 17 | #[derive(Template)] | 18 | #[derive(Template)] |
| 18 | #[template(path = "mods/list.html")] | 19 | #[template(path = "mods/list.html")] |
| @@ -119,11 +120,17 @@ async fn upload_mod( | |||
| 119 | None => return Err(Redirect::to("/instances")), | 120 | None => return Err(Redirect::to("/instances")), |
| 120 | }; | 121 | }; |
| 121 | 122 | ||
| 123 | // Sanitize instance name to prevent path traversal | ||
| 124 | let safe_name = match sanitize::sanitize_instance_name(&instance_name) { | ||
| 125 | Some(n) => n, | ||
| 126 | None => return Err(Redirect::to("/instances")), | ||
| 127 | }; | ||
| 128 | |||
| 122 | let mods_dir = state | 129 | let mods_dir = state |
| 123 | .config | 130 | .config |
| 124 | .server_directory | 131 | .server_directory |
| 125 | .join("instances") | 132 | .join("instances") |
| 126 | .join(&instance_name) | 133 | .join(&safe_name) |
| 127 | .join("data") | 134 | .join("data") |
| 128 | .join("mods"); | 135 | .join("mods"); |
| 129 | let _ = std::fs::create_dir_all(&mods_dir); | 136 | let _ = std::fs::create_dir_all(&mods_dir); |
| @@ -243,15 +250,8 @@ fn process_jar_upload( | |||
| 243 | result.side | 250 | result.side |
| 244 | ); | 251 | ); |
| 245 | 252 | ||
| 246 | if result.accepted { | 253 | // FIX: Insert DB record BEFORE writing file to disk. |
| 247 | let dest = mods_dir.join(&safe_name); | 254 | // This prevents orphaned mod files if the process crashes between write and DB insert. |
| 248 | if std::fs::write(&dest, bytes).is_ok() { | ||
| 249 | tracing::info!("Written to disk: {}", dest.display()); | ||
| 250 | } else { | ||
| 251 | tracing::error!("Failed to write {}", dest.display()); | ||
| 252 | } | ||
| 253 | } | ||
| 254 | |||
| 255 | let conn = state.db.conn(); | 255 | let conn = state.db.conn(); |
| 256 | if let Err(e) = conn.execute( | 256 | if let Err(e) = conn.execute( |
| 257 | "INSERT INTO mods (instance_id, filename, display_name, side, accepted, rejection_reason) | 257 | "INSERT INTO mods (instance_id, filename, display_name, side, accepted, rejection_reason) |
| @@ -266,10 +266,20 @@ fn process_jar_upload( | |||
| 266 | ], | 266 | ], |
| 267 | ) { | 267 | ) { |
| 268 | tracing::error!("DB insert failed: {e}"); | 268 | tracing::error!("DB insert failed: {e}"); |
| 269 | drop(conn); | ||
| 270 | return; | ||
| 269 | } | 271 | } |
| 270 | drop(conn); | 272 | drop(conn); |
| 271 | 273 | ||
| 274 | // Now write to disk (DB insert succeeded, so on restart we can detect incomplete writes) | ||
| 272 | if result.accepted { | 275 | if result.accepted { |
| 276 | let dest = mods_dir.join(&safe_name); | ||
| 277 | if std::fs::write(&dest, bytes).is_ok() { | ||
| 278 | tracing::info!("Written to disk: {}", dest.display()); | ||
| 279 | } else { | ||
| 280 | tracing::error!("Failed to write {}", dest.display()); | ||
| 281 | } | ||
| 282 | |||
| 273 | let conn = state.db.conn(); | 283 | let conn = state.db.conn(); |
| 274 | let _ = conn.execute( | 284 | let _ = conn.execute( |
| 275 | "UPDATE instances SET has_modpack = 1 WHERE id = ?1", | 285 | "UPDATE instances SET has_modpack = 1 WHERE id = ?1", |
| @@ -336,11 +346,17 @@ async fn rescan_mods( | |||
| 336 | None => return Err(Redirect::to("/instances")), | 346 | None => return Err(Redirect::to("/instances")), |
| 337 | }; | 347 | }; |
| 338 | 348 | ||
| 349 | // Sanitize instance name to prevent path traversal | ||
| 350 | let safe_name = match sanitize::sanitize_instance_name(&instance_name) { | ||
| 351 | Some(n) => n, | ||
| 352 | None => return Err(Redirect::to("/instances")), | ||
| 353 | }; | ||
| 354 | |||
| 339 | let mods_dir = state | 355 | let mods_dir = state |
| 340 | .config | 356 | .config |
| 341 | .server_directory | 357 | .server_directory |
| 342 | .join("instances") | 358 | .join("instances") |
| 343 | .join(&instance_name) | 359 | .join(&safe_name) |
| 344 | .join("data") | 360 | .join("data") |
| 345 | .join("mods"); | 361 | .join("mods"); |
| 346 | 362 | ||
| @@ -386,15 +402,27 @@ async fn delete_mod( | |||
| 386 | ) | 402 | ) |
| 387 | .map_err(|_| Redirect::to(&format!("/instances/{instance_id}/mods")))?; | 403 | .map_err(|_| Redirect::to(&format!("/instances/{instance_id}/mods")))?; |
| 388 | 404 | ||
| 405 | // Sanitize instance name to prevent path traversal | ||
| 406 | let safe_name = match sanitize::sanitize_instance_name(&instance_name) { | ||
| 407 | Some(n) => n, | ||
| 408 | None => return Err(Redirect::to(&format!("/instances/{instance_id}/mods"))), | ||
| 409 | }; | ||
| 410 | |||
| 411 | // Sanitize filename to prevent path traversal | ||
| 412 | let safe_filename = match sanitize::sanitize_backup_filename(&filename) { | ||
| 413 | Some(f) => f, | ||
| 414 | None => return Err(Redirect::to(&format!("/instances/{instance_id}/mods"))), | ||
| 415 | }; | ||
| 416 | |||
| 389 | // Delete file from disk | 417 | // Delete file from disk |
| 390 | let mod_path = state | 418 | let mod_path = state |
| 391 | .config | 419 | .config |
| 392 | .server_directory | 420 | .server_directory |
| 393 | .join("instances") | 421 | .join("instances") |
| 394 | .join(&instance_name) | 422 | .join(&safe_name) |
| 395 | .join("data") | 423 | .join("data") |
| 396 | .join("mods") | 424 | .join("mods") |
| 397 | .join(&filename); | 425 | .join(&safe_filename); |
| 398 | let _ = std::fs::remove_file(&mod_path); | 426 | let _ = std::fs::remove_file(&mod_path); |
| 399 | 427 | ||
| 400 | // Remove from DB | 428 | // Remove from DB |
diff --git a/src/routes/players.rs b/src/routes/players.rs index 7cfb722..f90113a 100644 --- a/src/routes/players.rs +++ b/src/routes/players.rs | |||
| @@ -9,6 +9,7 @@ use serde::Deserialize; | |||
| 9 | 9 | ||
| 10 | use crate::auth::CurrentUser; | 10 | use crate::auth::CurrentUser; |
| 11 | use crate::routes::AppState; | 11 | use crate::routes::AppState; |
| 12 | use crate::sanitize; | ||
| 12 | 13 | ||
| 13 | #[derive(Template)] | 14 | #[derive(Template)] |
| 14 | #[template(path = "players/manage.html")] | 15 | #[template(path = "players/manage.html")] |
| @@ -46,11 +47,14 @@ async fn manage_players( | |||
| 46 | 47 | ||
| 47 | let (instance_name, whitelist, ops, bans) = | 48 | let (instance_name, whitelist, ops, bans) = |
| 48 | if let Some((_id, inst_name, _rcon_pw, _container_state)) = active { | 49 | if let Some((_id, inst_name, _rcon_pw, _container_state)) = active { |
| 50 | // Sanitize instance name to prevent path traversal in file reads | ||
| 51 | let safe_name = sanitize::sanitize_instance_name(&inst_name) | ||
| 52 | .unwrap_or_else(|| inst_name.clone()); | ||
| 49 | let data_dir = state | 53 | let data_dir = state |
| 50 | .config | 54 | .config |
| 51 | .server_directory | 55 | .server_directory |
| 52 | .join("instances") | 56 | .join("instances") |
| 53 | .join(&inst_name) | 57 | .join(&safe_name) |
| 54 | .join("data"); | 58 | .join("data"); |
| 55 | 59 | ||
| 56 | let w = read_json_list(&data_dir.join("whitelist.json")); | 60 | let w = read_json_list(&data_dir.join("whitelist.json")); |
| @@ -92,9 +96,20 @@ macro_rules! player_action { | |||
| 92 | CurrentUser(_user): CurrentUser, | 96 | CurrentUser(_user): CurrentUser, |
| 93 | Form(form): Form<PlayerAction>, | 97 | Form(form): Form<PlayerAction>, |
| 94 | ) -> Result<Redirect, axum::response::Redirect> { | 98 | ) -> Result<Redirect, axum::response::Redirect> { |
| 99 | // Validate Minecraft username to prevent RCON command injection | ||
| 100 | // Minecraft usernames are 3-16 alphanumeric + underscore chars | ||
| 101 | let safe_username = form.username.trim(); | ||
| 102 | if !sanitize::validate_username(safe_username) { | ||
| 103 | tracing::warn!( | ||
| 104 | "Rejected invalid username for '{}': {safe_username:?}", | ||
| 105 | stringify!($name) | ||
| 106 | ); | ||
| 107 | return Ok(Redirect::to("/players")); | ||
| 108 | } | ||
| 109 | |||
| 95 | if let Some((_, _, rcon_pw, _)) = get_active_instance(&state.db) { | 110 | if let Some((_, _, rcon_pw, _)) = get_active_instance(&state.db) { |
| 96 | let client = crate::rcon::RconClient::new(rcon_pw); | 111 | let client = crate::rcon::RconClient::new(rcon_pw); |
| 97 | let cmd = format!($command, form.username); | 112 | let cmd = format!($command, safe_username); |
| 98 | match client.send_command_async(cmd.clone()).await { | 113 | match client.send_command_async(cmd.clone()).await { |
| 99 | Ok(resp) => tracing::info!("RCON '{}' -> {}", cmd, resp.trim()), | 114 | Ok(resp) => tracing::info!("RCON '{}' -> {}", cmd, resp.trim()), |
| 100 | Err(e) => tracing::error!("RCON '{}' failed: {}", cmd, e), | 115 | Err(e) => tracing::error!("RCON '{}' failed: {}", cmd, e), |
diff --git a/src/sanitize.rs b/src/sanitize.rs new file mode 100644 index 0000000..f251131 --- /dev/null +++ b/src/sanitize.rs | |||
| @@ -0,0 +1,221 @@ | |||
| 1 | //! Input sanitization and validation utilities. | ||
| 2 | //! | ||
| 3 | //! Centralises all user-input validation to avoid path traversal, | ||
| 4 | //! command injection, and property injection vulnerabilities. | ||
| 5 | |||
| 6 | use std::path::{Path, PathBuf}; | ||
| 7 | |||
| 8 | /// The maximum length (in bytes) for an instance name. | ||
| 9 | const INSTANCE_NAME_MAX_BYTES: usize = 64; | ||
| 10 | |||
| 11 | /// The maximum length (in bytes) for a seed value. | ||
| 12 | const SEED_MAX_BYTES: usize = 64; | ||
| 13 | |||
| 14 | /// The maximum length (in bytes) for a Minecraft username. | ||
| 15 | const USERNAME_MAX_BYTES: usize = 16; | ||
| 16 | |||
| 17 | /// Allowed characters for an instance name. | ||
| 18 | /// We restrict to a safe subset: alphanumeric, hyphens, underscores, dots. | ||
| 19 | /// This prevents path traversal via `..` or `/` and avoids filesystem issues. | ||
| 20 | fn instance_name_char_allowed(c: char) -> bool { | ||
| 21 | c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' | ||
| 22 | } | ||
| 23 | |||
| 24 | /// Validate and sanitise an instance name. | ||
| 25 | /// | ||
| 26 | /// Returns `None` if the name is invalid; otherwise returns the sanitised name | ||
| 27 | /// (trimmed, collapsed whitespace removed). | ||
| 28 | pub fn sanitize_instance_name(raw: &str) -> Option<String> { | ||
| 29 | let trimmed = raw.trim(); | ||
| 30 | if trimmed.is_empty() { | ||
| 31 | return None; | ||
| 32 | } | ||
| 33 | // Reject any name containing `..` — path traversal attempt | ||
| 34 | if trimmed.contains("..") { | ||
| 35 | return None; | ||
| 36 | } | ||
| 37 | // Reject names that would be filesystem metacharacters | ||
| 38 | if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains('\0') { | ||
| 39 | return None; | ||
| 40 | } | ||
| 41 | // Length limit | ||
| 42 | if trimmed.len() > INSTANCE_NAME_MAX_BYTES { | ||
| 43 | return None; | ||
| 44 | } | ||
| 45 | // Only allow safe characters | ||
| 46 | if !trimmed.chars().all(instance_name_char_allowed) { | ||
| 47 | return None; | ||
| 48 | } | ||
| 49 | // Must not start with a dot (hidden file issues) | ||
| 50 | if trimmed.starts_with('.') { | ||
| 51 | return None; | ||
| 52 | } | ||
| 53 | // Must not be a reserved name | ||
| 54 | match trimmed.to_ascii_lowercase().as_str() { | ||
| 55 | "" | "." | ".." | "con" | "prn" | "aux" | "nul" | "com1" | "com2" | "com3" | "com4" | ||
| 56 | | "lpt1" | "lpt2" | "lpt3" => return None, | ||
| 57 | _ => {} | ||
| 58 | } | ||
| 59 | Some(trimmed.to_string()) | ||
| 60 | } | ||
| 61 | |||
| 62 | /// Sanitise a world-seed value to prevent Java Properties injection. | ||
| 63 | /// | ||
| 64 | /// Strips newlines, carriage returns, `=`, and `:` characters that could | ||
| 65 | /// inject additional properties or break the properties file format. | ||
| 66 | pub fn sanitize_seed(raw: &str) -> String { | ||
| 67 | let s: String = raw | ||
| 68 | .chars() | ||
| 69 | .filter(|c| !matches!(c, '\n' | '\r' | '=' | ':' | '\0')) | ||
| 70 | .take(SEED_MAX_BYTES) | ||
| 71 | .collect(); | ||
| 72 | s.trim().to_string() | ||
| 73 | } | ||
| 74 | |||
| 75 | /// Validate a Minecraft username for RCON commands. | ||
| 76 | /// | ||
| 77 | /// Minecraft usernames are 3–16 characters, alphanumeric and underscores. | ||
| 78 | /// Returns `true` if the username is valid. | ||
| 79 | pub fn validate_username(raw: &str) -> bool { | ||
| 80 | let trimmed = raw.trim(); | ||
| 81 | if trimmed.len() < 3 || trimmed.len() > USERNAME_MAX_BYTES { | ||
| 82 | return false; | ||
| 83 | } | ||
| 84 | trimmed | ||
| 85 | .chars() | ||
| 86 | .all(|c| c.is_ascii_alphanumeric() || c == '_') | ||
| 87 | } | ||
| 88 | |||
| 89 | /// Verify that a resolved (canonical) path is within the given base directory. | ||
| 90 | /// | ||
| 91 | /// Both paths should be canonicalised before calling. Returns `true` if | ||
| 92 | /// `path` is a descendant of `base`. | ||
| 93 | #[allow(dead_code)] | ||
| 94 | pub fn path_is_within_base(path: &Path, base: &Path) -> bool { | ||
| 95 | path.canonicalize() | ||
| 96 | .ok() | ||
| 97 | .and_then(|p| base.canonicalize().ok().map(|b| p.starts_with(&b))) | ||
| 98 | .unwrap_or(false) | ||
| 99 | } | ||
| 100 | |||
| 101 | /// Build a safe instance data directory path. | ||
| 102 | /// | ||
| 103 | /// The instance name is validated first. Returns `None` if the name is invalid. | ||
| 104 | #[allow(dead_code)] | ||
| 105 | pub fn instance_data_dir(server_dir: &Path, instance_name: &str) -> Option<PathBuf> { | ||
| 106 | let safe = sanitize_instance_name(instance_name)?; | ||
| 107 | let dir = server_dir.join("instances").join(&safe).join("data"); | ||
| 108 | Some(dir) | ||
| 109 | } | ||
| 110 | |||
| 111 | /// Build a safe instance mods directory path. | ||
| 112 | #[allow(dead_code)] | ||
| 113 | pub fn instance_mods_dir(server_dir: &Path, instance_name: &str) -> Option<PathBuf> { | ||
| 114 | let data = instance_data_dir(server_dir, instance_name)?; | ||
| 115 | Some(data.join("mods")) | ||
| 116 | } | ||
| 117 | |||
| 118 | /// Build a safe instance world directory path. | ||
| 119 | #[allow(dead_code)] | ||
| 120 | pub fn instance_world_dir(server_dir: &Path, instance_name: &str) -> Option<PathBuf> { | ||
| 121 | let data = instance_data_dir(server_dir, instance_name)?; | ||
| 122 | Some(data.join("world")) | ||
| 123 | } | ||
| 124 | |||
| 125 | /// Build a safe server.properties path for an instance. | ||
| 126 | #[allow(dead_code)] | ||
| 127 | pub fn instance_properties_path(server_dir: &Path, instance_name: &str) -> Option<PathBuf> { | ||
| 128 | let data = instance_data_dir(server_dir, instance_name)?; | ||
| 129 | Some(data.join("server.properties")) | ||
| 130 | } | ||
| 131 | |||
| 132 | /// Build a safe instance directory (parent of data/) for an instance. | ||
| 133 | #[allow(dead_code)] | ||
| 134 | pub fn instance_dir(server_dir: &Path, instance_name: &str) -> Option<PathBuf> { | ||
| 135 | let safe = sanitize_instance_name(instance_name)?; | ||
| 136 | Some(server_dir.join("instances").join(&safe)) | ||
| 137 | } | ||
| 138 | |||
| 139 | /// Validate that a backup filename does not contain path traversal. | ||
| 140 | pub fn sanitize_backup_filename(raw: &str) -> Option<String> { | ||
| 141 | let trimmed = raw.trim(); | ||
| 142 | if trimmed.is_empty() { | ||
| 143 | return None; | ||
| 144 | } | ||
| 145 | if trimmed.contains("..") | ||
| 146 | || trimmed.contains('/') | ||
| 147 | || trimmed.contains('\\') | ||
| 148 | || trimmed.contains('\0') | ||
| 149 | { | ||
| 150 | return None; | ||
| 151 | } | ||
| 152 | if trimmed.len() > 256 { | ||
| 153 | return None; | ||
| 154 | } | ||
| 155 | Some(trimmed.to_string()) | ||
| 156 | } | ||
| 157 | |||
| 158 | #[cfg(test)] | ||
| 159 | mod tests { | ||
| 160 | use super::*; | ||
| 161 | |||
| 162 | #[test] | ||
| 163 | fn test_sanitize_instance_name_ok() { | ||
| 164 | assert_eq!(sanitize_instance_name("my-world").unwrap(), "my-world"); | ||
| 165 | assert_eq!(sanitize_instance_name("test_123").unwrap(), "test_123"); | ||
| 166 | assert_eq!(sanitize_instance_name("V1.20.4").unwrap(), "V1.20.4"); | ||
| 167 | assert_eq!(sanitize_instance_name(" hello ").unwrap(), "hello"); | ||
| 168 | } | ||
| 169 | |||
| 170 | #[test] | ||
| 171 | fn test_sanitize_instance_name_rejected() { | ||
| 172 | assert!(sanitize_instance_name("").is_none()); | ||
| 173 | assert!(sanitize_instance_name(" ").is_none()); | ||
| 174 | assert!(sanitize_instance_name("a/b").is_none()); | ||
| 175 | assert!(sanitize_instance_name("a\\b").is_none()); | ||
| 176 | assert!(sanitize_instance_name("..").is_none()); | ||
| 177 | assert!(sanitize_instance_name("foo/../bar").is_none()); | ||
| 178 | assert!(sanitize_instance_name(".hidden").is_none()); | ||
| 179 | assert!(sanitize_instance_name("hello world").is_none()); // space not allowed | ||
| 180 | assert!(sanitize_instance_name("a@b").is_none()); | ||
| 181 | } | ||
| 182 | |||
| 183 | #[test] | ||
| 184 | fn test_sanitize_seed() { | ||
| 185 | assert_eq!(sanitize_seed("12345"), "12345"); | ||
| 186 | assert_eq!(sanitize_seed("hello\nworld"), "helloworld"); | ||
| 187 | assert_eq!(sanitize_seed("key=value"), "keyvalue"); | ||
| 188 | // The seed is capped at SEED_MAX_BYTES=64. The string below is 64 chars after sanitization. | ||
| 189 | let long_seed = "-123456789012345678901234567890123456789012345678901234567890123"; | ||
| 190 | assert_eq!(long_seed.len(), 64); | ||
| 191 | assert_eq!( | ||
| 192 | sanitize_seed( | ||
| 193 | "-1234567890123456789012345678901234567890123456789012345678901234567890" | ||
| 194 | ), | ||
| 195 | long_seed | ||
| 196 | ); | ||
| 197 | } | ||
| 198 | |||
| 199 | #[test] | ||
| 200 | fn test_validate_username() { | ||
| 201 | assert!(validate_username("Notch")); | ||
| 202 | assert!(validate_username("player_123")); | ||
| 203 | assert!(!validate_username("ab")); // too short | ||
| 204 | assert!(!validate_username("a@b.com")); // special chars | ||
| 205 | assert!(!validate_username("")); // empty | ||
| 206 | } | ||
| 207 | |||
| 208 | #[test] | ||
| 209 | fn test_path_is_within_base() { | ||
| 210 | use std::fs; | ||
| 211 | let dir = tempfile::tempdir().unwrap(); | ||
| 212 | let base = dir.path().join("server"); | ||
| 213 | fs::create_dir_all(&base).unwrap(); | ||
| 214 | let sub = base.join("instances").join("test").join("data"); | ||
| 215 | fs::create_dir_all(&sub).unwrap(); | ||
| 216 | assert!(path_is_within_base(&sub, &base)); | ||
| 217 | // Path outside base should fail | ||
| 218 | let outside = dir.path().join("other"); | ||
| 219 | assert!(!path_is_within_base(&outside, &base)); | ||
| 220 | } | ||
| 221 | } | ||
diff --git a/templates/instances/edit.html b/templates/instances/edit.html index 2660461..77dfca6 100644 --- a/templates/instances/edit.html +++ b/templates/instances/edit.html | |||
| @@ -35,8 +35,7 @@ | |||
| 35 | value="0" | 35 | value="0" |
| 36 | {% | 36 | {% |
| 37 | if | 37 | if |
| 38 | instance.difficulty="" | 38 | instance.difficulty == 0 |
| 39 | ="0" | ||
| 40 | %}selected{% | 39 | %}selected{% |
| 41 | endif | 40 | endif |
| 42 | %} | 41 | %} |
| @@ -47,8 +46,7 @@ | |||
| 47 | value="1" | 46 | value="1" |
| 48 | {% | 47 | {% |
| 49 | if | 48 | if |
| 50 | instance.difficulty="" | 49 | instance.difficulty == 1 |
| 51 | ="1" | ||
| 52 | %}selected{% | 50 | %}selected{% |
| 53 | endif | 51 | endif |
| 54 | %} | 52 | %} |
| @@ -59,8 +57,7 @@ | |||
| 59 | value="2" | 57 | value="2" |
| 60 | {% | 58 | {% |
| 61 | if | 59 | if |
| 62 | instance.difficulty="" | 60 | instance.difficulty == 2 |
| 63 | ="2" | ||
| 64 | %}selected{% | 61 | %}selected{% |
| 65 | endif | 62 | endif |
| 66 | %} | 63 | %} |
| @@ -71,8 +68,7 @@ | |||
| 71 | value="3" | 68 | value="3" |
| 72 | {% | 69 | {% |
| 73 | if | 70 | if |
| 74 | instance.difficulty="" | 71 | instance.difficulty == 3 |
| 75 | ="3" | ||
| 76 | %}selected{% | 72 | %}selected{% |
| 77 | endif | 73 | endif |
| 78 | %} | 74 | %} |
| @@ -84,16 +80,16 @@ | |||
| 84 | <label> | 80 | <label> |
| 85 | Gamemode | 81 | Gamemode |
| 86 | <select name="gamemode"> | 82 | <select name="gamemode"> |
| 87 | <option value="0" {% if instance.gamemode="" ="0" %}selected{% endif %}> | 83 | <option value="0" {% if instance.gamemode == 0 %}selected{% endif %}> |
| 88 | Survival | 84 | Survival |
| 89 | </option> | 85 | </option> |
| 90 | <option value="1" {% if instance.gamemode="" ="1" %}selected{% endif %}> | 86 | <option value="1" {% if instance.gamemode == 1 %}selected{% endif %}> |
| 91 | Creative | 87 | Creative |
| 92 | </option> | 88 | </option> |
| 93 | <option value="2" {% if instance.gamemode="" ="2" %}selected{% endif %}> | 89 | <option value="2" {% if instance.gamemode == 2 %}selected{% endif %}> |
| 94 | Adventure | 90 | Adventure |
| 95 | </option> | 91 | </option> |
| 96 | <option value="3" {% if instance.gamemode="" ="3" %}selected{% endif %}> | 92 | <option value="3" {% if instance.gamemode == 3 %}selected{% endif %}> |
| 97 | Spectator | 93 | Spectator |
| 98 | </option> | 94 | </option> |
| 99 | </select> | 95 | </select> |
| @@ -162,8 +158,8 @@ | |||
| 162 | {% endif %} | 158 | {% endif %} |
| 163 | 159 | ||
| 164 | <div class="meta" style="margin-bottom: 1rem"> | 160 | <div class="meta" style="margin-bottom: 1rem"> |
| 165 | <span | 161 | <span> |
| 166 | ><strong>Current seed:</strong> | 162 | <strong>Current seed:</strong> |
| 167 | {% if let Some(seed) = current_seed %} {{ seed }} {% else %} | 163 | {% if let Some(seed) = current_seed %} {{ seed }} {% else %} |
| 168 | <em>not yet generated</em> | 164 | <em>not yet generated</em> |
| 169 | {% endif %} | 165 | {% endif %} |
| @@ -181,8 +177,7 @@ | |||
| 181 | {% | 177 | {% |
| 182 | if | 178 | if |
| 183 | instance.is_active | 179 | instance.is_active |
| 184 | %} | 180 | %}tabindex="-1" |
| 185 | tabindex="-1" | ||
| 186 | {% | 181 | {% |
| 187 | endif | 182 | endif |
| 188 | %} | 183 | %} |
| @@ -205,8 +200,7 @@ | |||
| 205 | {% | 200 | {% |
| 206 | if | 201 | if |
| 207 | instance.is_active | 202 | instance.is_active |
| 208 | %} | 203 | %}disabled{% |
| 209 | disabled{% | ||
| 210 | endif | 204 | endif |
| 211 | %} | 205 | %} |
| 212 | /> | 206 | /> |
| @@ -219,8 +213,7 @@ | |||
| 219 | {% | 213 | {% |
| 220 | if | 214 | if |
| 221 | instance.is_active | 215 | instance.is_active |
| 222 | %} | 216 | %}disabled{% |
| 223 | disabled{% | ||
| 224 | endif | 217 | endif |
| 225 | %} | 218 | %} |
| 226 | > | 219 | > |
| @@ -246,8 +239,7 @@ | |||
| 246 | {% | 239 | {% |
| 247 | if | 240 | if |
| 248 | instance.is_active | 241 | instance.is_active |
| 249 | %} | 242 | %}disabled{% |
| 250 | disabled{% | ||
| 251 | endif | 243 | endif |
| 252 | %} | 244 | %} |
| 253 | /> | 245 | /> |
| @@ -257,8 +249,7 @@ | |||
| 257 | {% | 249 | {% |
| 258 | if | 250 | if |
| 259 | instance.is_active | 251 | instance.is_active |
| 260 | %} | 252 | %}disabled{% |
| 261 | disabled{% | ||
| 262 | endif | 253 | endif |
| 263 | %} | 254 | %} |
| 264 | > | 255 | > |
