Fix leak: redact per-person on authed non-member reads
A logged-in NON-member of a public/unlisted tree could read living people's dates, real alternate names, and media (incl. downloading photos) through the family-view endpoints — only the person LIST was redacted; list_events, list_relationships, list_names, list_media gated on can_view_tree alone. For non-members, these now delegate to the same visibility-filtered reads the public surface uses (person_visibility-driven): living-person events/names dropped, relationships touching a hidden person dropped, media limited to full-visibility persons, and media download (get_media → media_content) 404s for a redacted/unlinked person's media. Members are unchanged. Adds list_public_relationships_for_person / list_public_media / can_view_media to public_view_service. Test: an authed non-member sees no living-person PII across events/names/relationships/media and can't download a living person's file, while the owner still sees everything. Full suite: 72 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Justin Paul <justin@jpaul.me>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
"""Authed non-member reads must redact PER-PERSON, not just gate on the tree.
|
||||
|
||||
A logged-in user who is NOT a member of a public tree previously saw living
|
||||
people's dates, real alternate names, and media through the family-view
|
||||
endpoints — only the person *list* was redacted. These tests assert that leak is
|
||||
closed while members still see everything.
|
||||
"""
|
||||
|
||||
from tests.conftest import auth, register
|
||||
|
||||
LSURNAME = "Authleaksurname"
|
||||
LALIAS = "Authleakalias"
|
||||
LYEAR = "2003"
|
||||
|
||||
|
||||
async def _setup(client):
|
||||
owner = auth(await register(client, "anm-owner@ex.com"))
|
||||
tid = (
|
||||
await client.post(
|
||||
"/api/v1/trees", json={"name": "Pub", "visibility": "public"}, headers=owner
|
||||
)
|
||||
).json()["id"]
|
||||
old = (
|
||||
await client.post(
|
||||
f"/api/v1/trees/{tid}/persons",
|
||||
json={"given": "Olde", "surname": "Gone", "is_living": False},
|
||||
headers=owner,
|
||||
)
|
||||
).json()["id"]
|
||||
young = (
|
||||
await client.post(
|
||||
f"/api/v1/trees/{tid}/persons",
|
||||
json={"given": "Youngauth", "surname": LSURNAME, "is_living": True},
|
||||
headers=owner,
|
||||
)
|
||||
).json()["id"]
|
||||
for pid, year in ((old, "1855"), (young, LYEAR)):
|
||||
await client.post(
|
||||
f"/api/v1/trees/{tid}/events",
|
||||
json={"event_type": "birth", "person_id": pid, "date_value": year},
|
||||
headers=owner,
|
||||
)
|
||||
await client.post(
|
||||
f"/api/v1/trees/{tid}/persons/{young}/names",
|
||||
json={"name_type": "alias", "given": LALIAS},
|
||||
headers=owner,
|
||||
)
|
||||
om = (
|
||||
await client.post(
|
||||
f"/api/v1/trees/{tid}/media",
|
||||
files={"file": ("o.txt", b"old-photo", "text/plain")},
|
||||
data={"person_id": old},
|
||||
headers=owner,
|
||||
)
|
||||
).json()["id"]
|
||||
ym = (
|
||||
await client.post(
|
||||
f"/api/v1/trees/{tid}/media",
|
||||
files={"file": ("y.txt", b"young-photo", "text/plain")},
|
||||
data={"person_id": young},
|
||||
headers=owner,
|
||||
)
|
||||
).json()["id"]
|
||||
return owner, tid, old, young, om, ym
|
||||
|
||||
|
||||
async def test_authed_nonmember_does_not_see_living_pii(client):
|
||||
owner, tid, old, young, om, ym = await _setup(client)
|
||||
stranger = auth(await register(client, "anm-stranger@ex.com"))
|
||||
|
||||
# Living person's events dropped; deceased kept.
|
||||
events = (await client.get(f"/api/v1/trees/{tid}/events", headers=stranger)).json()
|
||||
assert any(e["person_id"] == old for e in events)
|
||||
assert not any(e["person_id"] == young for e in events)
|
||||
|
||||
# Per-person living: names + events empty.
|
||||
assert (
|
||||
await client.get(f"/api/v1/trees/{tid}/persons/{young}/names", headers=stranger)
|
||||
).json() == []
|
||||
assert (
|
||||
await client.get(f"/api/v1/trees/{tid}/persons/{young}/events", headers=stranger)
|
||||
).json() == []
|
||||
|
||||
# The living surname/alias/birth-year must not appear in any of these.
|
||||
for path in (
|
||||
f"/api/v1/trees/{tid}/events",
|
||||
f"/api/v1/trees/{tid}/relationships",
|
||||
f"/api/v1/trees/{tid}/persons/{young}/names",
|
||||
f"/api/v1/trees/{tid}/media",
|
||||
):
|
||||
body = (await client.get(path, headers=stranger)).text
|
||||
assert LSURNAME not in body, path
|
||||
assert LALIAS not in body, path
|
||||
assert LYEAR not in body, path
|
||||
|
||||
# Media: living person's media hidden from the list and undownloadable;
|
||||
# deceased person's media is fine.
|
||||
media_ids = {m["id"] for m in (await client.get(f"/api/v1/trees/{tid}/media", headers=stranger)).json()}
|
||||
assert om in media_ids
|
||||
assert ym not in media_ids
|
||||
assert (
|
||||
await client.get(f"/api/v1/trees/{tid}/media/{ym}/content", headers=stranger)
|
||||
).status_code == 404
|
||||
assert (
|
||||
await client.get(f"/api/v1/trees/{tid}/media/{om}/content", headers=stranger)
|
||||
).status_code == 200
|
||||
|
||||
|
||||
async def test_member_still_sees_everything(client):
|
||||
owner, tid, old, young, om, ym = await _setup(client)
|
||||
|
||||
events = (await client.get(f"/api/v1/trees/{tid}/events", headers=owner)).json()
|
||||
assert any(e["person_id"] == young for e in events)
|
||||
assert (
|
||||
await client.get(f"/api/v1/trees/{tid}/persons/{young}/names", headers=owner)
|
||||
).json() != []
|
||||
member_media = {m["id"] for m in (await client.get(f"/api/v1/trees/{tid}/media", headers=owner)).json()}
|
||||
assert ym in member_media
|
||||
assert (
|
||||
await client.get(f"/api/v1/trees/{tid}/media/{ym}/content", headers=owner)
|
||||
).status_code == 200
|
||||
Reference in New Issue
Block a user