feat(mcp): attachment reach — attachments[] + download_asset tool#15
Merged
Conversation
Surfaces message attachments and adds the MCP-only path to their bytes,
so a remote/MCP-only deployment (publishing only :23375) can read files
that previously required the raw Beeper API on :23373.
- normalizeMessage now carries attachments[] ({type, file_name,
mime_type, src_url, size, is_voice_note}); [] when none. A MEDIA
message previously arrived as "[MEDIA]" with no way to reach the file.
- New download_asset tool (12 tools now): returns attachment bytes as
base64, by src_url or chat_id+message_id(+index). Proxies
GET /v1/assets/serve; capped at BEEPERBOX_MAX_ASSET_BYTES (default
8 MiB) via Content-Length pre-check + buffered post-check.
Security:
- Allowlist mxc:// / localmxc:// and refuse file:// (arbitrary local
file read / secret exfil) and http(s):// (SSRF) BEFORE the fetch,
covering both the caller src_url and a message-resolved src_url.
- Per-call serve timeout (BEEPERBOX_ASSET_TIMEOUT_MS, default 30s)
bounds a hung/slow-drip source.
Tests: +3 unit (normalizeAttachments) +3 unit (scheme guard);
new scripts/asset-serve-check.js exercises the I/O path against a stub
Beeper API (binary integrity, both cap paths, error branches, scheme
refusal) and is wired into the mcp-test unit job. CI has no live
account, so the serve proxy stays validate-against-real-account before
production media flows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…validated) Live testing against a real Beeper account revealed two things the stub could not: 1. Real cached attachment src_urls are file:///root/.config/BeeperTexts/ media/... (not mxc://). The previous blanket file:// rejection broke the message-resolution path and every locally-cached attachment. 2. /v1/assets/serve sends no Content-Type for these (content_type is null; callers already have mime_type from the attachment, so non-fatal). The guard now allows mxc:// / localmxc://, allows file:// ONLY when it resolves inside the media cache (BEEPERBOX_ASSET_FILE_ROOT, default /root/.config/BeeperTexts/media/), and refuses everything else. The path is percent-decoded and path.normalize()'d before the prefix check — new URL() does NOT collapse ../, so a literal-prefix check would let media/../../../etc/passwd and %2f-encoded variants escape (verified). Validated live: real PDF downloads via both src_url and chat_id+message_id (exact bytes); /etc/passwd, the sent-ledger, and ../ traversal all blocked. Unit tests +2 (cache-allow + traversal-refuse); asset-serve-check + the stub cover the same. 37 unit tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ding; honest defense-in-depth framing Code review (high) + live validation against real Beeper: - Real /v1/assets/serve has its OWN guards: 403 for a file:// outside its media dir, 400 for a non-mxc/localmxc/file scheme. So the original "arbitrary local file read / SSRF" was NOT a confirmed end-to-end exploit against real Beeper — it was confirmed only against the stub, which does not model serve's guard. Reframed as defense-in-depth in the CHANGELOG and comments (clear early error + survives an upstream guard regressing), not a patched live vuln. Two real guard-soundness gaps the reviewer found, both fixed (neither was exploitable against real serve — 400/404 — but the guard should validate what it forwards): - file:// authority/host (file://attacker/<media-path>) passed the pathname check but the host was forwarded — now reject any non-empty host (file://localhost normalizes the host away, still allowed). - double-encoded %252e survived one decode as literal %2e past normalize(); now refuse any residual %2e/%2f after a single decode. Single-decode is deliberate — it matches serve's own single-decode and avoids over-rejecting a legit filename containing a literal % (which a decode loop would throw on). Unit +4 (host allow/refuse, double-encode); asset-serve-check +2; all re-validated live (real cached attachment still downloads; host-bearing variant of the real url refused). 39 unit tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…odel) Reflect the hardened guard in the two docs that were silent on it: - beeperbox.context.md download_asset: add a Security note (mxc/localmxc or file:// inside the media cache; other path/scheme/host/encoded-../ refused). - PRD §7 threat model: add an "Unreleased hardening" bullet, framed as defense-in-depth over Beeper serve's own 403/400 — not a patched live exploit. GUIDE/README already accurate (12 tools, "arbitrary file:// refused"); CHANGELOG covered in the harden commit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Gives an MCP-only / remote beeperbox the ability to reach message attachments — the gap blocking multis's "send a PDF → index it" flow.
Changes
attachments[]on every normalizedMessage—{type, file_name, mime_type, src_url, size, is_voice_note},[]when none. AMEDIA/FILEmessage previously arrived astext: "[MEDIA]"with no way to reach the file. Pure passthrough of the raw Beeperattachments[](raw shapeid,type,mimeType,fileName,fileSize,srcURL— confirmed against a live account).download_assettool (12 tools now) — returns attachment bytes as base64, referenced bysrc_urlorchat_id+message_id(+index). ProxiesGET /v1/assets/serve, so a deployment publishing only:23375(not the raw:23373) can still read files. Capped atBEEPERBOX_MAX_ASSET_BYTES(default 8 MiB; realservesendsContent-Length, so the pre-check rejects oversize before buffering); per-call timeoutBEEPERBOX_ASSET_TIMEOUT_MS(30 s).Security —
/security+/code-review(high), every claim live-validatedHeadline correction, in the interest of honesty: the original "arbitrary local file read / SSRF via
src_url" was not a confirmed end-to-end exploit against real Beeper. I first "confirmed" it against the stub, which doesn't modelserve's own guard. Tested live, real/v1/assets/serveindependently blocks it —403for afile://outside its media dir,400for a non-mxc/localmxc/filescheme. Sodownload_asset's guard is defense-in-depth (a second, in-repo, clear-error boundary that survives an upstream guard regressing), not a patched live vuln.The guard (
assertServableSrcUrl): allowmxc:///localmxc://; allowfile://only when it resolves inside the media cache (BEEPERBOX_ASSET_FILE_ROOT); refuse everything else before the fetch, both for callersrc_urland a message-resolvedsrc_url. Code review surfaced two guard-soundness gaps — neither exploitable against realserve(400/404), both fixed because the guard should validate what it forwards:file://host/authority (file://attacker/<media-path>) — pathname passed but host was forwarded → now reject any non-empty host (file://localhostnormalizes away, still allowed).%252e— survived one decode as literal%2epastnormalize()→ now refuse residual%2e/%2fafter a single decode. Single-decode is deliberate (matchesserve; avoids over-rejecting a legit filename with a literal%, which a decode-loop throws on).Tests
normalizeAttachments+assertServableSrcUrl(cache-allow,/etc/passwd/ledger refuse, raw/single/double-encoded../refuse, host refuse, SSRF refuse).scripts/asset-serve-check.js— real MCP server vs a stubserve: exact-byte base64 integrity, both cap paths, error branches, every scheme/traversal/host refusal. Negatives were run to prove the harness can fail. Wired into themcp-testunit job (CI green)./etc/passwd, the sent-ledger,../, double-encoded, and host-bearing variants all blocked; the hardened guard still serves the real cached attachment.🤖 Generated with Claude Code