fix!: authenticate MCP, send no email, no caller SQL or error leaks (v0.3.0) #14

Merged
jlxq0 merged 7 commits from fix/t_9f9e27bd-audit into master 2026-10-24 03:35:10 +00:00
Owner

Fleet task t_9f9e27bd. Julian asked for this directly; it remediates read-only audit t_044e460f and the security corrections he authorized during review. Classification:

  • Fork-local: auth, the send gate and the HTTP transport.
  • Upstreamable as-is: the subscriber-write repairs, the path guard, removing caller SQL, and error sanitising.

What was wrong (v0.2.2, live)

  • Open endpoint: the public endpoint served every tool to anonymous callers. The server credential holds campaigns:send on the Heartbeat lists. Clark's route containment (t_0fd0bdf4) is in place. /mcp/ stays closed until Clark has deployed this release and proven an authorized read.
  • Broken subscriber writes: measured on a throwaway Listmonk v6.1.0 with a mail sink.
    • update_subscriber was always rejected by Listmonk.
    • change_subscriber_status was always rejected too.
    • The request update_subscriber sent would have deleted every subscription.
    • patch_subscriber changed nothing and re-sent opt-in mail.
  • Leaks: a tool error returned its Python traceback. get_subscriber_activity crashed for every subscriber.
  • Advisories: 15 locked packages had advisories.

Change

  • Auth: every /mcp/ request needs Authorization: Bearer, checked against LISTMONK_MCP_AUTH_TOKEN_SHA256 before the session manager. The HTTP transport refuses to start without it, and there is no unauthenticated mode. A session id is not a credential. /health is open.
  • Host check: LISTMONK_MCP_ALLOWED_HOSTS is required, for mcp 1.30's DNS-rebinding protection; without it every request gets 421. The path stays /mcp/.
  • No email, and no enabled path: these are refused before any Listmonk request:
    • send, schedule, and status running/scheduled (normalised first);
    • test, transactional and opt-in mail;
    • SMTP test, and import start and stop.
    • Subscriber writes carry preconfirm and no lists, so Listmonk's opt-in hook never runs. Membership changes only through PUT /api/subscribers/lists.
  • No caller SQL: the three *_by_query tools are removed. The email lookup uses search instead of building an expression.
  • No log passthrough: get_logs and get_import_logs are removed. Listmonk 5xx and database error text is withheld (status plus a reference only).
  • Path guard: every request path is checked per segment; update_setting("../campaigns/N/status") had sent a campaign. Each argument that lands in a path is also allowlisted.
  • Smaller fixes:
    • no tracebacks in tool results;
    • activity parsing fixed;
    • rename_media refuses (Listmonk has no endpoint for it);
    • the server reports its own version.
  • Dependencies: mcp>=1.30,<2, starlette>=1.3.1, and targeted upgrades. pip-audit finds nothing.
  • Tools: 72 → 67.
  • Release: 0.3.0, with three env changes: the token digest and allowed hosts are now required, and nothing sends email.

Evidence

  • Checks: 222 tests, ruff, mypy, buildcache guard and uv lock --check all pass.
  • Red/green: every new test is red on master, or on the review commit whose bypass it covers.
  • Measured on the throwaway Listmonk v6.1.0 and mail sink:
    • every email path sends 0 mails, and the campaign stays draft;
    • the update_setting traversal sent 15 mails before the fix and 0 after;
    • the SQL disclosure is gone;
    • body-replace keeps attachments.
  • Local image at this head, real MCP client:
    • no digest: refuses to start;
    • anonymous, wrong-token and reused-session requests: 401;
    • authorized: initialize, 67 tools, read OK, and send_campaign and change_campaign_status(running) are refused;
    • the token never appears in the logs.
  • Independent Codex review:
    • round 1: REWORK (path traversal, empty lists, dev mode, stop_import), fixed;
    • round 2: REWORK (race), fixed by a hook-free design;
    • round 3: REWORK (caller SQL), fixed under Julian's explicit correction;
    • fresh full-diff review: REWORK (log passthrough), fixed;
    • fresh full-diff review of the head: APPROVE.

For Clark (t_0fd0bdf4)

The contract is on the card:

  • LISTMONK_MCP_AUTH_TOKEN_SHA256 (ExternalSecret sha256sum) and LISTMONK_MCP_ALLOWED_HOSTS=listmonk-mcp.hanso.group.
  • Honoka's listmonk and listmonk_guarded entries need Authorization: Bearer ${MCP_LISTMONK_API_KEY}.
  • Drop the removed tools from her include lists: the three *_by_query, get_logs and get_import_logs. The send tools in listmonk_guarded will only refuse.
  • Removing campaigns:send and subscribers:sql_query from listmonk-mcp-honoka-v2 is still recommended. This server needs neither.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MjY9WoGT5SGnSCrYDVdiWJ

Fleet task **t_9f9e27bd**. Julian asked for this directly; it remediates read-only audit t_044e460f and the security corrections he authorized during review. **Classification:** - **Fork-local:** auth, the send gate and the HTTP transport. - **Upstreamable as-is:** the subscriber-write repairs, the path guard, removing caller SQL, and error sanitising. ## What was wrong (v0.2.2, live) - **Open endpoint:** the public endpoint served every tool to anonymous callers. The server credential holds `campaigns:send` on the Heartbeat lists. Clark's route containment (t_0fd0bdf4) is in place. **`/mcp/` stays closed until Clark has deployed this release and proven an authorized read.** - **Broken subscriber writes:** measured on a throwaway Listmonk v6.1.0 with a mail sink. - `update_subscriber` was always rejected by Listmonk. - `change_subscriber_status` was always rejected too. - The request `update_subscriber` sent would have deleted every subscription. - `patch_subscriber` changed nothing and re-sent opt-in mail. - **Leaks:** a tool error returned its Python traceback. `get_subscriber_activity` crashed for every subscriber. - **Advisories:** 15 locked packages had advisories. ## Change - **Auth:** every `/mcp/` request needs `Authorization: Bearer`, checked against `LISTMONK_MCP_AUTH_TOKEN_SHA256` before the session manager. The HTTP transport refuses to start without it, and there is no unauthenticated mode. A session id is not a credential. `/health` is open. - **Host check:** `LISTMONK_MCP_ALLOWED_HOSTS` is required, for mcp 1.30's DNS-rebinding protection; without it every request gets 421. The path stays `/mcp/`. - **No email, and no enabled path:** these are refused before any Listmonk request: - send, schedule, and status `running`/`scheduled` (normalised first); - test, transactional and opt-in mail; - SMTP test, and import start and stop. - Subscriber writes carry `preconfirm` and no lists, so Listmonk's opt-in hook never runs. Membership changes only through `PUT /api/subscribers/lists`. - **No caller SQL:** the three `*_by_query` tools are removed. The email lookup uses `search` instead of building an expression. - **No log passthrough:** `get_logs` and `get_import_logs` are removed. Listmonk 5xx and database error text is withheld (status plus a reference only). - **Path guard:** every request path is checked per segment; `update_setting("../campaigns/N/status")` had sent a campaign. Each argument that lands in a path is also allowlisted. - **Smaller fixes:** - no tracebacks in tool results; - activity parsing fixed; - `rename_media` refuses (Listmonk has no endpoint for it); - the server reports its own version. - **Dependencies:** `mcp>=1.30,<2`, `starlette>=1.3.1`, and targeted upgrades. pip-audit finds nothing. - **Tools:** 72 → 67. - **Release:** 0.3.0, with three env changes: the token digest and allowed hosts are now required, and nothing sends email. ## Evidence - **Checks:** 222 tests, ruff, mypy, buildcache guard and `uv lock --check` all pass. - **Red/green:** every new test is red on master, or on the review commit whose bypass it covers. - **Measured on the throwaway Listmonk v6.1.0 and mail sink:** - every email path sends 0 mails, and the campaign stays `draft`; - the `update_setting` traversal sent 15 mails before the fix and 0 after; - the SQL disclosure is gone; - body-replace keeps attachments. - **Local image at this head, real MCP client:** - no digest: refuses to start; - anonymous, wrong-token and reused-session requests: 401; - authorized: initialize, 67 tools, read OK, and `send_campaign` and `change_campaign_status(running)` are refused; - the token never appears in the logs. - **Independent Codex review:** - round 1: REWORK (path traversal, empty lists, dev mode, `stop_import`), fixed; - round 2: REWORK (race), fixed by a hook-free design; - round 3: REWORK (caller SQL), fixed under Julian's explicit correction; - fresh full-diff review: REWORK (log passthrough), fixed; - fresh full-diff review of the head: **APPROVE**. ## For Clark (t_0fd0bdf4) The contract is on the card: - `LISTMONK_MCP_AUTH_TOKEN_SHA256` (ExternalSecret `sha256sum`) and `LISTMONK_MCP_ALLOWED_HOSTS=listmonk-mcp.hanso.group`. - Honoka's `listmonk` and `listmonk_guarded` entries need `Authorization: Bearer ${MCP_LISTMONK_API_KEY}`. - Drop the removed tools from her include lists: the three `*_by_query`, `get_logs` and `get_import_logs`. The send tools in `listmonk_guarded` will only refuse. - Removing `campaigns:send` and `subscribers:sql_query` from `listmonk-mcp-honoka-v2` is still recommended. This server needs neither. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01MjY9WoGT5SGnSCrYDVdiWJ
Remediates the read-only audit (t_044e460f): the public endpoint served
every tool to anonymous callers with a credential holding campaigns:send.

- Bearer auth on every /mcp/ request, checked before the session manager
  (auth.py). The server holds SHA-256 digests only; HTTP refuses to start
  without one. A session id is not a credential. /health stays open.
- Send gate with no enabled path (send_gate.py): send, schedule, status
  running/scheduled (normalised first), test, transactional, opt-in, SMTP
  test, import, and subscriber writes that would mail an opt-in are refused
  before any Listmonk request.
- Subscriber writes, measured on a throwaway Listmonk v6.1.0: PUT without
  lists deleted every subscription and was rejected without email;
  patch_subscriber changed nothing and re-sent opt-in mail. Now PATCH with
  preconfirm when no lists are sent, the manage and blocklist endpoints, and
  a read-before-write refusal for unconfirmed double opt-in lists.
- No traceback in tool results; get_subscriber_activity parses Listmonk's
  object; rename_media refuses; serverInfo reports the package version.
- Dependency advisories: mcp >=1.30,<2, starlette >=1.3.1 and targeted
  upgrades; pip-audit clean. Endpoint kept at /mcp/ (mcp 1.30 serves a
  Route) and LISTMONK_MCP_ALLOWED_HOSTS for its DNS-rebinding check.
- Release 0.3.0. Docs: AGENTS.md pitfalls, README auth and sending,
  HARDGATE_DESIGN.md superseded.

BREAKING CHANGE: the streamable-HTTP transport requires
LISTMONK_MCP_AUTH_TOKEN_SHA256 and LISTMONK_MCP_ALLOWED_HOSTS, and no tool
sends email.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjY9WoGT5SGnSCrYDVdiWJ
- update_setting interpolated `key` into the URL: "../campaigns/N/status"
  started a campaign send past the gate (15 mails on the harness). Every
  request path is now checked segment by segment before it is sent, and
  each path argument is allowlisted.
- update_subscriber(lists=[]) skipped the opt-in check while Listmonk's hook
  selects every subscription for an empty filter; empty lists now send
  preconfirm.
- Remove LISTMONK_MCP_ALLOW_UNAUTHENTICATED: nothing tied it to loopback.
- Gate stop_import: a stopped import still sends the admin notification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjY9WoGT5SGnSCrYDVdiWJ
Review round 2: the read-before-write opt-in check raced a list switching to
double opt-in between the read and the write. Every subscriber POST/PATCH
now carries preconfirm_subscriptions: true and no lists, so Listmonk's
opt-in hook never runs and no subscription is created or confirmed by it;
membership changes only through PUT /api/subscribers/lists with an explicit
status. The read-based check is gone. Measured on a throwaway Listmonk
v6.1.0: add to and re-add a double opt-in list, clear lists, 0 mails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjY9WoGT5SGnSCrYDVdiWJ
Review round 3: the *_by_query tools forwarded caller SQL that Listmonk
dry-runs without a table allowlist, and its error text came back verbatim.
These tests fail on 016a9a9 (11 of 12; the control passes) and record the
root cause in AGENTS.md. The fix follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjY9WoGT5SGnSCrYDVdiWJ
- Remove delete_subscribers_by_query, blocklist_subscribers_by_query and
  manage_subscriber_lists_by_query (tools and client methods), and the
  query argument of get_subscribers. No tool takes a SQL expression.
- get_subscriber_by_email uses Listmonk's search and matches the address
  here, instead of interpolating it into an expression.
- A Listmonk 5xx, or any message with database error text, becomes
  "Listmonk returned HTTP <status> (ref <id>)"; the text is neither returned
  nor logged.

Turns the regression in 7757771 green. On the throwaway Listmonk the former
disclosure is gone, email lookup works, and every email path still sends 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjY9WoGT5SGnSCrYDVdiWJ
Fresh review of 3b26cd4: get_logs and get_import_logs return Listmonk's log
buffer under HTTP 200, past the error filter. Fails on 3b26cd4; fix follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjY9WoGT5SGnSCrYDVdiWJ
fix: drop the Listmonk log passthrough tools
All checks were successful
CI / python (pull_request) Successful in 27s
CI / docker (pull_request) Successful in 34s
dd8fdef971
get_logs and get_import_logs returned Listmonk's log buffer under HTTP 200,
past the error filter, with whatever Listmonk had logged, database errors
included. Pattern-filtering free-form logs is not a boundary; both tools
and their client methods are removed. Turns 45d9e27 green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjY9WoGT5SGnSCrYDVdiWJ
jlxq0 merged commit eefdedd885 into master 2026-10-24 03:35:10 +00:00
Sign in to join this conversation.
No description provided.