From cf3960db95b8a9aabaeb5d1512cf86169b2a0cdd Mon Sep 17 00:00:00 2001 From: Paul Csiki Date: Thu, 27 Aug 2026 19:51:59 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20add=20Traefik=20reverse=20?= =?UTF-8?q?proxy=20support=20for=20media-auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds support for serving media behind Traefik, which currently cannot work at all. The media-auth subrequest views read the original request URL from a hardcoded HTTP_X_ORIGINAL_URL header. That header is an nginx-ingress convention. Traefik's ForwardAuth middleware sends X-Forwarded-Uri instead and has no mechanism to emit X-Original-URL, so behind Traefik every recording download and file attachment is rejected with a bare 403 -- indistinguishable from a legitimate permission denial, which makes it painful to diagnose. Add MEDIA_AUTH_ORIGINAL_URL_HEADER, defaulting to HTTP_X_ORIGINAL_URL so existing nginx-ingress deployments are unaffected. Traefik deployments set it to HTTP_X_FORWARDED_URI. It is used in both places that resolve the header: RecordingViewSet._auth_get_original_url and the file attachment _authorize_subrequest. The log message on a missing header now names the header actually expected, which is what makes the failure diagnosable. This mirrors the setting the sibling Docs project already exposes (suitenumerique/docs, MEDIA_AUTH_ORIGINAL_URL_HEADER) for the same reason. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + src/backend/core/api/viewsets.py | 24 +++++--- .../tests/files/test_api_files_media_auth.py | 57 +++++++++++++++++ .../test_api_recordings_media_auth.py | 61 +++++++++++++++++++ src/backend/meet/settings.py | 9 +++ 5 files changed, 145 insertions(+), 7 deletions(-) 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