From 313acf4f7886478cac199e3d5ca2348f191b7027 Mon Sep 17 00:00:00 2001 From: Manuel Raynaud Date: Thu, 20 Mar 2025 11:04:02 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B(back)=20allow=20only=20images=20to?= =?UTF-8?q?=20be=20used=20with=20the=20cors-proxy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cors-proxy endpoint allowed to use every type of files and to execute it in the browser. We limit the scope only to images and Content-Security-Policy and Content-Disposition headers are also added to not allow script execution that can be present in a SVG file. --- CHANGELOG.md | 6 +++ src/backend/core/api/viewsets.py | 14 +++++-- .../test_api_documents_cors_proxy.py | 41 +++++++++++++++++-- 3 files changed, 54 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77261ea6..0f5910b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,14 @@ and this project adheres to ## [Unreleased] +## Added + - 📝(doc) add publiccode.yml +## Fixed + +- 🐛(back) allow only images to be used with the cors-proxy #781 + ## [2.5.0] - 2025-03-18 ## Added diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 910e0a86..5f8d87f2 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1271,13 +1271,21 @@ class DocumentViewSet( }, timeout=10, ) + content_type = response.headers.get("Content-Type", "") + + if not content_type.startswith("image/"): + return drf.response.Response( + status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE + ) # Use StreamingHttpResponse with the response's iter_content to properly stream the data proxy_response = StreamingHttpResponse( streaming_content=response.iter_content(chunk_size=8192), - content_type=response.headers.get( - "Content-Type", "application/octet-stream" - ), + content_type=content_type, + headers={ + "Content-Disposition": "attachment;", + "Content-Security-Policy": "default-src 'none'; img-src 'none' data:;", + }, status=response.status_code, ) diff --git a/src/backend/core/tests/documents/test_api_documents_cors_proxy.py b/src/backend/core/tests/documents/test_api_documents_cors_proxy.py index 1a073830..8f5d4219 100644 --- a/src/backend/core/tests/documents/test_api_documents_cors_proxy.py +++ b/src/backend/core/tests/documents/test_api_documents_cors_proxy.py @@ -1,6 +1,7 @@ """Test on the CORS proxy API for documents.""" import pytest +import responses from rest_framework.test import APIClient from core import factories @@ -8,17 +9,24 @@ from core import factories pytestmark = pytest.mark.django_db +@responses.activate def test_api_docs_cors_proxy_valid_url(): """Test the CORS proxy API for documents with a valid URL.""" document = factories.DocumentFactory(link_reach="public") client = APIClient() - url_to_fetch = "https://docs.numerique.gouv.fr/assets/logo-gouv.png" + url_to_fetch = "https://external-url.com/assets/logo-gouv.png" + responses.get(url_to_fetch, body=b"", status=200, content_type="image/png") response = client.get( f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}" ) assert response.status_code == 200 assert response.headers["Content-Type"] == "image/png" + assert response.headers["Content-Disposition"] == "attachment;" + assert ( + response.headers["Content-Security-Policy"] + == "default-src 'none'; img-src 'none' data:;" + ) assert response.streaming_content @@ -32,12 +40,14 @@ def test_api_docs_cors_proxy_without_url_query_string(): assert response.json() == {"detail": "Missing 'url' query parameter"} +@responses.activate def test_api_docs_cors_proxy_anonymous_document_not_public(): """Test the CORS proxy API for documents with an anonymous user and a non-public document.""" document = factories.DocumentFactory(link_reach="authenticated") client = APIClient() - url_to_fetch = "https://docs.numerique.gouv.fr/assets/logo-gouv.png" + url_to_fetch = "https://external-url.com/assets/logo-gouv.png" + responses.get(url_to_fetch, body=b"", status=200, content_type="image/png") response = client.get( f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}" ) @@ -47,6 +57,7 @@ def test_api_docs_cors_proxy_anonymous_document_not_public(): } +@responses.activate def test_api_docs_cors_proxy_authenticated_user_accessing_protected_doc(): """ Test the CORS proxy API for documents with an authenticated user accessing a protected @@ -58,15 +69,22 @@ def test_api_docs_cors_proxy_authenticated_user_accessing_protected_doc(): client = APIClient() client.force_login(user) - url_to_fetch = "https://docs.numerique.gouv.fr/assets/logo-gouv.png" + url_to_fetch = "https://external-url.com/assets/logo-gouv.png" + responses.get(url_to_fetch, body=b"", status=200, content_type="image/png") response = client.get( f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}" ) assert response.status_code == 200 assert response.headers["Content-Type"] == "image/png" + assert response.headers["Content-Disposition"] == "attachment;" + assert ( + response.headers["Content-Security-Policy"] + == "default-src 'none'; img-src 'none' data:;" + ) assert response.streaming_content +@responses.activate def test_api_docs_cors_proxy_authenticated_not_accessing_restricted_doc(): """ Test the CORS proxy API for documents with an authenticated user not accessing a restricted @@ -78,7 +96,8 @@ def test_api_docs_cors_proxy_authenticated_not_accessing_restricted_doc(): client = APIClient() client.force_login(user) - url_to_fetch = "https://docs.numerique.gouv.fr/assets/logo-gouv.png" + url_to_fetch = "https://external-url.com/assets/logo-gouv.png" + responses.get(url_to_fetch, body=b"", status=200, content_type="image/png") response = client.get( f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}" ) @@ -86,3 +105,17 @@ def test_api_docs_cors_proxy_authenticated_not_accessing_restricted_doc(): assert response.json() == { "detail": "You do not have permission to perform this action." } + + +@responses.activate +def test_api_docs_cors_proxy_unsupported_media_type(): + """Test the CORS proxy API for documents with an unsupported media type.""" + document = factories.DocumentFactory(link_reach="public") + + client = APIClient() + url_to_fetch = "https://external-url.com/assets/index.html" + responses.get(url_to_fetch, body=b"", status=200, content_type="text/html") + response = client.get( + f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}" + ) + assert response.status_code == 415