Skip to content

feat(mcp): attachment reach — attachments[] + download_asset tool#15

Merged
hamr0 merged 4 commits into
masterfrom
feat/attachments-download-asset
Jun 16, 2026
Merged

feat(mcp): attachment reach — attachments[] + download_asset tool#15
hamr0 merged 4 commits into
masterfrom
feat/attachments-download-asset

Conversation

@hamr0

@hamr0 hamr0 commented Jun 16, 2026

Copy link
Copy Markdown
Owner

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

  1. attachments[] on every normalized Message{type, file_name, mime_type, src_url, size, is_voice_note}, [] when none. A MEDIA/FILE message previously arrived as text: "[MEDIA]" with no way to reach the file. Pure passthrough of the raw Beeper attachments[] (raw shape id,type,mimeType,fileName,fileSize,srcURL — confirmed against a live account).
  2. New download_asset tool (12 tools now) — returns attachment bytes as base64, referenced by src_url or chat_id + message_id (+ index). Proxies GET /v1/assets/serve, so a deployment publishing only :23375 (not the raw :23373) can still read files. Capped at BEEPERBOX_MAX_ASSET_BYTES (default 8 MiB; real serve sends Content-Length, so the pre-check rejects oversize before buffering); per-call timeout BEEPERBOX_ASSET_TIMEOUT_MS (30 s).

Note: the brief assumed bytes came from /v1/assets/download, but that endpoint returns a container-local file:// path, not bytes. The bytes endpoint is /v1/assets/serve.

Security — /security + /code-review (high), every claim live-validated

Headline 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 model serve's own guard. Tested live, real /v1/assets/serve independently blocks it403 for a file:// outside its media dir, 400 for a non-mxc/localmxc/file scheme. So download_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): allow mxc:///localmxc://; allow file:// only when it resolves inside the media cache (BEEPERBOX_ASSET_FILE_ROOT); refuse everything else before the fetch, both for caller src_url and a message-resolved src_url. Code review surfaced two guard-soundness gaps — neither exploitable against real serve (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://localhost normalizes away, still allowed).
  • double-encoded %252e — survived one decode as literal %2e past normalize() → now refuse residual %2e/%2f after a single decode. Single-decode is deliberate (matches serve; avoids over-rejecting a legit filename with a literal %, which a decode-loop throws on).
  • Plus a per-call serve timeout (hang / slow-drip bound).

Tests

  • Unit: 39 greennormalizeAttachments + 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 stub serve: 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 the mcp-test unit job (CI green).
  • Live validation against a real Beeper account: a real 706 KB PDF downloads via both reference paths with exact byte length; /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

hamr0 and others added 4 commits June 16, 2026 10:56
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>
@hamr0 hamr0 merged commit 12571b8 into master Jun 16, 2026
2 checks passed
@hamr0 hamr0 deleted the feat/attachments-download-asset branch June 16, 2026 09:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant