chat: parsed-URL link policy, cached link ranges, history byte budget (Phase 4)
CI / check (push) Failing after 3m10s
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:
+53
-19
@@ -1,15 +1,18 @@
|
||||
# Chat hardening — ephemeral implementation plan
|
||||
|
||||
**Status (2026-07-18):** Phases 1–3 COMPLETE. Phase 1 = shared text policy in
|
||||
**Status (2026-07-18):** Phases 1–4 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 4–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.
|
||||
(`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+202A–202E, U+2066–2069) — 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.
|
||||
|
||||
Reference in New Issue
Block a user