diff --git a/CHANGELOG.md b/CHANGELOG.md index dd2e9ee8..873f4706 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to ### Added - ✨(frontend) add 1080p sending resolution option #1660 +- ✨(backend) add Traefik support via configurable media-auth url header #1649 ### Fixed diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 1b628057..5df32ab9 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1076,9 +1076,10 @@ class RecordingViewSet( def _auth_get_original_url(self, request): """ - Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header. + Extracts and parses the original URL from the configured header. Raises PermissionDenied if the header is missing. - The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header. + The original url is passed by the reverse proxy in the header named by the + MEDIA_AUTH_ORIGINAL_URL_HEADER setting, which defaults to "HTTP_X_ORIGINAL_URL". See corresponding ingress configuration in Helm chart and read about the nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress is configured to do this. @@ -1088,9 +1089,13 @@ class RecordingViewSet( reasons. """ # Extract the original URL from the request header - original_url = request.META.get("HTTP_X_ORIGINAL_URL") + original_url = request.META.get(settings.MEDIA_AUTH_ORIGINAL_URL_HEADER) if not original_url: - logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest") + logger.warning( + "Missing %s header in subrequest. Set MEDIA_AUTH_ORIGINAL_URL_HEADER " + "to the header your reverse proxy sends.", + settings.MEDIA_AUTH_ORIGINAL_URL_HEADER, + ) raise drf_exceptions.PermissionDenied() logger.debug("Original url: '%s'", original_url) @@ -1415,7 +1420,8 @@ class FileViewSet( Authorize access based on the original URL of an Nginx subrequest and user permissions. Returns a dictionary of URL parameters if authorized. - The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header. + The original url is passed by the reverse proxy in the header named by the + MEDIA_AUTH_ORIGINAL_URL_HEADER setting, which defaults to "HTTP_X_ORIGINAL_URL". See corresponding ingress configuration in Helm chart and read about the nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress is configured to do this. @@ -1434,9 +1440,13 @@ class FileViewSet( - PermissionDenied if authorization fails. """ # Extract the original URL from the request header - original_url = request.META.get("HTTP_X_ORIGINAL_URL") + original_url = request.META.get(settings.MEDIA_AUTH_ORIGINAL_URL_HEADER) if not original_url: - logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest") + logger.warning( + "Missing %s header in subrequest. Set MEDIA_AUTH_ORIGINAL_URL_HEADER " + "to the header your reverse proxy sends.", + settings.MEDIA_AUTH_ORIGINAL_URL_HEADER, + ) raise drf_exceptions.PermissionDenied() parsed_url = urlparse(original_url) diff --git a/src/backend/core/tests/files/test_api_files_media_auth.py b/src/backend/core/tests/files/test_api_files_media_auth.py index 51259f5a..b749d5bd 100644 --- a/src/backend/core/tests/files/test_api_files_media_auth.py +++ b/src/backend/core/tests/files/test_api_files_media_auth.py @@ -7,6 +7,7 @@ from urllib.parse import quote, urlparse from django.conf import settings from django.core.files.storage import default_storage +from django.test import override_settings from django.utils import timezone import pytest @@ -143,3 +144,59 @@ def test_api_files_media_auth_own_file_deleted(): ) assert response.status_code == 403 + + +@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI") +def test_api_files_media_auth_custom_original_url_header(): + """ + Authorization should honour the configured original-url header. + + Covers the attachment subrequest path, which resolves the header separately + from the recording one. Reverse proxies other than nginx-ingress use + different headers: Traefik's ForwardAuth sends X-Forwarded-Uri and cannot + emit X-Original-URL at all. + """ + user = factories.UserFactory() + + file = factories.FileFactory( + type=models.FileTypeChoices.BACKGROUND_IMAGE, + update_upload_state=models.FileUploadStateChoices.READY, + creator=user, + ) + + client = APIClient() + client.force_login(user) + + default_storage.save(file.file_key, BytesIO(b"my prose")) + + original_url = f"http://localhost/media/{file.file_key:s}" + response = client.get( + "/api/v1.0/files/media-auth/", HTTP_X_FORWARDED_URI=original_url + ) + + assert response.status_code == 200 + assert "AWS4-HMAC-SHA256 Credential=" in response["Authorization"] + + +@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI") +def test_api_files_media_auth_default_header_ignored_when_reconfigured(): + """ + Only the configured header should be honoured, never a hardcoded fallback. + """ + user = factories.UserFactory() + + file = factories.FileFactory( + type=models.FileTypeChoices.BACKGROUND_IMAGE, + update_upload_state=models.FileUploadStateChoices.READY, + creator=user, + ) + + client = APIClient() + client.force_login(user) + + original_url = f"http://localhost/media/{file.file_key:s}" + response = client.get( + "/api/v1.0/files/media-auth/", HTTP_X_ORIGINAL_URL=original_url + ) + + assert response.status_code == 403 diff --git a/src/backend/core/tests/recording/test_api_recordings_media_auth.py b/src/backend/core/tests/recording/test_api_recordings_media_auth.py index 51917632..cd9a6388 100644 --- a/src/backend/core/tests/recording/test_api_recordings_media_auth.py +++ b/src/backend/core/tests/recording/test_api_recordings_media_auth.py @@ -8,6 +8,7 @@ from uuid import uuid4 from django.conf import settings from django.core.files.storage import default_storage +from django.test import override_settings from django.utils import timezone import pytest @@ -282,3 +283,63 @@ def test_api_recordings_media_auth_success_administrator(mode): timeout=1, ) assert response.content.decode("utf-8") == "my prose" + + +def test_api_recordings_media_auth_missing_header(): + """ + Test that a subrequest without the configured original-url header is rejected. + """ + user = UserFactory() + + client = APIClient() + client.force_login(user) + + response = client.get("/api/v1.0/recordings/media-auth/") + + assert response.status_code == 403 + + +@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI") +def test_api_recordings_media_auth_custom_original_url_header(): + """ + Test that the header carrying the original URL can be configured. + + Reverse proxies other than nginx-ingress use different headers: Traefik's + ForwardAuth sends X-Forwarded-Uri and cannot emit X-Original-URL at all. + """ + user = UserFactory() + + client = APIClient() + client.force_login(user) + + original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4" + + response = client.get( + "/api/v1.0/recordings/media-auth/", HTTP_X_FORWARDED_URI=original_url + ) + + # The header was read and parsed: we get as far as looking the recording up, + # rather than being rejected for a missing header. + assert response.status_code == 404 + + +@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI") +def test_api_recordings_media_auth_default_header_ignored_when_reconfigured(): + """ + Test that only the configured header is honoured. + + Guards against the header being read from a hardcoded name in parallel with + the setting. + """ + user = UserFactory() + + client = APIClient() + client.force_login(user) + + original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4" + + response = client.get( + "/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url + ) + + assert response.status_code == 403 diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index 59669b83..2f0ceeed 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -129,6 +129,15 @@ class Base(Configuration): MEDIA_BASE_URL = values.Value( "", environ_name="MEDIA_BASE_URL", environ_prefix=None ) + # Header the reverse proxy uses to pass the original request URL to the + # media-auth subrequest views. nginx-ingress sends X-Original-URL, which is + # the default. Other proxies use different headers -- Traefik's ForwardAuth, + # for instance, sends X-Forwarded-Uri and cannot emit X-Original-URL at all. + MEDIA_AUTH_ORIGINAL_URL_HEADER = values.Value( + default="HTTP_X_ORIGINAL_URL", + environ_name="MEDIA_AUTH_ORIGINAL_URL_HEADER", + environ_prefix=None, + ) SITE_ID = 1