SECURITY: public folder links list and serve dot files (.env, .git/) that the owner's Files view hides #127

Closed
opened 2026-09-25 22:17:32 +00:00 by kayg · 2 comments
Owner

Severity: high (SECURITY: information exposure the owner cannot see)

Problem

The Files browser hides dot names by default (isHiddenName, "like Finder hides dotfiles"). A public link to a folder lists and serves them to anonymous visitors. An owner who shares a project folder from Files does not see its .env or .git/ in their own view, but a visitor downloads them.

Probe on a fresh server (owner uploads Pub/.env, Pub/.git/config, Pub/visible.txt, then makes a view+download public link pub):

GET /api/v1/public/pub/entries                 → .env, .git, visible.txt
GET /api/v1/public/pub/download?path=.env        → 200 "SECRET_TOKEN=hunter2"
GET /api/v1/public/pub/download?path=.git/config → 200 "[remote \"origin\"] url = https://user:token@…"
GET /api/v1/public/pub/download?path=.calternal.json → 404 (metadata is protected)

The public page shows ".hidden-looking folder" and "...dots....txt" that the owner's Files view of the same folder does not show: /home/kayg/Developer/calternal/target/breakit/run1/public-folder_1440-paper.png vs /home/kayg/Developer/calternal/target/breakit/run1/files-hostile_1440-paper.png (smoke run).

Expected

What a visitor can see is what the owner saw when they made the link. Options for the owner to decide:

  1. Public links (and shares) skip dot entries unless the owner turns on "Include hidden items" in the share dialog; the download endpoint refuses dot path segments the same way file drop already does (public.rs refuses dot names for uploads).
  2. Or the share dialog warns: "This folder has N hidden items (.env, .git …). Visitors can see them."

Server code: crates/plugins/files/src/public.rs (entries, download, zip). Owner: whoever owns public links; not fixed here because it touches file serving (job/inline-xss is active in that area).

Found by the break-it sweep (#117). Re-run: cd apps/web && bun run build && bun e2e/breakit.mjs --keep <dir> (script on branch job/breakit-fixes). Screenshots: /home/kayg/Developer/calternal/target/breakit (run 1 in run1/, fix checks in verify/).

**Severity:** high (SECURITY: information exposure the owner cannot see) ## Problem The Files browser hides dot names by default (`isHiddenName`, "like Finder hides dotfiles"). A public link to a folder lists **and serves** them to anonymous visitors. An owner who shares a project folder from Files does not see its `.env` or `.git/` in their own view, but a visitor downloads them. Probe on a fresh server (owner uploads `Pub/.env`, `Pub/.git/config`, `Pub/visible.txt`, then makes a view+download public link `pub`): ``` GET /api/v1/public/pub/entries → .env, .git, visible.txt GET /api/v1/public/pub/download?path=.env → 200 "SECRET_TOKEN=hunter2" GET /api/v1/public/pub/download?path=.git/config → 200 "[remote \"origin\"] url = https://user:token@…" GET /api/v1/public/pub/download?path=.calternal.json → 404 (metadata is protected) ``` The public page shows ".hidden-looking folder" and "...dots....txt" that the owner's Files view of the same folder does not show: `/home/kayg/Developer/calternal/target/breakit/run1/public-folder_1440-paper.png` vs `/home/kayg/Developer/calternal/target/breakit/run1/files-hostile_1440-paper.png` (smoke run). ## Expected What a visitor can see is what the owner saw when they made the link. Options for the owner to decide: 1. Public links (and shares) skip dot entries unless the owner turns on "Include hidden items" in the share dialog; the download endpoint refuses dot path segments the same way file drop already does (`public.rs` refuses dot names for uploads). 2. Or the share dialog warns: "This folder has N hidden items (.env, .git …). Visitors can see them." Server code: `crates/plugins/files/src/public.rs` (entries, download, zip). Owner: whoever owns public links; not fixed here because it touches file serving (job/inline-xss is active in that area). Found by the break-it sweep (#117). Re-run: `cd apps/web && bun run build && bun e2e/breakit.mjs --keep <dir>` (script on branch `job/breakit-fixes`). Screenshots: `/home/kayg/Developer/calternal/target/breakit` (run 1 in `run1/`, fix checks in `verify/`).
Author
Owner

Fixed on job/public-dotfiles (not merged, not pushed)

Commits

  • 6237eeb test(files): shares never show or serve hidden (dot) entries: the failing tests first, plus the adversarial probe
  • 6015467 fix(files): shares never show or serve hidden (dot) entries
  • 5926f55 fix(calendar, photos): a Share recipient's views leave out hidden entries
  • 1e19539 test(adversarial): the dot-file probe uses case variants that the store accepts

Proof that the tests failed first (6237eeb, before the fix): 5 of 6 tests in crates/plugins/files/src/tests/hidden_entries.rs failed:

public_listing_omits_hidden_entries: left: [".Hg", ".Npmrc", ".calternal", ".env", ".git", ".hidden.jpg", "project", "visible.txt"]
public_bytes_of_hidden_entries_are_refused_by_path: /dots/download?path=.env: 200 OK SECRET_TOKEN=hunter2
public_hidden_entries_cannot_be_reached_by_item_id: dots: 8 entries (expected 2)
public_links_on_hidden_items_are_refused_and_dead: Pub/.git  left: 200 (expected 400)
internal_share_recipients_do_not_see_hidden_entries: share of Pub/.git  left: 200 (expected 400)

public_file_drop_refuses_hidden_targets already passed. It stays as a regression guard, because job/share-audit already refused dot names on file drop.

The predicate: calternal_path::is_hidden_name (the name starts with U+002E) and is_hidden_path (any component is hidden). calternal-fs re-exports both. This is the same rule as the web isHiddenName, and each one now points to the other. Look-alike dots (U+2024, U+FF0E, U+FE52) name different files that the owner can see. NFC never turns them into ., so they cannot reach .env. . and .. count as hidden, so the check also refuses dot segments before RelPath. Case does not matter.

Choke points

  • Public links: authorize() refuses a stored link whose path is hidden. target() is now the only function that turns a visitor's address into a path, and it refuses any hidden component below the link. That covers both path= and item=. The listing leaves hidden entries out. The unfurl (preview) is generic. File drop uses the same predicate. Creating or updating a link on a hidden item gives 400.
  • Internal shares: grant_allows() refuses any hidden path. This covers read, write, stat, download, item lookup, search, the change feed, thumbnails and live notes. Both Shared listings leave hidden entries out: the indexed page filters in SQL, so totals and cursors agree. The ZIP skips hidden entries, so the archive completes without them. A share of a hidden item gives 400. A recipient's Calendar and Photos library also skip hidden paths of another owner (Photos had exposed place, camera and thumbhash).

Endpoints covered: info, entries, download, preview, thumb, the OG head, uploads (file drop), item-ID guessing, a nested .git/config, .. and . segments, look-alike dots, a double-encoded %2E, mixed case (.Npmrc, .Hg/, .ENV, .GIT). There is no public ZIP. The probe checks that too.

Internal-share decision (DESIGN §22): a member recipient follows the same rule as a public link. Dot entries are hidden and cannot be fetched, and an editor cannot create one. The recipient's "Show hidden files" shows nothing more under Shared/. To give a member a hidden file, the owner renames it or moves it into a visible folder. The owner's own view does not change.

Gates

  • cargo fmt --check: FMT-OK (no output)
  • cargo clippy --workspace --all-targets -- -D warnings: Finished \dev` profile [unoptimized + debuginfo] target(s) in 33.24s`
  • cargo test --workspace: exit 0; 65 test binaries, 1119 passed, 0 failed
  • bash packages/api-client/check-generated.sh: exit 0, no diff (the API shape did not change)
  • bun run check: COMPLETED 1427 FILES 0 ERRORS 0 WARNINGS 0 FILES_WITH_PROBLEMS; bun run test: Test Files 49 passed (49), Tests 356 passed (356) (the web changed only in one doc comment)
  • apps/web/e2e/share.mjs: SHARE E2E PASSED (all 10 PASS)
  • bash tests/adversarial/run.sh (full): round 2 has 12 SLOW findings (load) and 1 bug in my own probe (.Env next to .env gives 409, because the store refuses names that differ only in case), fixed in 1e19539. Hostile bytes: 0. Restart probe: 0. Round 1: SLOW (load), plus bookmark capture storm with -1 (no response in 30 s) under load. That is outside this change.
  • The share-options section run by itself, after 1e19539, on a fresh server (the member is still present, so the member part runs): dot files: member probe runs, ==== ROUND 2 FINDINGS 0. In a full run, the deletion section removes the members before share-options, so the member part prints that it was skipped.
## Fixed on `job/public-dotfiles` (not merged, not pushed) **Commits** - `6237eeb` test(files): shares never show or serve hidden (dot) entries: the failing tests first, plus the adversarial probe - `6015467` fix(files): shares never show or serve hidden (dot) entries - `5926f55` fix(calendar, photos): a Share recipient's views leave out hidden entries - `1e19539` test(adversarial): the dot-file probe uses case variants that the store accepts **Proof that the tests failed first** (`6237eeb`, before the fix): 5 of 6 tests in `crates/plugins/files/src/tests/hidden_entries.rs` failed: ``` public_listing_omits_hidden_entries: left: [".Hg", ".Npmrc", ".calternal", ".env", ".git", ".hidden.jpg", "project", "visible.txt"] public_bytes_of_hidden_entries_are_refused_by_path: /dots/download?path=.env: 200 OK SECRET_TOKEN=hunter2 public_hidden_entries_cannot_be_reached_by_item_id: dots: 8 entries (expected 2) public_links_on_hidden_items_are_refused_and_dead: Pub/.git left: 200 (expected 400) internal_share_recipients_do_not_see_hidden_entries: share of Pub/.git left: 200 (expected 400) ``` `public_file_drop_refuses_hidden_targets` already passed. It stays as a regression guard, because job/share-audit already refused dot names on file drop. **The predicate:** `calternal_path::is_hidden_name` (the name starts with U+002E) and `is_hidden_path` (any component is hidden). `calternal-fs` re-exports both. This is the same rule as the web `isHiddenName`, and each one now points to the other. Look-alike dots (U+2024, U+FF0E, U+FE52) name different files that the owner can see. NFC never turns them into `.`, so they cannot reach `.env`. `.` and `..` count as hidden, so the check also refuses dot segments before `RelPath`. Case does not matter. **Choke points** - Public links: `authorize()` refuses a stored link whose path is hidden. `target()` is now the only function that turns a visitor's address into a path, and it refuses any hidden component below the link. That covers both `path=` and `item=`. The listing leaves hidden entries out. The unfurl (`preview`) is generic. File drop uses the same predicate. Creating or updating a link on a hidden item gives 400. - Internal shares: `grant_allows()` refuses any hidden path. This covers read, write, stat, download, item lookup, search, the change feed, thumbnails and live notes. Both Shared listings leave hidden entries out: the indexed page filters in SQL, so totals and cursors agree. The ZIP skips hidden entries, so the archive completes without them. A share of a hidden item gives 400. A recipient's Calendar and Photos library also skip hidden paths of another owner (Photos had exposed place, camera and thumbhash). **Endpoints covered:** info, entries, download, preview, thumb, the OG head, uploads (file drop), item-ID guessing, a nested `.git/config`, `..` and `.` segments, look-alike dots, a double-encoded `%2E`, mixed case (`.Npmrc`, `.Hg/`, `.ENV`, `.GIT`). There is no public ZIP. The probe checks that too. **Internal-share decision (DESIGN §22):** a member recipient follows the same rule as a public link. Dot entries are hidden and cannot be fetched, and an editor cannot create one. The recipient's "Show hidden files" shows nothing more under `Shared/`. To give a member a hidden file, the owner renames it or moves it into a visible folder. The owner's own view does not change. **Gates** - `cargo fmt --check`: `FMT-OK` (no output) - `cargo clippy --workspace --all-targets -- -D warnings`: `Finished \`dev\` profile [unoptimized + debuginfo] target(s) in 33.24s` - `cargo test --workspace`: exit 0; 65 test binaries, 1119 passed, 0 failed - `bash packages/api-client/check-generated.sh`: exit 0, no diff (the API shape did not change) - `bun run check`: `COMPLETED 1427 FILES 0 ERRORS 0 WARNINGS 0 FILES_WITH_PROBLEMS`; `bun run test`: `Test Files 49 passed (49)`, `Tests 356 passed (356)` (the web changed only in one doc comment) - `apps/web/e2e/share.mjs`: `SHARE E2E PASSED` (all 10 PASS) - `bash tests/adversarial/run.sh` (full): round 2 has 12 SLOW findings (load) and 1 bug in my own probe (`.Env` next to `.env` gives 409, because the store refuses names that differ only in case), fixed in `1e19539`. Hostile bytes: 0. Restart probe: 0. Round 1: SLOW (load), plus `bookmark capture storm` with -1 (no response in 30 s) under load. That is outside this change. - The `share-options` section run by itself, after `1e19539`, on a fresh server (the member is still present, so the member part runs): `dot files: member probe runs`, `==== ROUND 2 FINDINGS 0`. In a full run, the deletion section removes the members before `share-options`, so the member part prints that it was skipped.
Author
Owner

Merged into dev; deploys after the next full gate run.

Merged into dev; deploys after the next full gate run.
kayg closed this issue 2026-09-26 07:08:17 +00:00
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal#127
No description provided.