From be9848959a1bba391076d1517f9c19a15752652a Mon Sep 17 00:00:00 2001 From: Erin Shepherd Date: Mon, 3 Aug 2026 15:35:41 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20make=20forward=20auth=20re?= =?UTF-8?q?quest=20uri=20header=20configurable=20In=20our=20deployment=20w?= =?UTF-8?q?e're=20using=20Traefik,=20not=20nginx,=20as=20an=20ingress.=20T?= =?UTF-8?q?raefik=20uses=20X-Forwarded-Uri=20instead=20of=20X-Original-Uri?= =?UTF-8?q?.=20This=20adds=20a=20setting=20which=20lets=20users=20adapt=20?= =?UTF-8?q?Docs=20to=20their=20ingress=20proxy=20of=20choice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of https://github.com/suitenumerique/docs/pull/2241 to drive Signed-off-by: Erin Shepherd --- CHANGELOG.md | 1 + docs/env.md | 1 + src/backend/core/api/viewsets.py | 14 +++-- .../tests/items/test_api_items_media_auth.py | 51 ++++++++++++++++--- src/backend/drive/settings.py | 5 ++ 5 files changed, 63 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 797decfe6..45d3be443 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to ### Added +- ✨(backend) make the forward auth request uri header configurable - ✨(backend) make the upload ACL configurable to support GCS based storages - ✨(frontend) show the messages widget button on the homepage - ✨(frontend) open the messages widget from the help menu diff --git a/docs/env.md b/docs/env.md index 6f1062467..c5366a441 100644 --- a/docs/env.md +++ b/docs/env.md @@ -71,6 +71,7 @@ This document lists all configurable environment variables for the Drive applica | `LOGGING_LEVEL_LOGGERS_APP` | Logging level for application loggers | `INFO` | | `LOGGING_LEVEL_LOGGERS_ROOT` | Logging level for root logger | `INFO` | | `MAX_PAGE_SIZE` | Limit the maximum page size the client may request | `200` | +| `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` | | `MEDIA_BASE_URL` | Base URL for media files | `None` | | `OIDC_AUTH_REQUEST_EXTRA_PARAMS` | Extra parameters for OIDC auth requests | `{}` | | `OIDC_ALLOW_DUPLICATE_EMAILS` | Allow multiple users with same email | `False` | diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 0f9892c95..4e01f4f1b 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1623,7 +1623,10 @@ 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. - 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. @@ -1642,9 +1645,14 @@ def _authorize_subrequest(self, request, pattern): - 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.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() 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..e8ad8ed2a 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 @@ -6,7 +6,6 @@ from io import BytesIO from urllib.parse import quote, urlparse -from django.conf import settings from django.core.files.storage import default_storage from django.utils import timezone @@ -21,7 +20,7 @@ pytestmark = pytest.mark.django_db -def test_api_items_media_auth_anonymous_public(): +def test_api_items_media_auth_anonymous_public(settings): """Anonymous users should be able to retrieve attachments linked to a public item""" item = factories.ItemFactory( link_reach="public", @@ -78,7 +77,7 @@ def test_api_items_media_auth_anonymous_authenticated_or_restricted(reach): @pytest.mark.parametrize("reach", ["public", "authenticated"]) -def test_api_items_media_auth_authenticated_public_or_authenticated(reach): +def test_api_items_media_auth_authenticated_public_or_authenticated(reach, settings): """ Authenticated users who are not related to an item should be able to retrieve attachments related to an item with public or authenticated link reach. @@ -158,7 +157,7 @@ def test_api_items_media_auth_authenticated_restricted(): models.ItemUploadStateChoices.FILE_TOO_LARGE_TO_ANALYZE, ], ) -def test_api_items_media_auth_related(via, mock_user_teams, upload_state): +def test_api_items_media_auth_related(via, mock_user_teams, upload_state, settings): """ Users who have a specific access to an item, whatever the role, should be able to retrieve related attachments if not pending. @@ -209,7 +208,7 @@ def test_api_items_media_auth_related(via, mock_user_teams, upload_state): assert response.content.decode("utf-8") == "my prose" -def test_api_items_media_auth_related_filename_with_spaces(): +def test_api_items_media_auth_related_filename_with_spaces(settings): """ Users who have a specific access to an item, whatever the role, should be able to retrieve related attachments. @@ -360,7 +359,7 @@ def test_api_items_media_auth_suspicious_item_creator(): assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ") -def test_api_items_media_auth_filename_with_hash(): +def test_api_items_media_auth_filename_with_hash(settings): """Files with '#' in their filename should not cause a SignatureDoesNotMatch.""" item = factories.ItemFactory( link_reach="public", @@ -399,3 +398,43 @@ 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_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" + 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") + + s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL) + file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/drive-media-storage/{item.file_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/drive/settings.py b/src/backend/drive/settings.py index c12d1c45f..70d92cc56 100755 --- a/src/backend/drive/settings.py +++ b/src/backend/drive/settings.py @@ -142,6 +142,11 @@ class Base(Configuration): MEDIA_URL_PREVIEW = "/media/preview/" MEDIA_ROOT = os.path.join(DATA_DIR, "media") MEDIA_BASE_URL = values.Value(None, environ_name="MEDIA_BASE_URL", 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, + ) SITE_ID = 1