Skip to content

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

Open

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

  1. Anulo2 commented on Sep 5, 2026

    @Anulo2
    Author

    Related input-validation gap in the same export path: topic names are not sanitized for path use before slugification. A name containing / yields a slug with /, so os.path.join(out_dir, filename) points at a subdirectory that write_topic_csv never creates (it only makedirs(out_dir)), causing the export to fail. Control characters and other path-sensitive characters are likewise never rejected.

    I deliberately am not claiming a directory-traversal exploit — I have not proven an arbitrary write — but note there is no defense-in-depth to rely on: the two slugifiers are byte-identical (csv_export.topic_slug and db._slugify, db.py#L324-L325) — each only lowercases and removes spaces, ', " — and create_or_get_topic performs no other validation, so nothing in either layer strips / or other path-sensitive characters. That makes the absence of validation at the create/export boundary the real gap, so it needs an explicit check at topic creation or a conservative allowlist in export path construction.

    Correction: an earlier version of this comment described db.slugify as "more aggressive" than topic_slug. Wrong — they are the same four transforms, verified at the pinned commit.

  2. Anulo2 commented on Sep 5, 2026

    @Anulo2
    Author

    Enforcement level confirmed empirically, not just from CLAUDE.md prose:

    • Anonymous list_topics returned "No public topics found" — rules out AUTH_ENFORCEMENT=none, whose legacy branch returns every topic (server.py:1750-1762).
    • Anonymous export_csv succeeded — rules out all, whose mode='read' branch would have refused on a private topic (server.py:345-349).

    So production is running exactly writes, the setting the body assumes.

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