Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 19 additions & 5 deletions src/backend/core/api/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
55 changes: 55 additions & 0 deletions src/backend/core/tests/items/test_api_items_media_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 9 additions & 0 deletions src/backend/drive/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down