Repository: openfun/marsha

Issue:
Authorize iframe integration for public resources
## Feature Request

**Is your feature request related to a problem or unsupported use case? Please describe.**

If you want to use marsha resources integrated in an iframe, you have to use the LTI integration. We would also like to authorize public resources.

**Describe the solution you'd like**

When a resource is belonging to a consumer_site (playlist -> consumer_site), we would to authorize the domain defined in the consumer_site to be able to integrate this resource in an iframe. For the LTI view, the decorator `xframe_options_exempt` is used, we have to check if we can use it conditionnaly or if something else can be use.

**Discovery, Documentation, Adoption, Migration Strategy**

https://docs.djangoproject.com/en/4.1/ref/clickjacking/
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options



Discussion:

Resolving patch:
diff --git a/src/backend/marsha/core/views.py b/src/backend/marsha/core/views.py
--- a/src/backend/marsha/core/views.py
+++ b/src/backend/marsha/core/views.py
@@ -546,6 +546,8 @@ class BaseView(BaseModelResourceView, ABC):
     This view answer to GET request only.
     """
 
+    resource = None
+
     @property
     def cache_key(self):
         """Cache key from view context."""
@@ -573,11 +575,11 @@ def _get_app_data(self):
         session_id = str(uuid.uuid4())
 
         if app_data is None:
-            resource = self._get_resource()
+            self.resource = self._get_resource()
 
             app_data = self._get_base_app_data()
             app_data["resource"] = self._get_resource_data(
-                resource,
+                self.resource,
                 session_id,
             )
 
@@ -609,7 +611,9 @@ def _get_resource(self):
         resource_id = self.kwargs["uuid"]
 
         try:
-            return self.model.objects.select_related("playlist").get(
+            return self.model.objects.select_related(
+                "playlist", "playlist__consumer_site"
+            ).get(
                 self.model.get_ready_clause(),
                 is_public=True,
                 pk=resource_id,
@@ -623,7 +627,24 @@ def get(self, request, *args, **kwargs):
 
         Publicly accessible resources or direct access videos can be fetched
         """
-        return self.render_to_response(self.get_context_data())
+        response = self.render_to_response(self.get_context_data())
+
+        if not self.resource:
+            return response
+
+        resource_consumer_site = self.resource.playlist.consumer_site
+        # If the resource is owned in a consumer site then we allow only this consumer
+        # site domain to embed a public resource in an iframe
+        # All subdomain from the domain are also accepted to have the same behavior as
+        # with LTI passports
+        if resource_consumer_site:
+            response.xframe_options_exempt = True
+            response.headers["Content-Security-Policy"] = (
+                f"frame-ancestors {resource_consumer_site.domain} "
+                f"*.{resource_consumer_site.domain};"
+            )
+
+        return response
 
 
 class VideoView(BaseView):


Test patch:
diff --git a/src/backend/marsha/core/tests/test_views_public_document.py b/src/backend/marsha/core/tests/test_views_public_document.py
--- a/src/backend/marsha/core/tests/test_views_public_document.py
+++ b/src/backend/marsha/core/tests/test_views_public_document.py
@@ -10,7 +10,7 @@
 from marsha.core.simple_jwt.tokens import ResourceAccessToken, ResourceRefreshToken
 
 from ..defaults import STATE_CHOICES
-from ..factories import DocumentFactory
+from ..factories import DocumentFactory, OrganizationFactory, PlaylistFactory
 
 
 # We don't enforce arguments documentation in tests
@@ -28,6 +28,7 @@ def test_document_publicly_accessible(self):
             id="301b5f4f-b9f1-4a5f-897d-f8f1bf22c396",
             playlist__title="playlist-003",
             playlist__lti_id="course-v1:ufr+mathematics+00001",
+            playlist__consumer_site__domain="trusted_domain.com",
             is_public=True,
             title="document-001",
             upload_state=random.choice([s[0] for s in STATE_CHOICES]),
@@ -37,6 +38,12 @@ def test_document_publicly_accessible(self):
         response = self.client.get(f"/documents/{document.pk}")
 
         self.assertEqual(response.status_code, 200)
+        self.assertIn("Content-Security-Policy", response.headers)
+        self.assertNotIn("X-Frame-Options", response.headers)
+        self.assertEqual(
+            response.headers["Content-Security-Policy"],
+            "frame-ancestors trusted_domain.com *.trusted_domain.com;",
+        )
         self.assertContains(response, "<html>")
         content = response.content.decode("utf-8")
 
@@ -80,6 +87,72 @@ def test_document_publicly_accessible(self):
         self.assertIsNone(context.get("context_id"))
         self.assertIsNone(context.get("consumer_site"))
 
+    def test_public_document_without_consumer_site(self):
+        """Public document without consumer site should have x-frame-options header"""
+        organization = OrganizationFactory()
+        playlist = PlaylistFactory(
+            title="playlist-003",
+            lti_id=None,
+            organization=organization,
+            consumer_site=None,
+        )
+        document = DocumentFactory(
+            playlist=playlist,
+            is_public=True,
+            title="document-001",
+            upload_state=random.choice([s[0] for s in STATE_CHOICES]),
+            uploaded_on="2019-09-24 07:24:40+00",
+        )
+
+        response = self.client.get(f"/documents/{document.pk}")
+
+        self.assertEqual(response.status_code, 200)
+        self.assertNotIn("Content-Security-Policy", response.headers)
+        self.assertIn("X-Frame-Options", response.headers)
+        self.assertEqual(response.headers["X-Frame-Options"], "DENY")
+        self.assertContains(response, "<html>")
+        content = response.content.decode("utf-8")
+
+        match = re.search(
+            '<div id="marsha-frontend-data" data-context="(.*)">', content
+        )
+
+        context = json.loads(unescape(match.group(1)))
+        jwt_token = ResourceAccessToken(context.get("jwt"))
+        ResourceRefreshToken(context.get("refresh_token"))  # Must not raise
+
+        self.assertEqual(
+            jwt_token.payload["permissions"],
+            {"can_access_dashboard": False, "can_update": False},
+        )
+        self.assertEqual(context.get("state"), "success")
+        self.assertEqual(
+            context.get("resource"),
+            {
+                "active_stamp": "1569309880",
+                "is_ready_to_show": True,
+                "show_download": True,
+                "id": str(document.pk),
+                "upload_state": document.upload_state,
+                "title": document.title,
+                "extension": None,
+                "filename": "playlist-003_document-001",
+                "playlist": {
+                    "id": str(document.playlist.pk),
+                    "title": "playlist-003",
+                    "lti_id": None,
+                },
+                "url": (
+                    f"https://abc.cloudfront.net/{document.pk}"
+                    "/document/1569309880?response-content-disposition=attachment%3B"
+                    "+filename%3Dplaylist-003_document-001"
+                ),
+            },
+        )
+        self.assertEqual(context.get("modelName"), "documents")
+        self.assertIsNone(context.get("context_id"))
+        self.assertIsNone(context.get("consumer_site"))
+
     def test_document_not_publicly_accessible(self):
         """Validate it is impossible to access to a non public document."""
         document = DocumentFactory(
diff --git a/src/backend/marsha/core/tests/test_views_public_video.py b/src/backend/marsha/core/tests/test_views_public_video.py
--- a/src/backend/marsha/core/tests/test_views_public_video.py
+++ b/src/backend/marsha/core/tests/test_views_public_video.py
@@ -11,7 +11,12 @@
 from marsha.core.simple_jwt.tokens import ResourceAccessToken, ResourceRefreshToken
 
 from ..defaults import DELETED, HARVESTED, PENDING, RAW, RUNNING, STATE_CHOICES
-from ..factories import LiveSessionFactory, VideoFactory
+from ..factories import (
+    LiveSessionFactory,
+    OrganizationFactory,
+    PlaylistFactory,
+    VideoFactory,
+)
 
 
 # We don't enforce arguments documentation in tests
@@ -29,6 +34,7 @@ def test_video_publicly_accessible(self):
             id="301b5f4f-b9f1-4a5f-897d-f8f1bf22c397",
             playlist__title="playlist-003",
             playlist__lti_id="course-v1:ufr+mathematics+00001",
+            playlist__consumer_site__domain="trusted_domain.com",
             is_public=True,
             resolutions=[144, 240, 480, 720, 1080],
             upload_state=random.choice(
@@ -38,9 +44,14 @@ def test_video_publicly_accessible(self):
         )
 
         response = self.client.get(f"/videos/{video.pk}")
-
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "<html>")
+        self.assertIn("Content-Security-Policy", response.headers)
+        self.assertNotIn("X-Frame-Options", response.headers)
+        self.assertEqual(
+            response.headers["Content-Security-Policy"],
+            "frame-ancestors trusted_domain.com *.trusted_domain.com;",
+        )
         content = response.content.decode("utf-8")
 
         match = re.search(
@@ -487,3 +498,128 @@ def test_video_accessible_public_from_mail_wrong_video(self):
             f"{public_registration.get_generate_salted_hmac()}"
         )
         self.assertEqual(response.status_code, 404)
+
+    def test_video_public_without_consumer_site(self):
+        """Public video without consumer site should have x-frame-options header."""
+        organization = OrganizationFactory()
+        playlist = PlaylistFactory(
+            title="playlist-003",
+            lti_id=None,
+            organization=organization,
+            consumer_site=None,
+        )
+        video = VideoFactory(
+            playlist=playlist,
+            is_public=True,
+            resolutions=[144, 240, 480, 720, 1080],
+            upload_state=random.choice(
+                [s[0] for s in STATE_CHOICES if s[0] not in [DELETED, HARVESTED]]
+            ),
+            uploaded_on="2019-09-24 07:24:40+00",
+        )
+
+        response = self.client.get(f"/videos/{video.pk}")
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, "<html>")
+        self.assertNotIn("Content-Security-Policy", response.headers)
+        self.assertIn("X-Frame-Options", response.headers)
+        self.assertEqual(response.headers["X-Frame-Options"], "DENY")
+        content = response.content.decode("utf-8")
+
+        match = re.search(
+            '<div id="marsha-frontend-data" data-context="(.*)">', content
+        )
+
+        context = json.loads(unescape(match.group(1)))
+        jwt_token = ResourceAccessToken(context.get("jwt"))
+        ResourceRefreshToken(context.get("refresh_token"))  # Must not raise
+
+        self.assertEqual(
+            jwt_token.payload["permissions"],
+            {"can_access_dashboard": False, "can_update": False},
+        )
+        self.assertNotIn("user", jwt_token.payload)
+
+        self.assertEqual(
+            context.get("resource"),
+            {
+                "active_shared_live_media": None,
+                "active_shared_live_media_page": None,
+                "active_stamp": "1569309880",
+                "allow_recording": True,
+                "estimated_duration": None,
+                "has_chat": True,
+                "has_live_media": True,
+                "is_public": True,
+                "is_ready_to_show": True,
+                "is_recording": False,
+                "is_scheduled": False,
+                "join_mode": "approval",
+                "show_download": True,
+                "description": video.description,
+                "id": str(video.id),
+                "upload_state": video.upload_state,
+                "timed_text_tracks": [],
+                "thumbnail": None,
+                "title": video.title,
+                "urls": {
+                    "mp4": {
+                        "144": f"https://abc.cloudfront.net/{video.pk}/"
+                        "mp4/1569309880_144.mp4?response-content-disposition=attachment%3B+"
+                        "filename%3Dplaylist-003_1569309880.mp4",
+                        "240": f"https://abc.cloudfront.net/{video.pk}/"
+                        "mp4/1569309880_240.mp4?response-content-disposition=attachment%3B+"
+                        "filename%3Dplaylist-003_1569309880.mp4",
+                        "480": f"https://abc.cloudfront.net/{video.pk}/"
+                        "mp4/1569309880_480.mp4?response-content-disposition=attachment%3B+"
+                        "filename%3Dplaylist-003_1569309880.mp4",
+                        "720": f"https://abc.cloudfront.net/{video.pk}/"
+                        "mp4/1569309880_720.mp4?response-content-disposition=attachment%3B+"
+                        "filename%3Dplaylist-003_1569309880.mp4",
+                        "1080": f"https://abc.cloudfront.net/{video.pk}/"
+                        "mp4/1569309880_1080.mp4?response-content-disposition=attachment%3B+"
+                        "filename%3Dplaylist-003_1569309880.mp4",
+                    },
+                    "thumbnails": {
+                        "144": f"https://abc.cloudfront.net/{video.pk}/"
+                        "thumbnails/1569309880_144.0000000.jpg",
+                        "240": f"https://abc.cloudfront.net/{video.pk}/"
+                        "thumbnails/1569309880_240.0000000.jpg",
+                        "480": f"https://abc.cloudfront.net/{video.pk}/"
+                        "thumbnails/1569309880_480.0000000.jpg",
+                        "720": f"https://abc.cloudfront.net/{video.pk}/"
+                        "thumbnails/1569309880_720.0000000.jpg",
+                        "1080": f"https://abc.cloudfront.net/{video.pk}/"
+                        "thumbnails/1569309880_1080.0000000.jpg",
+                    },
+                    "manifests": {
+                        "hls": f"https://abc.cloudfront.net/{video.pk}/"
+                        "cmaf/1569309880.m3u8",
+                    },
+                    "previews": f"https://abc.cloudfront.net/{video.pk}/"
+                    "previews/1569309880_100.jpg",
+                },
+                "should_use_subtitle_as_transcript": False,
+                "starting_at": None,
+                "has_transcript": False,
+                "participants_asking_to_join": [],
+                "participants_in_discussion": [],
+                "playlist": {
+                    "id": str(video.playlist.id),
+                    "title": "playlist-003",
+                    "lti_id": None,
+                },
+                "recording_time": 0,
+                "shared_live_medias": [],
+                "live_state": None,
+                "live_info": {},
+                "live_type": None,
+                "xmpp": None,
+                "tags": [],
+                "license": None,
+            },
+        )
+        self.assertEqual(context.get("state"), "success")
+        self.assertEqual(context.get("modelName"), "videos")
+        self.assertIsNone(context.get("context_id"))
+        self.assertIsNone(context.get("consumer_site"))