"""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"") 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