Repository navigation
Anonymous MCP callers can export any topic's CSV to a public URL #11
Description
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):
POST /mcpinitialize— accepted; anonymous session id issued.tools/call export_csvwith onlytopic="<topic name>"and a note — succeeded; returneddownload_urlof the formhttps://topic-builder.wikiedu.org/exports/topic-articles-<slug>.csv.- Plain
curlGET 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_csvpasses the caller'stopicstraight into_require_topic(..., mode='read')(server.py#L9334, def at L9309).- The read branch returns
(tid, wiki, None)wheneverAUTH_ENFORCEMENT != 'all'— before_can_readis ever called (L345-L347). Under the deployedwritessetting, 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_topicfor any tool that materializes data at a public URL, regardless of thewritessetting. - Unguessable, expiring filenames (random token / signed URL) instead of slug-derived.
- Serve via
internal+X-Accel-Redirector an authenticated handler; drop the publicalias. - 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
Anulo2 commentedon Sep 5, 2026
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/, soos.path.join(out_dir, filename)points at a subdirectory thatwrite_topic_csvnever creates (it onlymakedirs(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_sluganddb._slugify, db.py#L324-L325) — each only lowercases and removes spaces,',"— andcreate_or_get_topicperforms 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.slugifyas "more aggressive" thantopic_slug. Wrong — they are the same four transforms, verified at the pinned commit.Anulo2 commentedon Sep 5, 2026
Enforcement level confirmed empirically, not just from CLAUDE.md prose:
list_topicsreturned "No public topics found" — rules outAUTH_ENFORCEMENT=none, whose legacy branch returns every topic (server.py:1750-1762).export_csvsucceeded — rules outall, whosemode='read'branch would have refused on a private topic (server.py:345-349).So production is running exactly
writes, the setting the body assumes.