From 394fbc5537cd27b1652f31c992dabc749ff9e206 Mon Sep 17 00:00:00 2001 From: Erin Date: Tue, 28 Apr 2026 10:57:19 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20make=20forward=20auth=20re?= =?UTF-8?q?quest=20uri=20header=20configurable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In deployment, Traefik is used, not nginx, as an ingress. Traefik uses `X-Forwarded-Ur`i instead of `X-Original-Url`. This adds a setting which lets users adapt Docs to their ingress proxy of choice The settings name is MEDIA_AUTH_ORIGINAL_URL_HEADER Signed-off-by: Erin Shepherd --- CHANGELOG.md | 1 + docs/env.md | 1 + src/backend/core/api/viewsets.py | 16 +++-- .../test_api_documents_media_auth.py | 61 +++++++++++++++++-- src/backend/impress/settings.py | 6 ++ 5 files changed, 75 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3174cdcce..3e65d6b2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to - ♿️(frontend) structure correctly 5xx error alerts #2128 - ♿️(frontend) make doc search result labels uniquely identifiable #2212 - ⬆️(backend) upgrade docspec to v3.0.x and adapt converter API #2220 +- ✨(backend) make forward auth request uri header configurable #2241 ### Fixed diff --git a/docs/env.md b/docs/env.md index 2dad89ecf..67f09c866 100644 --- a/docs/env.md +++ b/docs/env.md @@ -91,6 +91,7 @@ These are the environment variables you can set for the `impress-backend` contai | MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend | | MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} | | MEDIA_BASE_URL | | | +| MEDIA_AUTH_ORIGINAL_URL_HEADER | Parameter containing the original request URL, as seen at the media auth endpoint, in CGI/WSGI form (HTTP_HEADER_NAME_ALL_CAPS_WITH_UNDERSCORES) | HTTP_X_ORIGINAL_URL | | NO_WEBSOCKET_CACHE_TIMEOUT | Cache used to store current editor session key when only users without websocket are editing a document | 120 | | OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false | | OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} | diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index ecae88f2f..7d19f7c03 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1752,10 +1752,13 @@ class DocumentViewSet( 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 parameter 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 reverse proxy in the header specified by the + MEDIA_AUTH_ORIGINAL_URL_HEADER setting. + + For nginx (the default) this is set 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. @@ -1766,9 +1769,14 @@ class DocumentViewSet( 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.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest") + logger.debug( + "Missing %s header in subrequest. " + "Maybe you need to set MEDIA_AUTH_ORIGINAL_URL_HEADER correctly for your ingress" + " proxy.", + settings.MEDIA_AUTH_ORIGINAL_URL_HEADER, + ) raise drf.exceptions.PermissionDenied() logger.debug("Original url: '%s'", original_url) diff --git a/src/backend/core/tests/documents/test_api_documents_media_auth.py b/src/backend/core/tests/documents/test_api_documents_media_auth.py index ee76ef944..35ea8b2b6 100644 --- a/src/backend/core/tests/documents/test_api_documents_media_auth.py +++ b/src/backend/core/tests/documents/test_api_documents_media_auth.py @@ -6,7 +6,6 @@ from io import BytesIO from urllib.parse import urlparse from uuid import uuid4 -from django.conf import settings from django.core.files.storage import default_storage from django.utils import timezone @@ -37,7 +36,7 @@ def test_api_documents_media_auth_unkown_document(): assert models.Document.objects.exists() is False -def test_api_documents_media_auth_anonymous_public(): +def test_api_documents_media_auth_anonymous_public(settings): """Anonymous users should be able to retrieve attachments linked to a public document""" document_id = uuid4() filename = f"{uuid4()!s}.jpg" @@ -139,7 +138,7 @@ def test_api_documents_media_auth_anonymous_authenticated_or_restricted(reach): assert "Authorization" not in response -def test_api_documents_media_auth_anonymous_attachments(): +def test_api_documents_media_auth_anonymous_attachments(settings): """ Declaring a media key as original attachment on a document to which a user has access should give them access to the attachment file @@ -202,7 +201,9 @@ def test_api_documents_media_auth_anonymous_attachments(): @pytest.mark.parametrize("reach", ["public", "authenticated"]) -def test_api_documents_media_auth_authenticated_public_or_authenticated(reach): +def test_api_documents_media_auth_authenticated_public_or_authenticated( + reach, settings +): """ Authenticated users who are not related to a document should be able to retrieve attachments related to a document with public or authenticated link reach. @@ -284,7 +285,7 @@ def test_api_documents_media_auth_authenticated_restricted(): @pytest.mark.parametrize("via", VIA) -def test_api_documents_media_auth_related(via, mock_user_teams): +def test_api_documents_media_auth_related(via, mock_user_teams, settings): """ Users who have a specific access to a document, whatever the role, should be able to retrieve related attachments. @@ -368,7 +369,7 @@ def test_api_documents_media_auth_not_ready_status(): assert response.status_code == 403 -def test_api_documents_media_auth_missing_status_metadata(): +def test_api_documents_media_auth_missing_status_metadata(settings): """Attachments without status metadata should be considered as ready""" document_id = uuid4() filename = f"{uuid4()!s}.jpg" @@ -412,3 +413,51 @@ def test_api_documents_media_auth_missing_status_metadata(): timeout=1, ) assert response.content.decode("utf-8") == "my prose" + + +def test_api_documents_media_auth_anonymous_public_custom_origin_header(settings): + """Changing the setting MEDIA_AUTH_ORIGINAL_URL_HEADER to match other header should work""" + settings.MEDIA_AUTH_ORIGINAL_URL_HEADER = "HTTP_X_FORWARDED_URI" + document_id = uuid4() + filename = f"{uuid4()!s}.jpg" + key = f"{document_id!s}/attachments/{filename:s}" + default_storage.connection.meta.client.put_object( + Bucket=default_storage.bucket_name, + Key=key, + Body=BytesIO(b"my prose"), + ContentType="text/plain", + Metadata={"status": DocumentAttachmentStatus.READY}, + ) + + factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key]) + + original_url = f"http://localhost/media/{key:s}" + now = timezone.now() + with freeze_time(now): + response = APIClient().get( + "/api/v1.0/documents/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") + + s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL) + file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}" + response = requests.get( + file_url, + headers={ + "authorization": authorization, + "x-amz-date": response["x-amz-date"], + "x-amz-content-sha256": response["x-amz-content-sha256"], + "Host": f"{s3_url.hostname:s}:{s3_url.port:d}", + }, + timeout=1, + ) + assert response.content.decode("utf-8") == "my prose" diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index d1bd723da..4a7360231 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -130,6 +130,12 @@ class Base(Configuration): default=50, environ_name="SEARCH_INDEXER_QUERY_LIMIT", environ_prefix=None ) + MEDIA_AUTH_ORIGINAL_URL_HEADER = values.Value( + default="HTTP_X_ORIGINAL_URL", + environ_name="MEDIA_AUTH_ORIGINAL_URL_HEADER", + environ_prefix=None, + ) + # Static files (CSS, JavaScript, Images) STATIC_URL = "/static/" STATIC_ROOT = os.path.join(DATA_DIR, "static")