chat: parsed-URL link policy, cached link ranges, history byte budget (Phase 4)
CI / check (push) Failing after 3m10s

Phase 4 of docs/chat-hardening-plan.md — URL and rendering resilience.
Closes the chat-body half of S14 (bidi override strip).

- sanitize: new is_safe_web_url shared link policy (url crate, promoted to a
  direct dependency): http/https scheme + non-empty host + no userinfo;
  candidates failing it stay plain text (their whole whitespace run, interior
  not re-scanned). Scheme detection is now ASCII-case-insensitive.
- sanitize: linkify() -> link_ranges()/segments(): validated byte ranges
  computed once, exact-roundtrip slicing, at most CHAT_MSG_MAX_LINKS (8)
  clickable links per message; the rest stays selectable plain text.
- sanitize_chat: strips bidi overrides/isolates (U+202A-202E, U+2066-2069)
  from message bodies while keeping ZWJ/ZWNJ/LRM/RLM (S14 chat-body half).
- app: ChatEntry caches its link ranges (filled in push_chat), so redraws
  slice instead of rescanning/re-validating; only link spans allocate.
- app: chat history now also bounded by 512 KiB total sanitized text
  (CHAT_HISTORY_MAX_TEXT_BYTES) alongside the 300-entry cap; the attachment
  byte cache is deliberately untouched by history eviction (own budgets).
- app: AppMessage::OpenUrl re-checks the same parsed policy (defence in
  depth) instead of prefix checks - non-web schemes can never reach the
  opener even if the handler is invoked directly.

571 lib tests green (+3 net); clippy -D warnings + fmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 04:57:15 -04:00
co-authored by Claude Fable 5
parent 554b613466
commit 1d038be9a0
5 changed files with 399 additions and 165 deletions
+53 -19
View File
@@ -1,15 +1,18 @@
# Chat hardening — ephemeral implementation plan
**Status (2026-07-18):** Phases 13 COMPLETE. Phase 1 = shared text policy in
**Status (2026-07-18):** Phases 14 COMPLETE. Phase 1 = shared text policy in
`src/sanitize.rs`, ceilings enforced at UI input, sign point, and gossip ingress.
Phase 2 = roster-bound authorship (`src/core/chatroster.rs`), replay dedup + rate
limits (`ChatIngressGate` in `src/network/gossip.rs`). Phase 3 = attachment
cache/serve-store budgets, downscaled previews, auto-fetch byte/request budgets
(`src/core/fetchbudget.rs`), exact transfers, bounded local reads. All gates
green each phase. Phases 45 not started. This is a temporary scope contract for
hardening the existing room chat. Update the checkboxes and decision log as work
lands, then delete this file when the work is complete. Do not add link previews
as part of this effort.
(`src/core/fetchbudget.rs`), exact transfers, bounded local reads. Phase 4 =
parsed-URL link policy (`is_safe_web_url`/`link_ranges` in `src/sanitize.rs`,
`url` crate), 8-link cap, cached link ranges in `ChatEntry`, 512 KiB history
text budget, chat-body bidi-override strip (closes S14). All gates green each
phase. Phase 5 not started. This is a temporary scope contract for hardening
the existing room chat. Update the checkboxes and decision log as work lands,
then delete this file when the work is complete. Do not add link previews as
part of this effort.
## Goal
@@ -257,31 +260,34 @@ unbounded memory, bandwidth, decoder, or task pressure.
**Target:** keep clickable links without making malformed/deceptive input or many
small spans an unnecessary UI/launcher surface.
- [ ] Make `url` a direct dependency (already present transitively) and validate
- [x] Make `url` a direct dependency (already present transitively) and validate
link candidates with `url::Url`.
- [ ] A clickable URL must have an `http` or `https` scheme and a valid host.
- [ ] Treat URLs containing username/password syntax as plain text, or require an
- [x] A clickable URL must have an `http` or `https` scheme and a valid host.
- [x] Treat URLs containing username/password syntax as plain text, or require an
explicit confirmation that shows the parsed destination host. Prefer plain text
for the first implementation.
- [ ] Preserve the existing defense-in-depth validation in `AppMessage::OpenUrl`;
- [x] Preserve the existing defense-in-depth validation in `AppMessage::OpenUrl`;
replace prefix checks with the shared parsed-URL policy.
- [ ] Cap clickable candidates at eight per message. Remaining content stays
- [x] Cap clickable candidates at eight per message. Remaining content stays
selectable plain text and must still round-trip exactly.
- [ ] Refactor linkification to return borrowed ranges/offsets or cache link ranges
- [x] Refactor linkification to return borrowed ranges/offsets or cache link ranges
in `ChatEntry`, avoiding allocation and rescanning on every redraw.
- [ ] Bound retained history by total sanitized text bytes as well as 300 entries.
- [x] Bound retained history by total sanitized text bytes as well as 300 entries.
Eviction must keep attachment bookkeeping coherent and should not invalidate an
open Save/Play operation.
- [ ] Do not add metadata fetching, remote images, Markdown, or link previews.
- [x] Do not add metadata fetching, remote images, Markdown, or link previews.
- [x] (Folded in from S14, per the security handoff) Strip bidi
overrides/isolates from the chat BODY in `sanitize_chat`, keeping the other
expressive format characters (ZWJ/ZWNJ/LRM/RLM).
### Phase 4 tests
- [ ] Valid HTTP/HTTPS, malformed host, empty host, mixed case, Unicode path/query,
- [x] Valid HTTP/HTTPS, malformed host, empty host, mixed case, Unicode path/query,
punctuation, credentials/userinfo, and non-web schemes.
- [ ] Eight-link boundary and many-link adversarial input.
- [ ] Segment/range reconstruction exactly reproduces the sanitized message.
- [ ] Entry-count and total-text-budget history eviction.
- [ ] Opener policy cannot launch a non-web scheme even if called directly.
- [x] Eight-link boundary and many-link adversarial input.
- [x] Segment/range reconstruction exactly reproduces the sanitized message.
- [x] Entry-count and total-text-budget history eviction.
- [x] Opener policy cannot launch a non-web scheme even if called directly.
## Phase 5 — Honest local send status
@@ -484,3 +490,31 @@ The plan is complete when:
explicit length compare; short transfers get the explicit
`len == declared_size` check. Music fetches ride `fetch_blob`, so they
inherit exactness for free.
- **2026-07-18 (Phase 4):** The S14 chat-body half (bidi strip) landed here per
the security handoff: `sanitize_chat` strips ONLY bidi overrides/isolates
(U+202A202E, U+20662069) — the characters that can visually reorder a
rendered line — while ZWJ/ZWNJ (emoji sequences, joining scripts) and the
LRM/RLM direction *marks* (which cannot reorder) are kept. Labels/filenames
keep the stricter full-format-strip.
- **2026-07-18 (Phase 4):** A link's href is the exact displayed slice of the
message — validation is parse-only, no normalization on open — so what the
user sees IS the argv the opener receives. Consequence: WHATWG slash
collapsing means `http:///path` parses to host `path` (as in browsers) and is
accepted; the empty-host rejects are `http://` and friends that fail parsing.
- **2026-07-18 (Phase 4):** URLs with userinfo syntax went the plan-preferred
plain-text route (no confirmation dialog). A candidate that fails the policy
leaves its WHOLE whitespace-delimited run as plain text without re-scanning
the interior — `http://a@http://b.com` yields zero links, by design.
- **2026-07-18 (Phase 4):** Scheme detection became ASCII-case-insensitive
(`Http://…` from sentence auto-capitalization now linkifies); the policy
check is unaffected since `url` normalizes scheme/host case during parsing.
- **2026-07-18 (Phase 4):** Cached ranges in `ChatEntry.links`, filled inside
`push_chat` (the single history choke point), were chosen over
borrowed-return-per-redraw: redraws now slice cached char-boundary ranges,
and only link spans allocate (their href String).
- **2026-07-18 (Phase 4):** History byte-budget eviction (512 KiB, alongside
the 300-entry cap) deliberately does NOT touch the attachment byte cache:
that cache is bounded by its own Phase 3 budgets, and leaving it alone means
an open Save/Play on an evicted line keeps its bytes-in-hand (the save
dialog falls back to the generic "download" name). The just-pushed entry is
never evicted; a single message's 8 KiB ceiling cannot exceed the budget.