From 6c1b53fa023b26a109a582d2d04ac5762c103052 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ri=C3=ABl=20Notermans?= Date: Wed, 17 Jun 2026 00:28:11 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B(backend)=20accept=20configurable?= =?UTF-8?q?=20media-auth=20forward=20headers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ItemViewSet._authorize_subrequest() only read HTTP_X_ORIGINAL_URL, an nginx-ingress convention. On clusters running Traefik (or any RFC-7239 style proxy), the equivalent header is X-Forwarded-Uri, so the lookup returned None and every /media/... request 403'd — uploads succeeded but downloads/previews never resolved. Read the original URL from the first present header in the new MEDIA_AUTH_FORWARD_HEADERS setting (default ["X-Original-Url", "X-Forwarded-Uri"]), tried in order. nginx-ingress users are unaffected since X-Original-Url stays first; other proxies can be supported by overriding the env var with no code change. --- CHANGELOG.md | 4 ++ src/backend/core/api/viewsets.py | 24 ++++++-- .../tests/items/test_api_items_media_auth.py | 55 +++++++++++++++++++ src/backend/drive/settings.py | 9 +++ 4 files changed, 87 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0babb44a0..5a115eb26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to ## [Unreleased] +### Added + +- ✨(backend) accept configurable media-auth forward headers (Traefik X-Forwarded-Uri) + ## [v0.19.0] - 2026-06-09 ### Added diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 4c4b9283e..e4b8b4c8f 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1472,12 +1472,14 @@ def favorite(self, request, *args, **kwargs): def _authorize_subrequest(self, request, pattern): """ Shared method to authorize access based on the original URL of an Nginx subrequest - and user permissions. Returns a dictionary of URL parameters if authorized. + (or equivalent Traefik ForwardAuth 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. 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. + is configured to do this. Traefik's ForwardAuth middleware sends the equivalent + value in the "HTTP_X_FORWARDED_URI" header, which is accepted as a fallback. Based on the original url and the logged in user, we must decide if we authorize Nginx to let this request go through (by returning a 200 code) or if we block it (by returning @@ -1492,10 +1494,22 @@ def _authorize_subrequest(self, request, pattern): Raises: - PermissionDenied if authorization fails. """ - # Extract the original URL from the request header - original_url = request.META.get("HTTP_X_ORIGINAL_URL") + # Extract the original URL from the first configured header that is present. + # nginx ingress passes it in "X-Original-Url"; Traefik's ForwardAuth uses + # "X-Forwarded-Uri". The list of accepted headers is configurable via the + # MEDIA_AUTH_FORWARD_HEADERS setting. + original_url = None + for header in settings.MEDIA_AUTH_FORWARD_HEADERS: + meta_key = "HTTP_" + header.upper().replace("-", "_") + original_url = request.META.get(meta_key) + if original_url: + break + if not original_url: - logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest") + logger.debug( + "Missing media auth header (tried %s) in subrequest", + ", ".join(settings.MEDIA_AUTH_FORWARD_HEADERS), + ) raise drf.exceptions.PermissionDenied() parsed_url = urlparse(original_url) diff --git a/src/backend/core/tests/items/test_api_items_media_auth.py b/src/backend/core/tests/items/test_api_items_media_auth.py index fa1ed9384..c5474df94 100644 --- a/src/backend/core/tests/items/test_api_items_media_auth.py +++ b/src/backend/core/tests/items/test_api_items_media_auth.py @@ -399,3 +399,58 @@ def test_api_items_media_auth_filename_with_hash(): timeout=1, ) assert response.content.decode("utf-8") == "my prose" + + +def test_api_items_media_auth_forwarded_uri_header_public(): + """ + The media-auth endpoint should accept Traefik's "X-Forwarded-Uri" header as a + fallback for "X-Original-Url" and reach the same authorization decision (200). + """ + item = factories.ItemFactory( + link_reach="public", + type=models.ItemTypeChoices.FILE, + update_upload_state=models.ItemUploadStateChoices.READY, + ) + + default_storage.save(item.file_key, BytesIO(b"my prose")) + + original_url = f"http://localhost/media/{item.file_key:s}" + now = timezone.now() + with freeze_time(now): + response = APIClient().get( + "/api/v1.0/items/media-auth/", HTTP_X_FORWARDED_URI=original_url + ) + + assert response.status_code == 200 + + authorization = response["Authorization"] + assert "AWS4-HMAC-SHA256 Credential=" in authorization + assert "SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=" in authorization + assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ") + + +@pytest.mark.parametrize("reach", ["authenticated", "restricted"]) +def test_api_items_media_auth_forwarded_uri_header_blocked(reach): + """ + Anonymous users must still be blocked (403) when the original URL is carried by + Traefik's "X-Forwarded-Uri" header, mirroring the "X-Original-Url" behaviour. + """ + item = factories.ItemFactory(link_reach=reach) + + filename = f"{uuid.uuid4()!s}.jpg" + media_url = f"http://localhost/media/item/{item.pk!s}/{filename:s}" + + response = APIClient().get( + "/api/v1.0/items/media-auth/", HTTP_X_FORWARDED_URI=media_url + ) + + assert response.status_code == 403 + assert "Authorization" not in response + + +def test_api_items_media_auth_no_forward_header(): + """A subrequest carrying none of the accepted headers should be denied (403).""" + response = APIClient().get("/api/v1.0/items/media-auth/") + + assert response.status_code == 403 + assert "Authorization" not in response diff --git a/src/backend/drive/settings.py b/src/backend/drive/settings.py index ae6e808b0..c173a985a 100755 --- a/src/backend/drive/settings.py +++ b/src/backend/drive/settings.py @@ -143,6 +143,15 @@ class Base(Configuration): MEDIA_ROOT = os.path.join(DATA_DIR, "media") MEDIA_BASE_URL = values.Value(None, environ_name="MEDIA_BASE_URL", environ_prefix=None) + # Request headers carrying the original URL of a media auth subrequest, tried in order. + # nginx ingress uses "X-Original-Url"; Traefik's ForwardAuth uses "X-Forwarded-Uri". + # Add other proxy header names here (or via the env var) as needed. + MEDIA_AUTH_FORWARD_HEADERS = values.ListValue( + ["X-Original-Url", "X-Forwarded-Uri"], + environ_name="MEDIA_AUTH_FORWARD_HEADERS", + environ_prefix=None, + ) + SITE_ID = 1 STORAGES = {