feat(bridges): foreign-avatar proxy (LC-78-AVATAR-PROXY, on by default) #245
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/lc-78-avatar-proxy"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
LC-78-AVATAR-PROXY closes the v1 cliff where bridge messages rendered as initials. The server now fetches each foreign avatar URL exactly once, caches the bytes on disk, and serves them from a same-origin proxy URL. Viewer browsers never hit foreign homeservers; the foreign URL never appears in rendered HTML.
Stacked on #243 (LC-78 v1). Branch was cut from
feat/lc-78-bridge-registration-surface; rebase onto main once #243 lands.Behavior change on upgrade (read first)
What ships
migrations/chat/0056_bridge_avatar_proxies.sql): single table keyed by sha256 of the canonical foreign URL, with(hash, foreign_url, content_type, byte_size, fetched_at, last_seen_at, fetch_status, failure_reason). Disk files live in{data_dir}/bridge-avatars/{hash}, no extension (Content-Type comes from the row).server/src/bridge_avatar.rs): reqwest GET with 5s timeout, 1 MiB byte cap (streamed reject mid-stream), magic-byte sniff viainfer::get_from_path(Content-Type header is foreign-controlled and ignored), re-encode through the uploads pipeline (strips EXIF / XMP / IPTC / PNG text chunks; trust-posture parity with user uploads), atomic write via temp + rename.spawn_blockingisolates the sync decoder + any panic.POST /api/v1/bridges/{id}/messagesacceptsforeign_avatar(was a v1 400-reject), validates URL shape + scheme + length, SSRF re-resolves at submit time, upserts the cache row, fires the fire-and-forget fetch task. Pre-flight failures (bad URL, private host, too-long URL, gate off) are fail-loud 400s.GET /media/bridge-avatar-proxy/{hash}is AuthUser-gated. Threat-model framing (per the plan's sharpening #2): the gate prevents anonymous fetches of leaked hashes and leaves an audit trail; it does NOT enforce room-of-origin scoping (that comes from the rendering surface controlling who sees the hash in the first place). Serves bytes withContent-Typefrom the row +Cache-Control: public, max-age=31536000, immutable(content-addressed, safe to long-cache).<img src=/media/bridge-avatar-proxy/{hash}>withdata-fallback+onerrorswap to initials. A pending / failed fetch (proxy 404) degrades gracefully without a page refresh.last_seen_atis older than 30 days AND no livemessages.bridge_foreign_avatarreferences them get deleted (row + disk file together). Separate sweep markspendingrows older than 10 minutes asfailed(crash recovery from a process restart mid-fetch).LETS_CHAT_BRIDGE_AVATAR_PROXY_ENABLEDenv var. Default true. Setting tofalse/0/no/offrestores v1's reject posture (POST 400, GET 404 every hash with no fingerprinting between "feature off" and "hash unknown").Pre-execution audits (per plan)
LC-152 SSRF audit (sharpening #1).
ssrf::host_resolves_publicdoes a real DNS re-resolve viatokio::net::lookup_host, rejects dual-record[public, private]answers, and runs at both submit time (the new endpoint validates here too) and fetch time (the fetch module). Residual TOCTOU window between this check and reqwest's own DNS call exists and is shared with the LC-75 outgoing-webhook delivery path; closing it requires a custom reqwest DNS resolver that pins the resolved IP. Not in scope here; flagged as a follow-up that affects both LC-75 and LC-78 deliveries.Image-decoder spike (sharpening #4).
server/tests/spike_image_decoder_hostile_corpus.rsruns 12 hostile inputs throughimage 0.25's decode path wrapped incatch_unwind: empty bytes, random bytes, format-signature-only for PNG/JPEG/GIF/WebP, format-signature-plus-garbage, truncated-after-IHDR, pixel-bomb dimension overflow (65535x65535), and 1 MiB of random bytes. All 12 pass: no panics at the 1 MiB cap. Production fetch wraps the decode inspawn_blockingfor belt-and-suspenders panic isolation against a future image-crate bump that might regress.Threat-model alignment
The plan's sharpening #2 reframed the auth gate honestly: it prevents anonymous fetches of leaked hashes and adds an audit trail, but does NOT enforce room-of-origin scoping. That framing is now in the route module's doc comment and reflected in
docs/protocol-bridges.md.Sharpening #3 (default-on flip is a behavior change) is named prominently at the top of this PR description and in the protocol-bridges doc.
Test plan
db_bridge_avatar_proxies(upsert idempotency, mark_ok / mark_failed, sweep unreferenced, sweep pending orphans).bridge_avatar_fetch(local-receiver happy path with bytes round-trip through re-encode, HTTP 404 -> failed, Content-Type lie -> failed via magic-byte sniff, 2 MiB payload -> failed at byte cap, canonical_hash determinism).routes_api_bridge_messages(flipped + extended; existing v1 reject test renamedbridge_post_with_invalid_foreign_avatar_url_is_400+ newbridge_post_with_private_resolving_foreign_avatar_is_400for SSRF rejection + newbridge_post_with_foreign_avatar_stores_hash_v2for the happy path).routes_media_bridge_avatar_proxy(anonymous denied, unknown hash 404, pending row 404, malformed hash 404, ok row serves bytes withContent-Type+ immutableCache-Control).routes_media_bridge_avatar_proxy_gate_off(separate binary so the process-global env var does not race other tests).just testclean (120 binaries pass, 0 failed).just test-saasclean (111 binaries pass, 0 failed; standalone-gated tests correctly skipped).Deferred (and why)
last_seen_at; the bytes don't change. Adding a refresh button is a future LC-78-AVATAR-REFRESH.failedis terminal. Render falls back to initials forever. Avoids repeated outbound traffic to dead homeservers; an operator who wants to retry clears the row manually.<img>retry on a subsequent navigation) shows the avatar. Adding a WS re-swap is polish; nil functional improvement.crate::http_clienthelper that pins the resolved IP through a custom reqwest DNS resolver.finalize_email_inbox_message_senddoesn't enqueue LC-75 events). Bridges that subscribe via LC-75 still don't see email-authored messages. Tracked separately.Verifying as the operator
/admin/bridges-- existing bridges keep working with no avatar (initials).foreign_avatarinPOST /api/v1/bridges/{id}/messages. Existing messages don't backfill; new messages start showing avatars./data/bridge-avatars/for files. Pending rows are 0-byte before the first fetch; size grows after.LETS_CHAT_BRIDGE_AVATAR_PROXY_ENABLED=falsein the env, no restart needed.