Skip to content

Anonymous MCP callers can export any topic's CSV to a public URL #11

Description

@Anulo2

Audit commit: ce6b351c. All line links are permalinks pinned to it.

Repro — repeatable, run twice on 2026-09-05 from an anonymous session (no auth_token, no cookie):

  1. POST /mcp initialize — accepted; anonymous session id issued.
  2. tools/call export_csv with only topic="<topic name>" and a note — succeeded; returned download_url of the form https://topic-builder.wikiedu.org/exports/topic-articles-<slug>.csv.
  3. Plain curl GET on that URL, no auth — 200 text/csv (513 bytes, full article list).

The exported corpus is a visibility: private topic owned by this issue's author (owner_username: Anulo2, mine: true, confirmed via authenticated list_topics). The generalization to any topic name is attributable to the code — the mode='read' branch performs no owner check under the deployed writes setting — but the demonstration above is scoped to the author's own private topic. The name/URL stay out of this body — posting them would publish the corpus; the concrete filename is shared out-of-band for the purge below.

Mechanism (each step pinned):

  • export_csv passes the caller's topic straight into _require_topic(..., mode='read') (server.py#L9334, def at L9309).
  • The read branch returns (tid, wiki, None) whenever AUTH_ENFORCEMENT != 'all' — before _can_read is ever called (L345-L347). Under the deployed writes setting, any anonymous caller naming a private topic gets its CSV written and the public URL returned (L9348).

Discovery is not enumeration, and not the weak point either: under writes, anonymous list_topics correctly hides private topics (L1750-L1768) — an anonymous probe returning "No public topics found" is expected behavior, not a mitigation. The vector is any leaked or guessed topic name, and export filenames are deterministic from the slug (csv_export.py#L30-L36), so guessing is cheap.

Secondary observation (single instance, not reproducible on demand): the very first anonymous probe — no topic= argument — also exported, resolving a stale current-topic binding despite never calling start_topic. Four subsequent fresh-session control runs all correctly returned "No active topic" (the explicit error branch at L327-L334), which also rules out a shared per-process ctx.session. This is a one-off consistent with session-state address reuse; root cause tracked in #12, and this issue does not depend on it.

Mutation through the same read gate (code-derived, not exercised): export_csv(enriched=True) passes the identical check, then write_topic_csv backfills missing descriptions via fetch_descriptions_with_fallback and persists them with db.set_descriptions (csv_export.py#L74-L75) — an anonymous caller can mutate a private topic's article rows and spend the host's Wikipedia quota through a tool whose only gate is a read check. Separately, an unknown topic= name silently runs db.create_or_get_topic (server.py#L321-L324) — unauthenticated DB writes through a read-checked tool — and there is no documented way to delete a topic row.

Static layer keeps every export public forever. nginx serves the export dir via alias /opt/topic-builder/exports/ (deploy.sh#L123-L124); the filename is the only secret (a probe GET of a nonexistent name 404s). Files are never expired or deleted — everything ever written stays downloadable even after AUTH_ENFORCEMENT is later set to all, which gates only the tool call and the owner-checked /topics/<slug>/download.csv redirect, never the static location.

Remediation:

  • Enforce read ACL in _require_topic for any tool that materializes data at a public URL, regardless of the writes setting.
  • Unguessable, expiring filenames (random token / signed URL) instead of slug-derived.
  • Serve via internal + X-Accel-Redirect or an authenticated handler; drop the public alias.
  • One-time purge of /opt/topic-builder/exports/ — every file already written is currently public, including one file written twice by today's reproductions (deterministic filename; name shared out-of-band).
  • Expiry/cleanup for export artifacts (none today).
  • Reject unknown topic= names on read-checked tools instead of creating rows; add a topic-delete/admin path.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions