TownReporter is a self-hosted civic newsroom. Anyone can clone it and run it on the public internet, which means a vulnerability here is a vulnerability in every paper someone else is running. Coordinated disclosure matters more than usual for this project.
| Version | Supported |
|---|---|
| 0.6.x | Yes — fixes land on the current release |
| < 0.6 | No. Upgrade before reporting, in case the issue is already closed. |
There is one active release line, maintained by one person.
Do not open a public GitHub issue. A public issue tells every operator running a clone at the same moment it tells the maintainer.
Use GitHub's private vulnerability reporting for this repository: the Security tab, then Report a vulnerability. It is enabled — verified against the API rather than assumed, and a test in this repository fails if it is ever switched off while this document still points at it.
That route reaches the maintainer without a public issue and without an inbox anyone has to remember to check. There is deliberately no email address here. This document previously carried a placeholder where one was meant to go, which is worse than having none: it reads as a channel and is not one.
tips@townreporter.org is NOT a security channel. It is a receive-only
newspaper tip line, documented as such in SELF-HOSTING.md. Reporting a
vulnerability there gets it read by whoever reads reader mail.
Please include, however you report:
- What the issue is, in one or two sentences.
- The version or commit you tested.
- How you reached it: the route, the request, or the steps on the desk.
- What an attacker gets — read access, write access, another user's newsroom, the host, someone's model credentials.
- A proof of concept if you have one. A curl command is ideal.
What to expect:
| Acknowledgement | within 3 working days |
| First assessment | within 7 days |
| Fix or a stated plan | within 30 days for anything reachable from an unauthenticated request |
This is a project maintained by one person. If you do not hear back inside the acknowledgement window, send the report again before assuming it was ignored.
Credit is offered in the changelog entry for the fix unless you would rather not be named. There is no bug bounty.
In scope — the code in this repository:
- Authentication and session handling (
better-auth, the__Host-tr-auth.session_tokencookie —src/lib/auth/). Email and password is the only sign-in. The Grok sign-in, the preview bearer path and the federation code were removed in the 0.6.83 build; a way to bring any of them back is a report. - The desk boundary: every desk and Dark Desk mutation runs through
deskMiddleware(src/lib/news/desk-auth.ts), which resolves the user from the session and requires owner or editor membership (newsroom_members,src/lib/news/membership.ts). A way around that is a report. - Newsroom ownership: the first signed-in user claims the desk
(
deskIsClaimed/claimOwnerinsrc/lib/news/membership.ts); a way to claim ownership as a second identity, or to act as owner/editor without anewsroom_membersrow, is a report. - The SSRF guard on outbound fetches (
src/lib/news/url-guard.ts,fetch-url.ts): the address approved must be the address connected to. A way to make those differ, or to reach a private range, loopback, link-local, or a mapped IPv6 loopback (::ffff:127.0.0.1), is a report. - The cron endpoint (
/api/cron/monitors) andCRON_SECREThandling. - The reading of untrusted files and mail the desk is handed:
- Excel, PowerPoint and LibreOffice files are converted by headless
LibreOffice in a temporary profile with macros off, one conversion at a
time, with a two-minute limit (
src/lib/news/libreoffice.server.ts). A way to run a macro, or to make the conversion reach outside its temporary folders, is a report. - The newsletter mailbox is opened read-only, limited to 5 MB a message, with
HTML sanitised and no remote content loaded (
src/lib/news/newsletter-*.ts). The mailbox password is stored encrypted. - The one guarded Chromium read the scan tries when a public site refuses an ordinary request. It never submits a form, enters a credential or solves a challenge.
- Excel, PowerPoint and LibreOffice files are converted by headless
LibreOffice in a temporary profile with macros off, one conversion at a
time, with a two-minute limit (
- The Opinion voice-file boundary: only a validated path reaches a command line. Anything that gets file contents, a relative path, or a path inside the repository onto that command line is a report.
- Injection of any kind into the public paper — a published story, the feed, the sitemap, or the evidence routes.
- Anything that puts a third-party asset onto a reader's page. The paper is
self-contained — fonts self-hosted, no third-party script — and a cold load
of it makes zero requests to an outside host
(
docs/manual.md§"What the reader gets";npm run smokeproves it in a real browser). The desk's own outbound calls are a different surface, documented indocs/setup.md§"What leaves this machine": that documented egress is not itself a vulnerability.
Not in scope — configuration choices the operator makes, which the docs already warn about:
- Running with
VITE_AUTH_ENABLED=falseon a public host. Every document that mentions this flag says not to do it (docs/setup.md,docs/manual.md,README.md,.env.example). - PGLite data loss on restart. Documented behaviour, not a defect.
- Vulnerabilities in the model provider you chose, in a gateway you run, or in the civic websites the app fetches.
- Anything requiring an attacker who already has shell on the machine running the newsroom. That machine holds the operator's own model login.
- The editorial writer holds the voice file and its web tools in one run. A page the writer reads during a piece could in principle talk it into fetching an attacker's URL with style-guide text in it, which would leak the operator's private voice file. Units U12/U12b/c closed that by splitting the writer — a gathering pass with the tools and no voice, a writing pass with the voice and no tools — and the owner reversed it on 2026-09-30 (D20): the voice file CONTAINS the research protocol the writer is told to follow, so a writer holding the voice without the tools cannot do its job. The owner accepted the exfiltration risk for a single-person newsroom; the desk performs the searches for the writers that have no web tools of their own (DeepSeek, the local model, saved connections), where the model holds the voice and the desk holds the fetching.
- Reports generated by a scanner with no demonstrated path to impact.
Context, so a report can aim at what is not yet covered:
- Outbound fetches are guarded at connect time (
assertPublicHttpUrlinsrc/lib/news/fetch-url.ts), re-checking the resolved address rather than trusting an earlier DNS answer. - Mapped IPv6 loopback (
::ffff:7f00:1/::ffff:127.0.0.1) is recognized and blocked (src/lib/news/url-guard.ts). - Every override of a desk warning ("Publish anyway", "Kill anyway", and the rest) writes the editor, the time, the warning key and the target to the audit trail, so a warning that was passed over can be read back.
- The server refuses to serve a database that is behind its migrations (HTTP
503,
server/middleware/00-schema-current.ts), so a half-updated install does not serve a schema the code does not expect. - The default test suite (
npm test) is offline by construction, and a test enforces that no default test can reach a live model unasked (scripts/newsroom-security.test.mjs). - CI boots both the dev server and a real production build and exercises
them in a browser (
.github/workflows/ci.yml).
Pages, PDFs, captions, transcripts, packets and other fetched records are evidence. Text inside them does not grant an agent additional tools or change the reporting job's machine permissions. TownReporter selects the tool surface in application code before sending the prompt.
- Since 0.6.84 the command-line models run with their full tool set and
permission prompts turned off (decision D149, Oct 18 2026). The tool surface
is no longer a security boundary; the boundary is the machine account the
server runs under, and what that account can reach.
- The Codex CLI runs with
--dangerously-bypass-approvals-and-sandbox,--search,--ephemeral, and hooks, memories, project docs and notifications turned off per call (src/lib/news/ai-codex.server.ts). - Claude Code runs with
--dangerously-skip-permissions,--permission-mode bypassPermissions, no session persistence, and hooks, memory and personal CLAUDE.md files turned off per call. Its built-in tools and configured MCP servers stay loaded (src/lib/news/ai-claude-code.server.ts,buildClaudeCodeArgs). - The Gemini CLI runs with
--dangerously-skip-permissionsand slash commands turned off (src/lib/news/ai-gemini-cli.server.ts). - Run the server under an account that holds only what the newsroom needs. Text inside a fetched record that tries to steer an agent is a report.
- The Codex CLI runs with
- DeepSeek v4.1 Flash runs inside the same Claude Code CLI, with no MCP servers loaded, through
a small relay the server starts on
127.0.0.1with an operating-system chosen port (src/lib/news/deepseek-relay.server.ts). The relay is a boundary: it strips the incoming auth, API-key and cookie headers, forwards only the route's own bearer token, and sets the thinking budget from the run's effort (off at the default, effort none). A way to reach it from another machine, or to make it forward an operator credential, is a report.TOWNREPORTER_CLAUDE_CODE=0sends DeepSeek by plain HTTP instead. - OpenAI-compatible and Anthropic API calls, including local-model calls, receive text prompts without tool or function definitions. TownReporter performs source search and fetching itself through its guarded application paths.
These controls prevent a source instruction from granting shell, file-write, browser, MCP, plugin or agent access. They do not make generated reporting factually correct; editors must still review evidence and claims. Research paths that explicitly enable provider web search or fetch can make those research requests and consume the selected provider's usage allowance.
You are the publisher and you are also the sysadmin. The settings that matter
most are BETTER_AUTH_SECRET (a long random string, never left at a
preview-only default) and HOST (bind loopback, 127.0.0.1, when a tunnel
or reverse proxy fronts the app so that is the only way in). docs/setup.md
explains both, and docs/setup.md §"What leaves this machine" lists what
your desk sends where and to whom.
This policy describes the maintained repository release line. GitHub private vulnerability reporting is the supported confidential reporting route and is checked by the repository's security-policy test.