Repository navigation
Render and download generic file attachments - #120
Conversation
Signed-off-by: Zach Marley <[email protected]>
Signed-off-by: Zach Marley <[email protected]>
Signed-off-by: Zach Marley <[email protected]>
Signed-off-by: Zach Marley <[email protected]>
Signed-off-by: Zach Marley <[email protected]>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Changes requested: authenticated generic-file downloads fail
Reviewed head f69df7d9e691594c973e488f1945f138b02932fe against target base 0beb523443557c189e5fcf16a3ac9e8437666690, using merge base 7028b3e5c2671d797be92014ac87be48b3ad9515 for introduced changes. One material finding is attached inline.
The file-card download affordance is wired to a broker that only accepts image/video responses. This leaves the core authenticated generic-file retrieval contract incomplete, even though the card renders correctly. The broker restriction predates this PR, but the new download action depends on changing that boundary.
Reproduction and validation
A review-only Vitest probe exercised the actual connectBrokerTransport().media() URL resolver and relayBrokerPlugin HTTP handler with an ephemeral identity and fixture upstream responses. It verified the outgoing signed auth event; no live private data was accessed.
| Upstream Content-Type | Actual broker response |
|---|---|
application/pdf |
415, Media type rejected |
application/zip |
415, Media type rejected |
text/plain |
415, Media type rejected |
audio/mpeg |
415, Media type rejected |
image/png (control) |
200, exact fixture bytes |
Command: Hermit-managed pnpm exec vitest run dev/pr120-review-probe.test.mjs. All five diagnostic cases passed by asserting the observed results above; this reproduces the defect, not successful generic-file downloads. Tracked production files were unchanged. Existing hosted checks passed (Windows native validation skipped); I did not rerun broad CI suites or perform native/browser download acceptance.
Exit criterion
Provide an authenticated retrieval path for supported generic files, retaining origin/auth restrictions, bounded transfer behavior and safe download response headers. Add broker-level regression coverage that verifies successful status and exact downloaded bytes for a non-image/video response, plus retained rejection/safety coverage. Do not simply remove the type guard and expose sender-controlled active content inline on the app origin. Recheck the PR's live-download claim against the pinned implementation with a real generic response Content-Type and byte equality.
The parsing and presentation/consumer review lanes returned no additional material findings. No approval or merge performed.
| href={source} | ||
| download={attachment.name ?? ""} | ||
| aria-label={`Download ${displayName}`} |
There was a problem hiding this comment.
[P2] Make the authenticated broker support files before offering Download
For a relay-hosted PDF, ZIP, text or audio attachment, MessageRow supplies media(url) here, and connectBrokerTransport resolves it to /api/relay…/media?url=… (transport.ts:466–471). That handler still rejects every response whose Content-Type is neither image/* nor video/* with HTTP 415 (dev/relay-broker.mjs:917–921). Consequently this new download action never retrieves a normally typed generic file, including the relay files that require signed access; external HTTPS opening does not cover that contract.
Reproduced through the actual transport and broker with application/pdf, application/zip, text/plain and audio/mpeg: all return 415 {"error":"Media type rejected"}; the image/png control returns 200 with the fixture bytes. Implement the authenticated generic-download boundary with appropriate origin/auth, size and response-header safeguards, and cover retrieval status plus byte equality at broker level. Merely asserting this anchor's href/download attributes cannot catch the failure.
Signed-off-by: Zach Marley <[email protected]>
Signed-off-by: Zach Marley <[email protected]>
Signed-off-by: Zach Marley <[email protected]>
|
🤖 Replying on behalf of @zrmarley via their AI agent. Confirmed and fixed at Your finding reproduced independently. The manual verification claimed in the PR description was a false positive: because the file card's anchor carries Fix — three tiers, guard retained (not removed):
Adversarial review of the fix then found two smuggling routes, both closed with fail-then-pass regression tests:
Broker regression coverage ( Outstanding, and explicitly not claimed as done: live re-verification of the download with a real generic Content-Type, asserting byte equality and correct file type rather than "a download happened". That is owed by the author before merge, per your exit criterion. Two things noted as follow-ups rather than folded in — flagging in case you'd rather they block:
|
|
🤖 Posted on behalf of @zrmarley via their AI agent. Live verification now complete at
The post-fix download is byte-identical to the original source PDF (same SHA-256, 1,118,803 bytes both sides). The broker log shows One detail worth recording, because it is a trap for anyone verifying attachment work: the second pre-fix attempt looked like a success. The title-link projection added in this PR supplies the real filename to the anchor's Ready for re-review whenever you are. |
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Re-review: previous P2 fixed; no blocking findings
Reviewed head 29820e5685970fd102ad600d96fe3bd3412bb594 against target base 0beb523443557c189e5fcf16a3ac9e8437666690 (merge base 7028b3e5c2671d797be92014ac87be48b3ad9515), concentrating on the two-file repair since f69df7d9e691594c973e488f1945f138b02932fe. The prior parsing/presentation review remains applicable to the unchanged paths. An independent security/source re-review also returned no blocker.
The authenticated generic-download blocker in the previous review is resolved. Generic content now receives application/octet-stream, Content-Disposition: attachment, and nosniff; SVG is excluded from inline responses, and inline MIME values are validated and normalized rather than echoing an untrusted header. Existing target/auth restrictions, byte-budget checks and video streaming behavior remain in place.
Independently verified at this head
bin/pnpm exec vitest run dev/relay-broker-api.test.mjs: 24/24 passed, including video Range/teardown and MIME-smuggling regressions.- 13/13 review-only diagnostic tests passed against unchanged production files: actual
connectBrokerTransport().media()→ production HTTP broker returned 200 and exact fixture bytes for PDF, ZIP, text, audio, HTML and SVG; PNG control remained inline. Signed kind-24242 authorization, wrong-origin/target rejection, malformed Range rejection, and both declared/actual 20 MiB checks were exercised. - Chromium and WebKit each downloaded all six generic/active-content fixtures using the actual server-rendered
FileAttachmentanchor and broker: 12 downloaded files matched their source bytes and expected names. Neither ordinary download sent Range. This is focused browser/transport integration evidence, not a full-app or native acceptance run. Fixtures used an ephemeral identity and fake upstream bytes, not private relay content.
Non-blocking follow-up and limits
The disclosed generic Range limitation is real: a valid Range forwarded upstream can produce a 206 partial body that the non-video path relabels 200 and strips Content-Range from (dev/relay-broker.mjs:898–910,944–945,967–971). I reproduced it. Ordinary file-card downloads in both tested engines did not exercise this path; resumable/explicitly ranged generic retrieval remains unsupported. Keep it as a low-priority follow-up: either reject unsupported generic range responses or implement correct partial-response semantics, without returning a partial file as a complete 200.
The author's live PDF byte-equality report supplies separate live evidence; I did not independently download that private file. The retained 20 MiB limit is unchanged. Current applicable hosted checks pass; Windows native validation is skipped. No native acceptance run, approval, or merge is implied by this COMMENT review.
Fixes BOT-1929 (parent: BOT-1830 Files & media).
Problem
Received attachments were classified only as video vs. non-video (
Attachment.video: boolean), so every non-video, non-image file — PDFs, zips, documents, audio — entered the image render path, mounting a broken<img>("Image unavailable") with no name, type, or retrieval path. Generic files also leaked into the media review viewer's image grid.Change
Contract:
Attachment.video: boolean→kind: "image" | "video" | "file", plus optionalmime,size(validated positive safe-integer from imetasize), andname(decoded URL basename, rejected when it matches the 64-hex relay hash shape)."audio"is reserved for BOT-1934 and slots into one classification switch plus one render branch — no rework.Classification (
fold.ts): case-insensitive mime-driven; legacy no-mime extension fallback; markdown-projectedURLs remain image/video only (unrecognized extensions keep the image default — never dropped, neverfile).New
FileAttachmentcard: PhosphorFileTextIcon/DownloadIconvia the design-system gateway, filename (or mime-derived label), formatted size, and an authenticated download anchor using the same resolvedmedia()proxy source and<a download>pattern asImageReviewStage. Nameless files render a baredownloadattribute so they download rather than navigate. Unresolved source → "File unavailable" card with no anchor.Navigation policy: relay-proxied sources (same-origin
/api/relay…/media) keep the in-appdownloadanchor; sender-supplied externalhttps:sources instead route through the existingonOpenLinkopener capability withtarget="_blank" rel="noreferrer"and nodownloadaffordance — the same patternAttachmentImageuses — so a click can never replace the app webview with sender-controlled content. Anything else falls back to the unavailable card.Untrusted name hardening: sender-controlled names (markdown label or decoded URL basename) are rejected if they contain C0/C1 controls, LRM/RLM, bidi overrides (U+202A–202E) or isolates (U+2066–2069), closing right-to-left-override extension spoofing in both the rendered label and the
downloadfilename. The check runs after percent-decoding, so encoded overrides can't slip through; ordinary accented/CJK/emoji filenames are unaffected.Migration:
MessageRow,MediaAttachment(image branch now explicitlykind === "image"; residual kinds show unavailable status, never an<img>),MediaReviewViewer(image grid excludes files),ThreadPanel, fixtures.Attachment title links projected from message text: old Buzz sends file messages with a markdown link in the text labeled with the real filename, plus the matching imeta tag — previously this rendered as a broken anchor to the raw (auth-required) relay URL next to the card. Links whose
safeMessageUrl-normalized URL exactly matches a parsed imeta attachment URL are now stripped from the rendered text (same bounded-parse/range-strip mechanism as image projection, with overlapping/nested ranges merged), and the link's plain-text label (trimmed, ≤256 chars, URL-shaped and relay-hash-shaped labels rejected) becomes the attachmentname, taking precedence over URL-basename derivation. Non-matching links render exactly as before; a mismatch degrades to a visible duplicate link, never a wrong strip. This also applies to image/video attachments whose imeta URL appears as a text link — intended, matches the old-client format. Long filenames ellipsize in the card; the full name stays inaria-labelanddownload.Filename fallback: when no text link supplies a name and the URL basename is relay-hash-shaped, the card degrades to a mime-derived label ("PDF file") or "File".
Test plan
Colocated Vitest only; no new browser journey (no browser-only behavior beyond RTL coverage — see deferred checks).
fold.test.ts: mime classification incl. uppercase mime,audio/mpeg→file(BOT-1934 interim), size validation incl. overflow (Number.isSafeIntegerguard), hash-basename name rejection,decodeURIComponentfailure fallback, legacy extension fallback, extensionless markdown-image projection regression; link projection: direct/reference/autolink forms, non-matching links untouched, empty/hash-shaped/URL-shaped/over-length labels, duplicate links, mid-sentence stripping, link-only messages, nested image-in-link prose preservation.FileAttachment.test.tsx/MessageRow.test.tsx: named+available, nameless+available (baredownload, proxy href), unavailable (no anchor), label fallbacks (octet-stream/vnd.ms-excel→ "File"), no image markup for file attachments.MediaReviewViewer.test.tsx: files excluded from image grid.icon-labels.test.tsx: strayfilekind inMediaAttachmentrenders no<img>.Local validation:
tsc --noEmit✅ · Biome on changed files ✅ · full Vitest suite ✅ (175 files / 1,837 tests) · pre-commit and pre-push hooks ✅.Design pack consulted
tokens/registry andDESIGN.md(State, Contrast, Type): card styles reuse existingMessages.module.csssibling patterns and registered ramp tokens only; weight via existing--type-weight-medium; focus styles keyboard-only gated (html[data-keyboard-navigation]/:focus-visible); icons viasrc/shared/design-system/icons(repo-pinned Phosphor gateway). No new tokens, sizes, or components introduced.Compatibility
No migration is needed.
persistence.tsstores signed wire events only (re-verified and refolded by the owner after fresh roster authorization), so cached records carryimetatags rather thanAttachmentobjects — no persisted layer ever held the oldvideo: booleanshape.outbox-storage.ts,read-state-storage.ts,view-state.tsandplugins/storage.tscarry no attachments.Authenticated retrieval
The dev broker's
/mediahandler previously rejected any upstream Content-Type that was not image or video with a 415. Because the file card's anchor carriesdownload, the browser saved that error body as a file — an earlier manual check that looked successful had in fact downloaded a 31-bytemedia.jsoncontaining{"error":"Media type rejected"}. Thanks to @wesbillman's review for catching this; the card renders from the imeta tag, which masked the failure.The handler now classifies into three tiers instead of two, keeping the guard rather than removing it:
image/*andvideo/*: unchanged, including video Range/streaming,Accept-Rangesand the 206 budget exemption.Content-Type: application/octet-stream,Content-Disposition: attachment(no filename parameter — the client anchor'sdownloadalready names the file, and omitting it avoids header injection on a sender-controlled string),nosniffretained, buffered under the existingMAX_MEDIA_BYTES, and noAccept-Rangessince the buffered path cannot honour Range.image/svg+xmlis deliberately routed to the download tier, not the inline image tier — SVG is active content and an XSS vector when served inline on the app origin.Two smuggling routes were found and closed during review. Inline tiers are now gated on a strict single-token media-type match (so
image/png, image/svg+xmlcannot claim to be an image — browsers split on commas and honour the last parseable type), and the inline response emits the validated normalized media type rather than echoing the raw upstream header (soimage/png;x, image/svg+xmlcannot smuggle the tail through parameter position). Parameters are stripped as a consequence, includingcodecson video; container-based codec selection is unaffected and the Range/streaming assertions are unchanged.Origin pinning, the signed kind-24242 auth event,
redirect: "error", the upstream timeout, both byte-budget checks and the stream teardown handlers are untouched. The 20 MiB cap is retained for generic files by decision; raising it is a separate call.Review
Three adversarial review rounds plus one general code review; all findings fixed and re-verified. Notable catches: markdown content-loss for extensionless projected URLs, nameless files navigating instead of downloading, case-sensitive MIME matching, nested-range prose corruption, autolink URLs adopted as filenames, and the two hardening items above.
Deferred follow-ups (out of scope here)
A
Rangerequest against a generic file is still forwarded upstream, and a 206 response is then buffered and returned as a 200 with a truncated body (pre-existing shape, also present on the image branch'sContent-Rangepassthrough); narrowingMediaAttachment's prop type to make its invariant compiler-enforced; de-duplicating imeta field parsing betweenparseAttachmentsandimetaAttachmentUrls;formatFileSizerounding (1536→ "2 KB"); amimeLabelallowlist for noisy subtypes ("PLAIN file"); legacy.mov/.m4v/.heicextension classification; aMAX_MARKDOWN_LENGTHgate on the fold parse path; collapsing the two unavailable-card branches inFileAttachment.Deferred checks