# Muleline Security & Code Quality Audit

**Date**: 2026-03-07
**Auditor**: Sentinel
**Scope**: Full codebase review — engine.py, router.py, db.py, storage/*.py, exif.py, config.py, example.py

---

## Review: All files in ~/muleline/

### Bug Fix Verification

**Fix 1 — example.py auth middleware regex** (`re.match(r"^/sync/files/\d+/(serve|thumb)$", path)`): CORRECT. The previous `"/serve" in path` was a path-traversal auth bypass allowing `/sync/upload/abc/complete?x=/serve` to skip auth. The regex anchors both ends and restricts to digit-only IDs. Fix is sound.

**Fix 2 — engine.py upload_chunk: `chunk_path.exists()` before incrementing `received_chunks`**: CORRECT. Prevents double-counting on re-uploaded chunks. The `already_exists` flag gates the counter increment, and the `updated_at` timestamp is still refreshed on retries. Fix is sound.

**Fix 3 — engine.py cleanup_stale: filters `status = 'uploading'`**: CORRECT. Without this filter, rows in `status = 'failed'` or `status = 'complete'` would be deleted by the cleaner, which is wrong. Now only active stale sessions are cleaned. Fix is sound.

---

### Issues Found

#### CRITICAL

**C1 — Path traversal in `_chunk_dir` via crafted `upload_id`** (`engine.py:792–794`)

`_chunk_dir` does `CHUNK_DIR / upload_id` with no validation. `upload_id` comes from the URL path parameter in three routes:

```
POST /upload/{upload_id}/chunk
GET  /upload/{upload_id}/status
POST /upload/{upload_id}/complete
DELETE /upload/{upload_id}
```

FastAPI does not strip path separators from path parameters by default (Starlette's `{upload_id}` segment will URL-decode `%2F` in some configurations). A crafted `upload_id` like `../../etc` would resolve `_chunk_dir` to a path outside `CHUNK_DIR`. Even without `%2F` decoding, `..` without slashes still works via `Path` resolution on some OSes. The fix: validate that `upload_id` is a UUID-format string before using it as a path component. The DB lookup adds owner verification, but this validation should happen before any filesystem operation.

**C2 — `complete_upload` does not enforce `MAX_UPLOAD_SIZE` on assembled content** (`engine.py:143–145`)

`init_upload` checks `total_size > MAX_UPLOAD_SIZE` against the client-declared value. But `complete_upload` assembles all chunk files and never checks `len(content) <= MAX_UPLOAD_SIZE`. A client can declare `total_size = 1` (passes the size gate), upload many large chunks (chunk size is not validated against `total_size`), and assemble a file far larger than the limit. The hash check will fail (since the hash was declared for a 1-byte file), but the content has already been loaded entirely into memory. With 500MB limit and no chunk-size validation, a multi-GB in-memory assembly is reachable. Fix: validate `len(content) <= MAX_UPLOAD_SIZE` in `complete_upload` before proceeding to hash check, and validate individual chunk size against `CHUNK_SIZE` in `upload_chunk`.

**C3 — `upload_chunk` does not validate chunk data size** (`engine.py:79–120`)

Each chunk is written to disk with no upper bound on its size. A client can POST a 500MB body as a single "chunk." With `MAX_PENDING_UPLOADS = 20` concurrent sessions per user, this allows 10GB of disk writes with no server-side limit. Add: `if len(chunk_data) > CHUNK_SIZE * 2: return {"error": "Chunk too large"}` (2× headroom for the final chunk which may be smaller, but guards against abuse).

**C4 — Google Drive storage: SQL-injection-equivalent in Drive API query** (`storage/gdrive.py:36`)

```python
query = f"name='{key}' and trashed=false"
```

`key` is the storage key, constructed in `engine.py` as `f"{owner_id}/{filename}"`. If `owner_id` is attacker-controlled (in example.py, it is set from the Bearer token verbatim: `request.state.user = {"id": token}`), an owner_id containing a single quote breaks the Drive API query string. Example: `owner_id = "x' or trashed=true or name='y"` would corrupt the query. This is Drive API query injection. Fix: sanitize or percent-encode the key before embedding in the query, or use the Drive API's `q` filter with proper escaping.

**C5 — `example.py` sets `owner_id` from raw Bearer token with no validation** (`example.py:41`)

```python
request.state.user = {"id": token}
```

The Bearer token is used directly as `owner_id` without any length limit, character sanitization, or format check. This means the owner_id stored in every DB table (`files`, `pending_uploads`, `devices`, `sync_log`, `albums`, `shared_links`) can be an arbitrary string including SQL special characters, path separators, or a 10MB blob. While all DB writes use parameterized queries (no SQL injection risk there), the value is used in `_chunk_dir` indirectly — `storage_key` is `f"{owner_id}/{filename}"`, which in LocalStorage resolves to a filesystem path via `base_dir / safe`. LocalStorage's `_path` checks `".." in safe.parts` but not if `owner_id` itself contains slashes or traversal sequences that survive `Path` parsing.

This is the example/demo middleware. It should at minimum validate token length and format with a note that production integrators must do the same.

---

#### WARNING

**W1 — Shared link password comparison is not timing-safe** (`engine.py:725`)

```python
if hashlib.sha256(password.encode()).hexdigest() != link["password_hash"]:
```

String comparison via `!=` is not constant-time. An attacker making thousands of share-link requests can use timing analysis to recover the password hash prefix. Use `hmac.compare_digest()` instead:

```python
import hmac
if not hmac.compare_digest(hashlib.sha256(password.encode()).hexdigest(), link["password_hash"]):
```

**W2 — Shared link password sent as GET query parameter** (`router.py:397`)

```python
async def get_shared_content(link_id: str, request: Request, password: str = Query(default="")):
```

Passwords in query strings appear in server logs, browser history, proxy logs, and referrer headers. Use POST with a request body instead, or at minimum document this clearly. For a sync library that may be deployed behind reverse proxies, this is a meaningful leak vector.

**W3 — `/sync/cleanup` endpoint is unauthenticated in terms of admin gating** (`router.py:258–262`)

Any authenticated user can trigger `cleanup_stale()`, which iterates all pending uploads globally (not scoped to the caller's `owner_id`) and deletes expired sessions for all users. While the cleanup logic itself is safe (it only deletes expired rows), this gives any user the ability to drive cleanup timing and observe the `cleaned` count, leaking whether other users have active uploads. Consider either restricting this to an admin role or scoping `cleanup_stale` to an `owner_id`.

**W4 — `complete_upload` re-checks status but not expiry** (`engine.py:125–135`)

`upload_chunk` requires `status = 'uploading'` on the pending upload. `complete_upload` does NOT filter by status — it queries `WHERE id = ? AND owner_id = ?` without `status = 'uploading'`. This means a client can call `complete_upload` on a session that is already `status = 'failed'` (e.g., after a hash mismatch). In that case `received_chunks == total_chunks` may be true (from the failed run), chunk files have been deleted, `_received_indexes` returns `[]`, and the "Chunk files missing on disk" guard fires — so it does ultimately block. But it's a latent correctness bug if the control flow ever changes. Add `AND status = 'uploading'` to the `complete_upload` query.

**W5 — `register_device` does not validate ownership on `ON CONFLICT` update** (`engine.py:427–441`)

The `devices` table has `UNIQUE(device_id)`. If two different `owner_id` values claim the same `device_id`, the `ON CONFLICT DO UPDATE` silently reassigns the device's `device_name` and `platform` to the second caller, while the `owner_id` stays with the first. No error is returned. This allows user B to overwrite user A's device metadata if they can guess user A's `device_id`. Fix: add `AND owner_id = excluded.owner_id` to the conflict clause, or return an error if the existing row has a different `owner_id`.

**W6 — `log_sync` accepts `file_id` from client without ownership check** (`engine.py:485–499`, `router.py:234–247`)

The `file_id` field in the sync log is inserted directly from the client-supplied body with no verification that it belongs to the calling user. Any user can write audit log entries that reference another user's `file_id`, polluting the sync log. This matches a known pattern flagged in MEMORY.md.

**W7 — `list_files` / `search_files` `limit` parameter has no server-side cap** (`router.py:107`, `engine.py:256`)

`limit: int = 200` is the default but the client can pass `limit=999999999`. This loads unbounded rows into memory. Add `limit = min(limit, 1000)` in the engine methods.

**W8 — `bulk_download_zip` loads all file content into memory simultaneously** (`engine.py:339–365`)

For large selections, all files are read from storage and assembled into a ZIP in memory before streaming. With 200 files at 10MB each = 2GB in RAM. Use `zipfile.ZipFile` with a streaming approach and `StreamingResponse`, or add a cap on the number/total size of files that can be bulk-downloaded.

**W9 — `serve_file` serves raw bytes with `media_type` set from the database** (`router.py:122–127`)

The MIME type stored in the DB came from the client's `mime_type` field in `init_upload` (or guessed from the filename). A malicious client can upload an HTML file with `mime_type: text/html`, and when served to another user via a shared link, the browser will render it as HTML, enabling stored XSS. Add `X-Content-Type-Options: nosniff` to all file-serve responses, and consider forcing `Content-Disposition: attachment` for non-image/video types.

**W10 — SFTP backend stores credentials as instance attributes in plaintext** (`storage/sftp.py:30–31`)

`self.password = password` and `self.key_path = key_path` are stored on the object. If the object is ever serialized (e.g., for debugging or introspection), credentials leak. The key file path is fine; the cleartext password is not. Zero out or avoid storing it after `_connect()`.

---

#### SUGGESTION

**S1 — `_chunk_dir` creates `CHUNK_DIR` on every call** (`engine.py:792–794`)

`CHUNK_DIR.mkdir(parents=True, exist_ok=True)` is called on every invocation of `_chunk_dir`, including the hot path in `upload_chunk`. This is a syscall on every chunk upload. Move the `mkdir` to `__init__` or call it once at startup.

**S2 — `check_duplicate` is called before creating the `CHUNK_DIR`** (`engine.py:51`)

If `check_duplicate` returns a result (fast path), `conn.close()` is called but the connection is not passed back cleanly — the `conn` variable leaks (it was opened in `init_upload`, not in `check_duplicate` when called with a passed connection). This is correct code, but the `close` flag pattern in `check_duplicate` is fragile. The outer `init_upload` opens `conn`, passes it to `check_duplicate` with `close=False`, then the outer function closes it. This works but is hard to follow. Consider using a context manager.

**S3 — `get_shared_content` increments `view_count` before verifying the file/album exists** (`engine.py:728–731`)

`view_count` is incremented, then `conn.commit()`, and only then the file/album is fetched. If the file was deleted after the link was created, the count increments but an error is returned. Cosmetic issue, but re-order: fetch content first, then commit the view count.

**S4 — Google Drive `_find_id` uses an in-memory dict cache with no TTL** (`storage/gdrive.py:35`)

If a file is deleted externally and re-created, the cached `file_id` is stale. The `delete` method pops from cache correctly, but `save` with a new file doesn't invalidate an existing cache entry on update. This is an edge case but could cause silent failures on `update` if the Drive file was deleted externally.

**S5 — SHA-256 used without salt for share link passwords** (`engine.py:698`)

Raw SHA-256 is used to hash share link passwords. These passwords are typically short (user-typed). Use `hashlib.scrypt` or `bcrypt` to resist offline brute-force if the DB is ever exposed. At minimum, add a random per-link salt.

**S6 — `complete_upload` does not verify the assembled `len(content)` matches `pending["total_size"]`** (`engine.py:143–196`)

The hash check (`actual_hash != pending["file_hash"]`) will catch a mismatch, but `len(content)` is never compared to `pending["total_size"]`. This is fine functionally (the hash covers both), but is a missing sanity check that would give clearer error messages and catch bugs where chunks are corrupted but the overall size is wrong.

**S7 — `exif.py` uses `img._getexif()` (private PIL API)** (`exif.py:22`)

`_getexif()` is a private method on `PIL.JpegImageFile` that does not exist on PNG, WebP, or TIFF images — it silently returns `None` on those. The code handles `None` correctly, but this is fragile. Use `img.getexif()` (public, available since Pillow 6.0) instead.

**S8 — `list_files` constructs `ORDER BY` clause from a string built in Python** (`engine.py:263`)

```python
order = "created_at DESC" if sort == "newest" else "created_at ASC" if sort == "oldest" else "original_name ASC"
```

The fallthrough `else` clause maps any unknown `sort` value to `"original_name ASC"` rather than rejecting it. This is safe (the value is not interpolated from user input), but `search_files` uses `order_map.get(sort, "created_at DESC")` which is the cleaner, more explicit pattern. Normalize `list_files` to the same pattern.

**S9 — `init_upload` accepts `metadata` as a raw JSON string from the client with no size limit** (`router.py:59`, `engine.py:34`)

There is no cap on the length of the `metadata` string. A client can send a multi-MB JSON blob as `metadata`, which gets stored in the DB and re-read on every `complete_upload`. Add a cap (e.g., 64KB).

**S10 — `original_name` is stored and returned without sanitization** (`engine.py:30`, `router.py:55`)

`original_name` is displayed in the UI (via `list_files` / `get_file` responses). If the API consumer renders it in HTML without escaping, filenames like `<script>alert(1)</script>.jpg` cause stored XSS. The engine cannot know how the consumer will render it, but the field should be documented as untrusted HTML. Consider truncating filenames to a reasonable length (e.g., 512 chars) to prevent DB bloat.

---

### Summary

Fix C1 (path traversal in `_chunk_dir`), C2 (no size check on assembled upload), C3 (unbounded chunk size), and C4 (Drive API query injection) before shipping. These are exploitable with a crafted client. W1 (timing-safe password comparison) and W9 (XSS via served MIME type) should also be addressed before public deployment. The three verified bug fixes are all correct and sufficient for their stated purpose.
