chafan-dev / chafan-dev/chafan-core

RSS feed bypasses the responder permission check, exposing visibility=REGISTERED answer bodies to anonymous readers

Open
#170 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Python
Stars
29
Forks
6
Avg merge
4h 48m
Merged PRs (30d)
10

Description

`/api/v1/rss/site/{subdomain}/rss.xml` serves content without ever applying the per-principal permission check that every other read path applies. Answers marked `visibility=REGISTERED` — meaning "registered users only" — are served in full, body included, to anonymous requests.

This predates the event-distribution work (#166–#169) and is untouched by it.

## The path

```
endpoints/rss.py:12 get_site_activity -- deps.get_request_context, anonymous allowed
services/rss.py:11 site_rss_xml -- checks site.public_readable, nothing else
feed_impl.py:108 get_site_activities -- queries Activity by site_id
feed_impl.py:60 retrieve_content -- dereferences the event's ids
responders/rss.py:11 build_rss -- description = content.body
```

`retrieve_content` checks only whether content is *deleted or hidden*:

| Branch | Checks | Missing |
|---|---|---|
| `CreateQuestionInternal` | `is_hidden` | — (Question has no `visibility` column) |
| `AnswerQuestionInternal` | `is_hidden_by_moderator`, `is_published` | **`visibility`** |
| `CreateArticleInternal` | `is_deleted`, `is_published` | **`visibility`** |

None of them consider *who is asking*. Compare `feed_impl.materialize_activity`, the normal feed path, which calls `data_broker.as_principal(receiver_id)` and returns `None` when that principal may not read the content. `retrieve_content` has no principal at all.

That asymmetry is the actual defect: the design note in `docs/proposals/2026-08-03-event-distribution.md` states that "a `Feed` row grants nothing" precisely because `materialize_activity` re-checks at read time. RSS is the one path where that guarantee does not hold, which is why it was tracked separately there.

## Exposure

- **Answers with `visibility=REGISTERED` in a `public_readable` site.** `build_rss` sets `description = content.body` (`responders/rss.py:34`), so the entire answer body is published. This is the live one.
- Articles carry `visibility` too, but article activities have `site_id=None` (articles are not site-scoped), so they are filtered out of the per-site query and are not reachable here today. That is an accident of the site filter, not a check — it would come back the moment article activities gain a site.

`ContentVisibility` has only `ANYONE` and `REGISTERED` today (`utils/base.py:193`), so the blast radius is bounded, but `# TODO: FRIENDS` sits right below it and would inherit the same hole.

## Suggested fix

Give `retrieve_content` a principal and run the same responder gate the rest of the codebase uses, rather than adding a third hand-written visibility check beside the two already there. The anonymous case then falls out of the existing rule that anonymous requires `ANYONE`, which `test_anonymous_requires_anyone_visibility` already pins for articles.

## Note

**There are no tests for the RSS path at all** — `grep -rl rss chafan_core/tests/` returns nothing. Whatever the fix, it should arrive with a test that an anonymous `rss.xml` request omits `REGISTERED` content.

Contributor guide

Open the contributing guide

Research direction

Start with endpoints/rss.py and services/rss.py, then trace retrieve_content in feed_impl.py and compare it with feed_impl.materialize_activity and its principal check. Review test_anonymous_requires_anyone_visibility, then add RSS coverage for an anonymous request. Done means REGISTERED answer content is omitted while permitted RSS content remains available.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
api, backend, security
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.