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 /mcp initialize — accepted; anonymous session id issued.
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.
- 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.
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.curlGET on that URL, no auth —200 text/csv(513 bytes, full article list).The exported corpus is a
visibility: privatetopic owned by this issue's author (owner_username: Anulo2,mine: true, confirmed via authenticatedlist_topics). The generalization to any topic name is attributable to the code — themode='read'branch performs no owner check under the deployedwritessetting — 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).(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, anonymouslist_topicscorrectly 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 callingstart_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-processctx.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, thenwrite_topic_csvbackfills missing descriptions viafetch_descriptions_with_fallbackand persists them withdb.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 unknowntopic=name silently runsdb.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 afterAUTH_ENFORCEMENTis later set toall, which gates only the tool call and the owner-checked/topics/<slug>/download.csvredirect, never the static location.Remediation:
_require_topicfor any tool that materializes data at a public URL, regardless of thewritessetting.internal+X-Accel-Redirector an authenticated handler; drop the publicalias./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).topic=names on read-checked tools instead of creating rows; add a topic-delete/admin path.