Share options: prove every public-link option end to end (API + browser) #109

Closed
opened 2026-09-25 18:30:45 +00:00 by kayg · 1 comment
Owner

Branch job/share-audit. Owner asked 'is every one of these toggles tested?'. For each Share dialog option (custom link name, view, download, file drop, edit, linked notes, expires, password, limit downloads, show as, hide file names) prove visitor behaviour via real API calls and Playwright in a fresh browser, fix every hole (security first), regression-test each, report a matrix.

Branch job/share-audit. Owner asked 'is every one of these toggles tested?'. For each Share dialog option (custom link name, view, download, file drop, edit, linked notes, expires, password, limit downloads, show as, hide file names) prove visitor behaviour via real API calls and Playwright in a fresh browser, fix every hole (security first), regression-test each, report a matrix.
Author
Owner

Share audit report (branch job/share-audit, not pushed)

Matrix: option → API test → UI test → result

API test = tests/adversarial/attack2.py section share-options (live server) plus Rust regression tests in crates/plugins/files. UI test = apps/web/e2e/share.mjs (real dialog, then a fresh anonymous browser context).

Option API UI Result
Custom link name reserved words, Cyrillic/full-width/zero-width/RTL, dashes, 2/64/65 chars, 409 collision, rename onto a taken name dash rule, "not allowed", "taken" messages, reopen shows the name pass (fixed)
View listing ok; /download and /preview of photo/PDF 403; thumbs are webp and never the original; note preview is text gallery, Quick Look from thumbnail, no /download request, no Download button pass (fixed)
Download bytes + attachment; download-only has no listing or thumbs Download button, file name pass
File drop adds; existing name, replace, dot names, .calternal.json, .xmp for an existing file, traversal all refused; 12-writer storm → 1 lands; no version created drop zone, no listing, same name → renamed copy, original unchanged pass (fixed)
Edit refused 400; a stored flag grants nothing toggle removed not built → #105
Linked notes refused 400 toggle removed not built → #106
Expires every endpoint 404; If-None-Match on a thumbnail → 404; unfurl → "Shared link" page shows "not available" pass (fixed)
Password 401 on every endpoint including uploads; 5 tries then 429 (also on the right password); 24 parallel thumbnails pass; removal works wrong/right password, gallery without 429, turning it off removes it pass (fixed)
Limit downloads 48-way race → exactly 3; previews, thumbs and unfurls count 0; after the limit, full preview 403 and note preview 200 document view and Quick Look do not count; button gone after the limit pass (fixed)
Show as folder: auto/list/gallery; file: auto/document; others 400 auto → gallery for photos, list for docs, document for .md; list forced; disabled segments give a reason pass (fixed)
Hide file names no name in info, listing, sub-listing, download/preview headers, page HTML, OG; guessed names 404; foreign item IDs refused; no public ZIP exists "Item N.ext" only, subfolder by ID, no name in body or title pass (fixed)
Toggle state on reopen — every toggle, expiry day, password placeholder, presentation, limit match the server pass (fixed)

Bugs found and fixed (by severity)

Security (blocks a merge)

  1. Edit + File drop let a visitor overwrite any file in the folder (tus Upload-Conflict-Policy: replace), not only notes and text. Public uploads now never replace a file, at create and at completion.
  2. File drop could create .calternal.json (folder tags), dot folders, and .xmp sidecars for existing photos, which changes the owner's metadata. Refused.
  3. With hidden names the server still accepted real names in ?path=, so a visitor could probe for names. Now it takes item IDs only.

High
4. Every preview (document view, Quick Look) went through /download and used up the download limit. A password folder in Quick Look fetched and counted every file at once. New /preview endpoint: inline, sandboxed (CSP sandbox, nosniff, HTML sent as text), Range support, not counted. View-only gets notes and text only.
5. Hidden names broke every download and subfolder (the page sent "Item 3" as the path).
6. A request without a password used up a rate-limit attempt: five page loads locked the visitor out. Also, a password gallery got 429 on parallel thumbnails, and every thumbnail ran Argon2. A verified password is now cached, keyed by its PHC hash.
7. A password could not be removed (password: null kept it). Added clear_password; the dialog uses it.

Medium / low
8. Edit and Linked notes toggles were stored but did nothing. The API refuses them and the toggles are removed.
9. File drop on a file, gallery or list on a file, document on a folder, and a limit without Download were all accepted. Now 400 on the server, and disabled with a reason in the dialog (aria-describedby).
10. Reopening a link showed the UTC expiry day, so each save moved the expiry by one day west of UTC.
11. Thumbnails were cached for 5 minutes after expiry or revoke. They now use no-cache + ETag and 304.
12. Content-Disposition failed on control characters, and a file inside a folder link was named after the folder. Now RFC 6266 filename*, with bidi and control characters stripped (the rule is shared with head.rs).
13. Link names like -a, a-, a--b, admin, login were accepted. Now refused.

Not fixed, filed

  • #105 Public links: guest editing (Edit permission)
  • #106 Public links: linked notes
  • #107 Owner /files/download?inline=true serves HTML and SVG as live pages on the app origin (stored XSS via an editor share). Outside this job's files.

Commits

cff9420 Public links: enforce every option on every endpoint
34a18d2 Share dialog and link page show only what a link really does
8a13267 Adversarial probe: attack every public link option on a live server
c1f6ba2 E2E: every Share dialog option, seen by a visitor
505d256 Format the download-limit regression test
708c43c Adversarial probe: fit the deletion link and the cross-user check to the new rules

Gates

  • cargo fmt --check: exit 0
  • cargo clippy --workspace --all-targets -- -D warnings: Finished \dev` profile [unoptimized + debuginfo] target(s) in 5m 38s`, exit 0
  • cargo test --workspace: all test result: ok, 1039 passed, 0 failed, 9 ignored, exit 0
  • bash packages/api-client/check-generated.sh: exit 0 (contract changed: /preview, item param, clear_password, extension; regenerated and committed)
  • bun run check: COMPLETED 1383 FILES 0 ERRORS 0 WARNINGS 0 FILES_WITH_PROBLEMS
  • bun run test: Test Files 36 passed (36), Tests 252 passed (252)
  • bun run build: exit 0
  • bun e2e/share.mjs: 10 PASS lines, SHARE E2E PASSED. bun e2e/files.mjs (now uses the shared e2e/harness.mjs): FILES E2E PASSED. One rerun flaked in WebAuthn registration ("A request is already pending"); the same step is unchanged from dev.
  • bash tests/adversarial/run.sh: ==== ROUND 2 FINDINGS 0. Round 1 is not 0: ==== FINDINGS 30 in the last run, all SLOW timings over the 5 s threshold (appearance concurrent PUT, saved search create storm, DAV, calendar Log). None of that code was touched, and none of the findings were errors. The host load average was 27–33 from other jobs during all three runs. Round 1 needs a rerun on a quiet host before merge.

cargo clean done.

## Share audit report (branch `job/share-audit`, not pushed) ### Matrix: option → API test → UI test → result API test = `tests/adversarial/attack2.py` section `share-options` (live server) plus Rust regression tests in `crates/plugins/files`. UI test = `apps/web/e2e/share.mjs` (real dialog, then a fresh anonymous browser context). | Option | API | UI | Result | |---|---|---|---| | Custom link name | reserved words, Cyrillic/full-width/zero-width/RTL, dashes, 2/64/65 chars, 409 collision, rename onto a taken name | dash rule, "not allowed", "taken" messages, reopen shows the name | pass (fixed) | | View | listing ok; /download and /preview of photo/PDF 403; thumbs are webp and never the original; note preview is text | gallery, Quick Look from thumbnail, no /download request, no Download button | pass (fixed) | | Download | bytes + attachment; download-only has no listing or thumbs | Download button, file name | pass | | File drop | adds; existing name, replace, dot names, `.calternal.json`, `.xmp` for an existing file, traversal all refused; 12-writer storm → 1 lands; no version created | drop zone, no listing, same name → renamed copy, original unchanged | pass (fixed) | | Edit | refused 400; a stored flag grants nothing | toggle removed | **not built** → #105 | | Linked notes | refused 400 | toggle removed | **not built** → #106 | | Expires | every endpoint 404; If-None-Match on a thumbnail → 404; unfurl → "Shared link" | page shows "not available" | pass (fixed) | | Password | 401 on every endpoint including uploads; 5 tries then 429 (also on the right password); 24 parallel thumbnails pass; removal works | wrong/right password, gallery without 429, turning it off removes it | pass (fixed) | | Limit downloads | 48-way race → exactly 3; previews, thumbs and unfurls count 0; after the limit, full preview 403 and note preview 200 | document view and Quick Look do not count; button gone after the limit | pass (fixed) | | Show as | folder: auto/list/gallery; file: auto/document; others 400 | auto → gallery for photos, list for docs, document for .md; list forced; disabled segments give a reason | pass (fixed) | | Hide file names | no name in info, listing, sub-listing, download/preview headers, page HTML, OG; guessed names 404; foreign item IDs refused; no public ZIP exists | "Item N.ext" only, subfolder by ID, no name in body or title | pass (fixed) | | Toggle state on reopen | — | every toggle, expiry day, password placeholder, presentation, limit match the server | pass (fixed) | ### Bugs found and fixed (by severity) **Security (blocks a merge)** 1. Edit + File drop let a visitor overwrite **any** file in the folder (tus `Upload-Conflict-Policy: replace`), not only notes and text. Public uploads now never replace a file, at create and at completion. 2. File drop could create `.calternal.json` (folder tags), dot folders, and `.xmp` sidecars for existing photos, which changes the owner's metadata. Refused. 3. With hidden names the server still accepted real names in `?path=`, so a visitor could probe for names. Now it takes item IDs only. **High** 4. Every preview (document view, Quick Look) went through `/download` and used up the download limit. A password folder in Quick Look fetched and counted **every** file at once. New `/preview` endpoint: inline, sandboxed (CSP `sandbox`, `nosniff`, HTML sent as text), Range support, not counted. View-only gets notes and text only. 5. Hidden names broke every download and subfolder (the page sent "Item 3" as the path). 6. A request without a password used up a rate-limit attempt: five page loads locked the visitor out. Also, a password gallery got 429 on parallel thumbnails, and every thumbnail ran Argon2. A verified password is now cached, keyed by its PHC hash. 7. A password could not be removed (`password: null` kept it). Added `clear_password`; the dialog uses it. **Medium / low** 8. Edit and Linked notes toggles were stored but did nothing. The API refuses them and the toggles are removed. 9. File drop on a file, gallery or list on a file, document on a folder, and a limit without Download were all accepted. Now 400 on the server, and disabled with a reason in the dialog (`aria-describedby`). 10. Reopening a link showed the UTC expiry day, so each save moved the expiry by one day west of UTC. 11. Thumbnails were cached for 5 minutes after expiry or revoke. They now use `no-cache` + ETag and 304. 12. `Content-Disposition` failed on control characters, and a file inside a folder link was named after the folder. Now RFC 6266 `filename*`, with bidi and control characters stripped (the rule is shared with head.rs). 13. Link names like `-a`, `a-`, `a--b`, `admin`, `login` were accepted. Now refused. ### Not fixed, filed - #105 Public links: guest editing (Edit permission) - #106 Public links: linked notes - #107 Owner `/files/download?inline=true` serves HTML and SVG as live pages on the app origin (stored XSS via an editor share). Outside this job's files. ### Commits cff9420 Public links: enforce every option on every endpoint 34a18d2 Share dialog and link page show only what a link really does 8a13267 Adversarial probe: attack every public link option on a live server c1f6ba2 E2E: every Share dialog option, seen by a visitor 505d256 Format the download-limit regression test 708c43c Adversarial probe: fit the deletion link and the cross-user check to the new rules ### Gates - `cargo fmt --check`: exit 0 - `cargo clippy --workspace --all-targets -- -D warnings`: `Finished \`dev\` profile [unoptimized + debuginfo] target(s) in 5m 38s`, exit 0 - `cargo test --workspace`: all `test result: ok`, 1039 passed, 0 failed, 9 ignored, exit 0 - `bash packages/api-client/check-generated.sh`: exit 0 (contract changed: `/preview`, `item` param, `clear_password`, `extension`; regenerated and committed) - `bun run check`: `COMPLETED 1383 FILES 0 ERRORS 0 WARNINGS 0 FILES_WITH_PROBLEMS` - `bun run test`: `Test Files 36 passed (36)`, `Tests 252 passed (252)` - `bun run build`: exit 0 - `bun e2e/share.mjs`: 10 PASS lines, `SHARE E2E PASSED`. `bun e2e/files.mjs` (now uses the shared `e2e/harness.mjs`): `FILES E2E PASSED`. One rerun flaked in WebAuthn registration ("A request is already pending"); the same step is unchanged from dev. - `bash tests/adversarial/run.sh`: `==== ROUND 2 FINDINGS 0`. Round 1 is **not 0**: `==== FINDINGS 30` in the last run, all `SLOW` timings over the 5 s threshold (appearance concurrent PUT, saved search create storm, DAV, calendar Log). None of that code was touched, and none of the findings were errors. The host load average was 27–33 from other jobs during all three runs. Round 1 needs a rerun on a quiet host before merge. `cargo clean` done.
kayg closed this issue 2026-09-25 20:24:25 +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#109
No description provided.