coulomb-social/tests/test_private_assets.py
tegwick 021f0a5916
Some checks failed
Use-case regression gate / usecase-regressions (push) Has been cancelled
Prove the global space-slug constraint for WP-0013-T01
Migration 0003 refuses duplicate content slugs and rolls back by dropping
only spaces_space_slug_global_uniq. Tests cover the SQL boundary, import
and admin races, and legacy fail-closed reads. The live database is not
migrated.

Assistant: grok
Assistant-Session: 01a12cc2-e3b2-77a3-8aef-9cc60e577fee
2026-10-11 23:12:18 +02:00

198 lines
8.9 KiB
Python

"""Real image reads obey space access and filesystem boundaries."""
import base64
import json
from pathlib import Path
import pytest
from django.urls import reverse
from coulomb_social.apps.spaces import pageops
from coulomb_social.apps.spaces.assets import local_asset_url
from coulomb_social.apps.spaces.content import load_space_page
from coulomb_social.apps.spaces.models import Space, SpaceMembership
from coulomb_social.apps.spaces.services import create_space, put_space_page
from tests.test_space_permissions import make_member
SCENARIO = json.loads((Path(__file__).parent / "usecases/fixtures/reichelag-weekly-call.json")
.read_text(encoding="utf-8"))
PNG = base64.b64decode(SCENARIO["visual_base64"])
@pytest.fixture
def images(tmp_path, settings, client):
settings.CONTENT_ROOT = str(tmp_path / "content")
owner, reader, outsider = [make_member(name) for name in ("owner", "reader", "outsider")]
space = create_space(owner, title="Pictures", slug="pictures", visual="assets/cover.png")
SpaceMembership.objects.create(space=space, member=reader, role="viewer")
pageops.assets_dir(space.slug).joinpath("cover.png").write_bytes(PNG)
client.force_login(owner.user)
return space, owner, reader, outsider
def image_url(space, path="cover.png"):
return reverse("spaces:asset", kwargs={"slug": space.slug, "asset_path": path})
@pytest.mark.django_db
@pytest.mark.usecase("CSOC-UC-008")
def test_owner_and_reader_get_exact_private_image(images, client):
space, owner, reader, _ = images
for member in (owner, reader):
client.force_login(member.user)
response = client.get(image_url(space))
assert response.status_code == 200
assert response["Content-Type"] == "image/png"
assert response["Cache-Control"] == "private, no-store"
assert response["X-Content-Type-Options"] == "nosniff"
assert b"".join(response.streaming_content) == PNG
assert client.post(image_url(space)).status_code == 405
response = client.head(image_url(space))
assert response.status_code == 200
assert b"".join(response.streaming_content) == b""
@pytest.mark.django_db
@pytest.mark.usecase("CSOC-UC-008")
def test_outsider_foreign_tenant_revoked_archived_and_anonymous_denied(images, client):
space, _, reader, outsider = images
foreign = make_member("foreign", tenant="tenant:b")
# Even an erroneous cross-tenant grant cannot authorize the read.
SpaceMembership.objects.create(space=space, member=foreign, role="owner")
for member in (outsider, foreign):
client.force_login(member.user)
assert client.get(image_url(space)).status_code == 404
client.force_login(reader.user)
SpaceMembership.objects.filter(space=space, member=reader).delete()
assert client.get(image_url(space)).status_code == 404
client.force_login(space.created_by.user)
space.is_active = False
space.save()
assert client.get(image_url(space)).status_code == 404
client.logout()
assert client.get(image_url(space)).status_code == 302
@pytest.mark.django_db
@pytest.mark.django_db(transaction=True)
def test_duplicate_slug_fails_closed_for_both_tenants(images, client):
from tests.legacy_slug import without_global_slug_constraint
space, _, _, _ = images
other = make_member("foreign-owner", tenant="tenant:b")
with without_global_slug_constraint():
Space.objects.create(tenant_id=other.tenant_id, slug=space.slug,
title="Foreign pictures", created_by=other)
assert client.get(image_url(space)).status_code == 404
client.force_login(other.user)
assert client.get(image_url(space)).status_code == 404
@pytest.mark.django_db
@pytest.mark.parametrize("path", ["../pages/index.md", "../cover.png", "nested/../cover.png",
"nested//cover.png", "nested/.hidden.png", "cover.svg",
"cover.html", "missing.png", "cover.png/extra",
"..%2fcover.png", "cover\\.png"])
def test_invalid_missing_and_non_raster_paths_denied(images, client, path):
space, _, _, _ = images
assert client.get(image_url(space, path)).status_code == 404
@pytest.mark.django_db
@pytest.mark.parametrize("location", ["file", "nested", "assets", "space"])
def test_symlink_at_every_tree_level_is_denied(images, client, tmp_path, location):
space, _, _, _ = images
outside = tmp_path / "outside"
outside.mkdir()
(outside / "secret.png").write_bytes(PNG)
root = pageops.space_dir(space.slug)
if location == "file":
(root / "assets/link.png").symlink_to(outside / "secret.png")
path = "link.png"
elif location == "nested":
(root / "assets/nested").symlink_to(outside, target_is_directory=True)
path = "nested/secret.png"
elif location == "assets":
(root / "assets").rename(root / "original-assets")
(root / "assets").symlink_to(outside, target_is_directory=True)
path = "secret.png"
else:
(outside / "assets").mkdir()
(outside / "assets/secret.png").write_bytes(PNG)
root.rename(root.with_name("original-pictures"))
root.symlink_to(outside, target_is_directory=True)
path = "secret.png"
assert client.get(image_url(space, path)).status_code == 404
@pytest.mark.django_db
def test_nested_raster_allowed_but_html_disguised_as_png_denied(images, client):
space, _, _, _ = images
assets = pageops.assets_dir(space.slug)
(assets / "nested").mkdir()
(assets / "nested/real.png").write_bytes(PNG)
response = client.get(image_url(space, "nested/real.png"))
assert response.status_code == 200
assert b"".join(response.streaming_content) == PNG
(assets / "fake.png").write_bytes(b"<html><script>alert(1)</script></html>")
assert client.get(image_url(space, "fake.png")).status_code == 404
@pytest.mark.django_db
@pytest.mark.usecase("CSOC-UC-008")
def test_display_uses_private_images_without_clipboard_changes(images, client):
space, owner, _, _ = images
body = '![Body visual](../assets/cover.png)\n\n[Open visual](assets/cover.png)\n'
put_space_page(owner, space, "notes", title="Notes", body=body,
visual="assets/cover.png")
rendered = load_space_page(space, "notes")
assert f'src="{image_url(space)}"' in rendered.html
assert f'href="{image_url(space)}"' in rendered.html
assert rendered.visual_url == image_url(space)
assert body.removesuffix("\n") in rendered.clipboard_text
assert 'src="../assets/cover.png"' in rendered.clipboard_html
response = client.get(reverse("spaces:detail", kwargs={"slug": space.slug}) + "?page=notes")
assert b'alt="Pictures cover"' in response.content
assert b'alt="Notes visual"' in response.content
response = client.get(reverse("core:app_home"))
assert b'class="space-cover"' in response.content
assert f'src="{image_url(space)}"'.encode() in response.content
assert b"Visual: assets/cover.png" not in response.content
client.force_login(images[3].user)
response = client.get(reverse("core:app_home"))
assert b'class="space-cover"' not in response.content
assert image_url(space).encode() not in response.content
@pytest.mark.parametrize("reference", ["https://remote.test/image.png", "//remote.test/a.png",
"assets/../../private.png", "assets/%2e%2e/private.png",
"assets/cover.svg", "http://[", "assets/cover.png?key=x"])
def test_untrusted_visual_reference_is_not_embedded(reference):
assert local_asset_url("pictures", reference) == ""
@pytest.mark.django_db
@pytest.mark.parametrize("visual", ["../private/assets/secret.png", "assets/../secret.png",
"assets/link.png"])
def test_copy_cannot_launder_a_private_or_symlink_visual(images, tmp_path, visual):
space, _, _, _ = images
secret = tmp_path / "secret.png"
secret.write_bytes(PNG)
(pageops.assets_dir(space.slug) / "link.png").symlink_to(secret)
pageops.write_page(space.slug, "notes", title="Notes", body="Source notes", visual=visual)
with pytest.raises(pageops.PageOpsError):
pageops.copy_page(space.slug, "notes", space.slug, "copy")
assert not pageops.read_page(space.slug, "copy").exists
assert pageops.read_page(space.slug, "notes").body == "Source notes\n"
@pytest.mark.django_db
def test_copy_does_not_overwrite_a_destination_visual(images):
space, _, _, _ = images
pageops.write_page(space.slug, "notes", title="Notes", visual="assets/cover.png")
destination = pageops.assets_dir(space.slug) / "copy-cover.png"
destination.write_bytes(b"existing asset")
with pytest.raises(pageops.PageOpsError, match="destination Visual"):
pageops.copy_page(space.slug, "notes", space.slug, "copy")
assert destination.read_bytes() == b"existing asset"
assert not pageops.read_page(space.slug, "copy").exists