summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-07-29 02:24:41 +0200
committersalmonik <salmonik@tuta.io>2026-07-29 02:24:41 +0200
commitc654a6d17eb58adbb15b9c47ab32bcc3a1666307 (patch)
tree545544bf7d805254415da787fad1cdc711767833
parentAdd per-instance mod management with client-side detection (diff)
downloadminecraft-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.
-rw-r--r--piolium/attack-surface/advisory-summary.md395
-rw-r--r--piolium/attack-surface/architecture-entrypoints.md137
-rw-r--r--piolium/attack-surface/candidates-summary.md55
-rw-r--r--piolium/attack-surface/candidates.jsonl26
-rw-r--r--piolium/attack-surface/cross-service-edges.json1
-rw-r--r--piolium/attack-surface/deep-cleanup-summary.json34
-rw-r--r--piolium/attack-surface/deep-probe-summary.md164
-rw-r--r--piolium/attack-surface/knowledge-base-report.md597
-rw-r--r--piolium/attack-surface/lite-recon.md37
-rw-r--r--piolium/attack-surface/manual-attack-surface-inventory.md311
-rw-r--r--piolium/attack-surface/patch-bypass-summary.md305
-rw-r--r--piolium/attack-surface/public-routes-authz-matrix.md92
-rw-r--r--piolium/attack-surface/sbom.json282
-rw-r--r--piolium/attack-surface/source-sink-flows-all-severities.md143
-rw-r--r--piolium/attack-surface/spec-gap-summary.md164
-rw-r--r--piolium/attack-surface/state-concurrency-summary.md46
-rw-r--r--piolium/attack-surface/unauthenticated-surface.md110
-rw-r--r--piolium/attack-surface/variant-summary.md130
-rw-r--r--piolium/audit-state.json128
-rw-r--r--piolium/final-audit-report.md407
-rw-r--r--src/auth.rs66
-rw-r--r--src/main.rs1
-rw-r--r--src/mods.rs31
-rw-r--r--src/routes/backups.rs40
-rw-r--r--src/routes/instances.rs103
-rw-r--r--src/routes/mod.rs2
-rw-r--r--src/routes/mods.rs54
-rw-r--r--src/routes/players.rs19
-rw-r--r--src/sanitize.rs221
-rw-r--r--templates/instances/edit.html39
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
34All 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
128The 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:**
1411. **`mods.rs` (480 lines)** — Custom JAR/ZIP parser, class-file constant pool scanning, ZIP extraction. Highest complexity, most attack surface.
1422. **`routes/instances.rs` (782 lines)** — World import/export (ZIP extraction), instance lifecycle, Docker orchestration. Largest single file.
1433. **`auth.rs` (199 lines)** — Session management, password hashing, cookie handling. Custom session cookie parsing (no library).
1444. **`docker.rs` (209 lines)** — Docker API calls with bollard, container creation with user-controlled env vars.
1455. **`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
177No patch-bypass history exists for this project (new codebase, no patched vulnerabilities). However, several **architectural concerns** warrant attention:
178
1791. **`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
1812. **`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
1833. **`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
1854. **`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
2601. **Mod Upload → Validation → Disk Write** — External attacker-controlled JAR/ZIP → custom bytecode parser → file written to `mods/` directory
2612. **World Import → ZIP Extraction → Disk Write** — Uploaded ZIP → `zip::ZipArchive::by_index` → `enclosed_name()` → file extraction to `world/`
2623. **Instance Create → Docker Container Start** — User-controlled form fields → env var interpolation → Docker API call
2634. **Backup Download → File Read → HTTP Response** — Backup ID → file read from disk → streamed to user (path traversal risk)
2645. **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
3561. **No regular dependency scanning configured** — No `cargo audit` or `cargo deny` in CI. Relying on manual version choice.
3572. **`htmx.min.js` (vendored)** — Static file served via `tower-http::services::ServeDir`. Should verify integrity of vendored copy.
3583. **`itzg/minecraft-server` Docker images** — External base images with Java runtimes. Image pull happens at runtime; no image hash pinning in code.
3594. **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
365Based on pattern analysis:
366
3671. **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
3722. **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
3783. **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
3854. **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
3Generated 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
55Project 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
17This manual deep probe conducted:
18
191. **Full source code review** of all 16 Rust source files (1,540+ lines analyzed)
202. **Attack surface inventory** — 38 HTTP routes catalogued with entry points, sinks, and trust boundaries
213. **Backward reasoning** — trace from sinks (file write, RCON command, Docker env, SQL) back to attacker inputs
224. **Contradiction reasoning** — identify gaps between intended security boundaries and actual code
235. **Cross-model analysis** — chain multiple low-severity issues into higher-severity attack paths
246. **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
56The full attack chain:
571. Create instance with `name=../../../tmp/pwned`
582. Upload mod/world to write files at `/tmp/pwned/data/mods/evil.so`
593. 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
65The 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
71The `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
77All 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
81The `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
87The `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
961. CSRF (P8-006) tricks authenticated admin to create instance with traversed name
972. Path traversal (P8-003) enables writing arbitrary files outside data directory
983. Combined impact: full file system write without user interaction
99
100### Chain 2: RCON Password Exposure + Command Injection
1011. Any authenticated user views RCON password (P8-009)
1022. Same user exploits RCON command injection (P8-002) for server compromise
1033. Combined impact: horizontal privilege escalation to Minecraft server operator
104
105### Chain 3: Session Leak + No TTL
1061. Session cookie leaked through any channel (logs, DB backup, etc.)
1072. No server-side TTL (P8-005) means session never expires
1083. POST /logout (P8-007) allows anonymous invalidation of leaked session
1094. 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
1431. **No rate limiting** — `POST /login` has no brute-force protection (existing p4-005, not re-drafted here)
1442. **No CSRF on any POST** — universal issue (P8-006)
1453. **Flat authorization model** — any authenticated user can access any instance (by-design for single-user/small-team)
1464. **No audit logging** — no record of state-changing operations (existing p4-017)
1475. **No encryption at rest** — SQLite database unencrypted on disk (existing p4-021)
1486. **No backup restore functionality** — `restore_backup` is a stub (existing p4-024)
1497. **Cookie without Secure flag** — missing when behind HTTPS proxy (existing p4-009)
150
151---
152
153## Recommendations (Priority Order)
154
1551. **CRITICAL: Sanitize instance name** — validate against `^[a-zA-Z0-9_-]+$` before filesystem use
1562. **HIGH: Strip newlines from Docker env vars** — sanitize MOTD and all user-controlled fields before Docker env injection
1573. **HIGH: Validate RCON usernames** — restrict to `[a-zA-Z0-9_]` (3-16 chars) before command construction
1584. **HIGH: Add CSRF protection** — SameSite=Strict cookie + CSRF tokens or origin validation
1595. **HIGH: Remove RCON password from edit form** — expose only to superusers
1606. **HIGH: Escape seed value in server.properties** — reject newlines and control characters
1617. **MEDIUM: Add server-side session TTL** — filter sessions by `created_at > datetime('now', '-1 day')`
1628. **MEDIUM: Move logout behind require_auth** — defense-in-depth
1639. **MEDIUM: Add timeout to mod validation** — prevent DoS via crafted JARs
16410. **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.
22Binds 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
461. **HTTP** (localhost:3000) — all web traffic
472. **Docker Unix Socket** (`/var/run/docker.sock`) — container management
483. **TCP** (localhost:25575) — RCON protocol to Minecraft container
494. **TCP** (0.0.0.0:25565) — Minecraft client connections (managed by container, not webUI)
505. **HTTPS** (outbound to Mojang API) — version manifest fetch
51
52### Trust Boundaries
53
54```
55HTTP 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:**
691. **Public→Protected routes:** session cookie check via `require_auth` middleware (src/auth.rs:55)
702. **WebUI→Docker:** local Unix socket — any code running as the webUI user can control containers
713. **WebUI→RCON:** localhost TCP — password-protected, but breakable if rcon_password exposed
724. **WebUI→Database:** same-process SQLite — no isolation between auth and data ops
735. **Mod upload→Filesystem:** validated JAR → instance mods directory (path traversal risk)
746. **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```
128Attacker ──▶ 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```
149Request ──▶ 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
2041. **WebUI Rust process** — local user, Docker socket access, SQLite access, filesystem write
2052. **Docker container** — itzg/minecraft-server, Java process, local network, RCON port
2063. **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
2381. **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
2422. **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
2463. **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
2504. **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
358Based 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
371See the `## SAST Enrichment` section below for per-candidate classification and keep/drop decisions.
372
373---
374
375## CodeQL Structural Analysis
376
377CodeQL 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
387Key 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
399All 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
411All 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
415All 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
543No formal specs or RFCs are implemented by this project. The application implements:
5441. **RCON protocol** (Minecraft Remote Console) — ad hoc binary protocol, not formally specified, but follows Minecraft community convention
5452. **Argon2id password hashing** (via `argon2` crate) — follows RFC 9106
5463. **UUID v4** (via `uuid` crate) — follows RFC 4122
5474. **`.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
558None 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
5731. **No CSRF protection** — all POST endpoints are vulnerable to cross-site request forgery
5742. **No rate limiting** — login endpoint, mod upload, any POST form
5753. **No session TTL enforcement** — server never checks `created_at` on sessions
5764. **No audit logging** — no record of who performed what action (except RCON commands logged via tracing)
5775. **No input validation for RCON username** — could contain spaces or special RCON-parsed characters
5786. **No backup restore implementation** — `restore_backup` handler returns a redirect without performing any restore
5797. **No encryption at rest** — SQLite DB unencrypted on disk
5808. **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
597Skipped — 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
3Generated 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
18Recent commits:
19
20```
2174d2ada Add per-instance mod management with client-side detection
2297920e4 create example config on startup
23ce30a87 remove unused code, also, third stable
249ad52d0 second stable
25ae7ef10 first stable
260c2e661 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
143All 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```
170POST /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```
180POST /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```
188POST /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```
198POST /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```
207POST /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```
216POST /login → sets cookie Max-Age=86400 (24h)
217src/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```
227All 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```
234POST /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```
242GET /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```
251src/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)
258ALL 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```
272POST /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```
280POST /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```
290POST /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
12Minecraft server management webUI built with **Rust/Axum** + **SQLite** + **Docker** (itzg/minecraft-server).
13Manages 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.
17All 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:
85let seed = form.seed.unwrap_or_default();
86// ...
87new_lines.push(format!("level-seed={seed}"));
88```
89A 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:**
103The switch to `file_name()` prevents path traversal in backup content (files inside `data/` are only stored by basename):
104```rust
105// Before (ae7ef10):
106if let Some(name) = path.file_name() { ... }
107
108// After (ce30a87 — same pattern, already present)
109```
110
111This 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:**
144The `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:**
147Added 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
161macro_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```
178POST /players/whitelist/add
179username=player1;say%20pwned
180```
181Would 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
233initial_superuser = "admin"
234initial_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
287No 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
289However, the following patterns suggest latent vulnerabilities that **should** have been fixed but remain open:
290
2911. **RCON injection in `player_action!` macro** — the most severe unfixed issue (CWE-77)
2922. **Property injection in `regenerate_world`** — seed field allows injecting arbitrary `server.properties` lines
2933. **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
305The 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
3Generated 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
12This 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
3Reachable 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
27The route registration at src/routes/mod.rs:20:
28
29```rust
30Router::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
37The `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:**
401. Session IDs are UUIDv4 (122 bits of entropy) — practically unpredictable
412. Attacker would need a victim's exact session cookie value
423. No account information is leaked; only session deletion occurs
43
44**Risk if session IDs leak:**
45Session 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
51If leaked, an attacker can force-logout the victim by sending `Cookie: mcui_session=<leaked-id>` to `/logout`.
52
53## Pre-Auth Risk Summary
54
55The 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
98The 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
110All 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
11For each of the 17 surviving Phase 10 findings, the entire codebase was searched for:
121. Same root cause pattern (unsanitized input → dangerous sink)
132. Same flow shape (source type → transformation → sink type)
143. Same attacker control level (authenticated/unauthenticated, direct/indirect)
154. 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)
94The `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)
97Only `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)
100Architectural/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)
103Each 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)
106The 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)
109Single architectural gap — no encryption layer on SQLite. No variants.
110
111---
112
113## Registry Updates
114
115The 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
130The 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
7This 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
72This 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
95Variant 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
280The 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
2981. **38 HTTP routes** — 3 public POST, 1 public GET, 31 authenticated POST, 3 authenticated GET
2992. **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
3003. **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)
3014. **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
309The 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
349The 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
3654 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
3734 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
3805 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
391This 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
393The 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};
11use rusqlite::params; 11use rusqlite::params;
12use serde::Deserialize; 12use serde::Deserialize;
13use std::collections::HashMap;
14use std::sync::Mutex as StdMutex;
15use std::time::Instant;
13use uuid::Uuid; 16use uuid::Uuid;
14 17
15use crate::models::Account; 18use crate::models::Account;
@@ -23,6 +26,34 @@ pub struct LoginForm {
23 26
24const SESSION_COOKIE: &str = "mcui_session"; 27const 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.
31const RATE_LIMIT_WINDOW_SECS: u64 = 300; // 5 minutes
32const RATE_LIMIT_MAX_ATTEMPTS: u32 = 10;
33
34static LOGIN_ATTEMPTS: std::sync::LazyLock<StdMutex<HashMap<String, (u32, Instant)>>> =
35 std::sync::LazyLock::new(|| StdMutex::new(HashMap::new()));
36
37fn 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
52fn reset_rate_limit(ip: &str) {
53 let mut attempts = LOGIN_ATTEMPTS.lock().unwrap();
54 attempts.remove(ip);
55}
56
26pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> { 57pub 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.
144const SESSION_MAX_AGE_SECS: i64 = 86400; // 24 hours
145
95pub async fn require_auth( 146pub 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
176pub async fn logout( 233pub 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;
7mod properties; 7mod properties;
8mod rcon; 8mod rcon;
9mod routes; 9mod routes;
10mod sanitize;
10 11
11use tracing_subscriber::EnvFilter; 12use 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).
47const 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.
58pub fn validate_mod(data: &[u8], filename: &str) -> ValidationResult { 61pub 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).
221const 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).
348const MAX_CP_ENTRIES: usize = 10_000;
349
321fn scan_single_class(data: &[u8]) -> ClassScan { 350fn 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
13use crate::auth::CurrentUser; 13use crate::auth::CurrentUser;
14use crate::routes::AppState; 14use crate::routes::AppState;
15use 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;
11use crate::auth::CurrentUser; 11use crate::auth::CurrentUser;
12use crate::models::Instance; 12use crate::models::Instance;
13use crate::routes::AppState; 13use crate::routes::AppState;
14use 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};
13use crate::auth::CurrentUser; 13use crate::auth::CurrentUser;
14use crate::mods; 14use crate::mods;
15use crate::routes::AppState; 15use crate::routes::AppState;
16use 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
10use crate::auth::CurrentUser; 10use crate::auth::CurrentUser;
11use crate::routes::AppState; 11use crate::routes::AppState;
12use 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
6use std::path::{Path, PathBuf};
7
8/// The maximum length (in bytes) for an instance name.
9const INSTANCE_NAME_MAX_BYTES: usize = 64;
10
11/// The maximum length (in bytes) for a seed value.
12const SEED_MAX_BYTES: usize = 64;
13
14/// The maximum length (in bytes) for a Minecraft username.
15const 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.
20fn 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).
28pub 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.
66pub 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.
79pub 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)]
94pub 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)]
105pub 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)]
113pub 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)]
120pub 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)]
127pub 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)]
134pub 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.
140pub 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)]
159mod 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 >