12 KiB
Security Audit — File Storage & Path Traversal
Scope: v1.1.5 files::* SDK + admin API; FS layout <PICLOUD_FILES_ROOT>/files/<app_id>/<collection>/<id[0:2]>/<id>.
Reviewed files:
/home/fabi/PiCloud/crates/manager-core/src/files_service.rs/home/fabi/PiCloud/crates/manager-core/src/files_repo.rs/home/fabi/PiCloud/crates/manager-core/src/files_api.rs/home/fabi/PiCloud/crates/manager-core/src/files_sweep.rs/home/fabi/PiCloud/crates/shared/src/files.rs/home/fabi/PiCloud/crates/executor-core/src/sdk/files.rs/home/fabi/PiCloud/dashboard/src/routes/apps/[slug]/files/+page.svelte/home/fabi/PiCloud/dashboard/src/lib/api.ts/home/fabi/PiCloud/caddy/Caddyfile
Counts by severity
| Severity | Count |
|---|---|
| Critical | 0 |
| High | 2 |
| Medium | 4 |
| Low | 4 |
| Info | 2 |
Findings
High
F-FS-001 — Missing MIME allowlist enables stored XSS via Content-Disposition: inline on the admin download endpoint
- Severity: High (storage half — rendering half is agent 7's call)
- Location:
crates/manager-core/src/files_api.rs:153-161(get_file),crates/shared/src/files.rs(NewFile::validate) - Summary:
get_fileechoes the script-suppliedcontent_typestraight into the responseContent-Typeheader, paired withContent-Disposition: inline; filename="...". There is no allowlist oncontent_typeat the SDK boundary —NewFile::validateonly caps its length at 127 bytes. A Rhai script canfiles::create(#{ name: "x.svg", content_type: "image/svg+xml", data: <svg onload=...> })(ortext/html), and any logged-in operator who clicks Download in the dashboard renders the attacker payload same-origin under/api/v1/admin/..., with the picloud admin session cookie attached. That is admin-session theft. - Storage-side fix: maintain a small allowlist (
image/png|jpeg|gif|webp,application/pdf,application/octet-stream,text/plain,application/json) atNewFile::validate+FileUpdate::validate. Anything else stored becomesapplication/octet-streamon serve, OR the admin endpoint forcesContent-Disposition: attachmentandX-Content-Type-Options: nosniff+Content-Security-Policy: sandboxfor non-allowlisted types. - Cross-ref: agent 7 owns the rendering / response-header conclusion (CSP, sandbox,
nosniff, force-attachment). Flag jointly.
F-FS-002 — Admin files API skips collection validation; underlying head/get/list/delete skip the FS-path guard too
- Severity: High (defense-in-depth gap; not exploitable today, but one bug away from arbitrary-FS-read/write/unlink)
- Location:
crates/manager-core/src/files_api.rs:78-184(novalidate_collection);crates/manager-core/src/files_repo.rs:321,395(guard_collectionis only called fromcreateandupdate—head,get,list,deletedo not call it). - Summary: The admin endpoints accept
collectionas a path/query parameter and pass it raw to the repo. The repo'shead/get/listonly execute parametrized SQL (no immediate FS access), anddeleteonly buildsfinal_pathandremove_files after a matching DB row is found. Today this is safe becausecreate/updatevalidate before insert, so no row can exist with a traversal collection. Tomorrow it's one missed validation away from full filesystem reach. Specifically:FsFilesRepo::get→read_verify_at(..., collection, id, ...)andFsFilesRepo::delete→self.final_path(app_id, collection, id)+std::fs::remove_file(path)both build paths from the unvalidated collection. A future code path that inserts a row withcollection = "../../etc"(a buggy migration, an admin restore tool, a backup loader) would immediately give cross-app file-read and unlink. - Fix shape: call
validate_files_collectionat the top of every admin endpoint (list_files,get_file,delete_file) AND addSelf::guard_collection(collection)?;toFsFilesRepo::{head, get, list, delete}. Both layers — depth — because the audit posture for this surface is "no traversal possible regardless of caller."
Medium
F-FS-003 — No per-app storage quota; one tenant can fill the host disk
- Severity: Medium (cross-ref agent 8 for the host-DoS angle)
- Location:
crates/manager-core/src/files_service.rs,crates/manager-core/src/files_repo.rs— no quota check anywhere. - Summary: Only the per-file cap (
PICLOUD_FILES_MAX_FILE_SIZE_BYTES, default 100 MB) is enforced. An app can create unlimited rows of up to 100 MB each via any public Rhai script that callsfiles::create. With multi-tenant isolation as a stated goal, this is the cheapest path to host-level DoS: one anonymous public HTTP script + a loop. CLAUDE.md acknowledges per-app quotas are deferred to v1.2, but the threat surface exists today. - Fix shape: track
SUM(size_bytes) GROUP BY app_idin a small counter table (files_app_usage), increment/decrement in the same tx as thefilesrow mutation, rejectcreate/updatewhen projected usage exceedsPICLOUD_FILES_APP_QUOTA_BYTES(default e.g. 5 GB).
F-FS-004 — Content-Disposition filename is not RFC 5987-encoded; non-ASCII names lose data or break the header
- Severity: Medium
- Location:
crates/manager-core/src/files_api.rs:153-156 - Summary:
CRLF and
let disposition = format!( "inline; filename=\"{}\"", meta.name.replace('"', "").replace(['\r', '\n'], "") );"are stripped (good — no header injection), but no RFC 5987filename*=UTF-8''<pct-encoded>form. Non-ASCII filenames (café.pdf,合同.pdf) either round-trip through a lossy ASCII strip (browsers vary), or become an invalidfilename="..."token if any high-bit byte is preserved. Combined withinline, the response then renders without an obvious filename, weakening the user's ability to spot a malicious upload. - Fix shape: emit both forms —
filename="<ASCII-sanitized fallback>"; filename*=UTF-8''<percent-encoded>— per RFC 6266 §5.
F-FS-005 — Temp file mode honors umask (typically 0644) instead of 0600
- Severity: Medium (downgraded by the 0o700 shard-dir guard, but defense-in-depth misses)
- Location:
crates/manager-core/src/files_repo.rs:267—std::fs::File::create(&tmp) - Summary: The temp file gets default
OpenOptions::create(true).write(true).truncate(true)mode, masked through the process umask. On a typical Linux box that's 0644. The 0o700 parent shard dir protects against world access today, but ifPICLOUD_FILES_ROOTever lives somewhere mounted withnosuid,nodevfor a service user, or if the operator chowns the root to a less-restrictive group, file bytes leak. - Fix shape: on Unix, set
OpenOptionsExt::mode(0o600)on the temp file create; thenset_permissions(0o600)after rename to harden against the umask not applying as expected.
F-FS-006 — No symlink-aware path resolution; a stray symlink inside the root escapes
- Severity: Medium (theoretical — there is no code path that creates symlinks under
PICLOUD_FILES_ROOT, but the sweeper and the read path do not detect them) - Location:
crates/manager-core/src/files_repo.rs:282-311(read_verify_at);crates/manager-core/src/files_sweep.rs:75-111(walk) - Summary:
read_verify_atopens viastd::fs::read(&path)which follows symlinks. The orphan sweeper'swalkusesentry.file_type()(which returns the symlink type if applicable), and skips non-files — symlinks-to-files would be skipped, but symlinks-to-dirs are also skipped, OK. The bigger gap is read: if any code path (a future backup-restore, an operator drop-in, a docker volume mount) plants a symlink at<root>/files/<app>/<col>/<sh>/<id>pointing to/etc/shadow, a subsequentget_filereads it. There's nocanonicalize+ prefix check. - Fix shape: in
read_verify_at/write_atomic_at, after constructing the path,canonicalizeit and verify the canonical form starts with the canonicalized files-root; reject otherwise. For the sweeper, usesymlink_metadatainstead ofmetadatato avoid traversing.
Low
F-FS-007 — Download endpoint relies on session cookie only; no signed-URL option
- Severity: Low (auth is required via cookie — but the URL is unbearer-able)
- Location:
crates/manager-core/src/files_api.rs:130-165,dashboard/src/lib/api.ts:956-957 - Summary:
api.files.downloadUrl(...)returns a plain/api/v1/admin/...URL relying on thepicloud_sessioncookie for auth. Authn is enforced (auth middleware +require(... AppFilesRead)), so it is not open. The only concern is operator hygiene: if an admin copies the URL and shares it (intending to share the file), the recipient gets auth-free 403 instead of the file — fine, defense-wise. No fix required; just noting.
F-FS-008 — Filename-byte-length cap of 255 doesn't account for Windows / FAT-style ceilings or POSIX NAME_MAX on platforms with smaller caps
- Severity: Low
- Location:
crates/shared/src/files.rs:27,197 - Summary: 255 bytes is fine on ext4/btrfs/xfs. On some platforms (HFS+: 255 UTF-16 code units; ZFS: usually OK), edge cases. The name is also reflected into
Content-Dispositionand the dashboard table, so the cap is more a header-hygiene concern than an FS one.
F-FS-009 — head/get/list don't reject NUL bytes in collection before SQL
- Severity: Low (Postgres rejects NUL in TEXT, so the SQL fails cleanly with a 500 — but the user sees "internal error" instead of "invalid collection")
- Location:
crates/manager-core/src/files_repo.rs:347-516 - Summary: Adding
Self::guard_collection(see F-FS-002) gives a precise 4xx instead of a 5xx for\0in collection names.
F-FS-010 — Content-Length from bytes.len() is set, but no Cache-Control / X-Content-Type-Options on the download
- Severity: Low
- Location:
crates/manager-core/src/files_api.rs:158-164 - Summary: Missing
X-Content-Type-Options: nosnifflets the browser MIME-sniff a storedtext/plainpayload intotext/htmland render JS. Pairs with F-FS-001 as the response-header hardening pass.
Info
F-FS-011 — File IDs are server-side Uuid::new_v4(); safe from caller-controlled bytes
- Location:
crates/manager-core/src/files_repo.rs:322and:268(in-memory test). UUIDs are formatted viato_string()(lowercase hex + dashes, never traversal-shaped). Good.
F-FS-012 — Atomic write protocol is correct; delete order is correct
- Location:
files_repo.rs:217-277and:431-473. Temp + fsync + rename + dir-fsync; row delete inside tx then unlink after commit. The "metadata gone, bytes still on disk" leak is bounded (rare crash window after the tx commit) and is reclaimable by a future reconcile sweep (currently only*.tmp.*orphans are reaped byfiles_sweep.rs). No bug — just noting the design is sound and the residual leak is documented.
Cross-references for other agents
- Agent 7 (XSS / rendering): F-FS-001 — the storage side allows arbitrary
content_type; rendering side owns CSP /X-Content-Type-Options: nosniff/ forcedattachment. Joint sev = High. - Agent 8 (DoS / resource): F-FS-003 (no per-app quota) — same concern they may have raised for
kv::set/docs::*/pubsub/queueunbounded payloads (cf. F-S-001 in AUDIT.md). Files has a per-row cap, but no per-app aggregate. - Agent 10 (CLI / config files): CLI credentials are
0o600(verified,crates/picloud-cli/src/config.rs:98,107); no finding from the FS angle.
Most-important takeaway
Today nothing escapes PICLOUD_FILES_ROOT — create and update guard collection, and FS paths are only ever built from server-generated UUIDs and validated collections. The exposure that is live is F-FS-001: a public-route script can stash text/html or image/svg+xml content that the operator's browser renders same-origin under the admin session cookie. The exposure that is one mistake away is F-FS-002: every read/delete path through the admin API trusts collection-validation done elsewhere, and the repo's head/get/list/delete don't have the belt-and-suspenders guard that create/update carry. Add the missing validate_files_collection calls at the admin layer and guard_collection calls at the repo layer, and the surface becomes traversal-proof regardless of caller. The MIME allowlist + Content-Disposition: attachment for non-image types closes the stored-XSS class jointly with agent 7's response-header pass.