diff --git a/docs/oidc.md b/docs/oidc.md index 7f85b397..60ca2896 100644 --- a/docs/oidc.md +++ b/docs/oidc.md @@ -12,7 +12,7 @@ With a subpath (`URL_BASE=/shelfmark/`): https:///shelfmark/api/auth/oidc/callback ``` -The callback URL is constructed from the incoming request, so your reverse proxy must forward `X-Forwarded-Proto` and `X-Forwarded-Host` correctly. PKCE (S256) is used automatically. +The callback URL is constructed from the incoming request, so your reverse proxy must forward `X-Forwarded-Proto` and `X-Forwarded-Host` correctly, including the external port when it is not the protocol default. PKCE (S256) is used automatically. ## Settings diff --git a/docs/reverse-proxy.md b/docs/reverse-proxy.md index 294fa2b5..5282bbc9 100644 --- a/docs/reverse-proxy.md +++ b/docs/reverse-proxy.md @@ -23,10 +23,11 @@ server { location / { proxy_pass http://shelfmark:8084; proxy_http_version 1.1; - proxy_set_header Host $host; + proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $http_host; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; } @@ -56,11 +57,11 @@ All Shelfmark paths (UI, API, assets, Socket.IO) are served under the base path. location /shelfmark/ { proxy_pass http://shelfmark:8084/shelfmark/; proxy_http_version 1.1; - proxy_set_header Host $host; + proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Host $http_host; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_read_timeout 86400; @@ -136,11 +137,11 @@ location /shelfmark/ { proxy_pass http://shelfmark:8084/shelfmark/; proxy_http_version 1.1; - proxy_set_header Host $host; + proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Host $http_host; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_read_timeout 86400; @@ -158,6 +159,7 @@ If login, settings saves, or downloads appear to fail in the browser but the act - Do not force `Connection: upgrade` on every request. That can break normal `POST` and `PUT` responses while the backend still processes them. - If your proxy UI does not support conditional websocket headers, remove the forced websocket headers entirely and let Shelfmark fall back to polling. - Keep the standard forwarded headers: `Host`, `X-Forwarded-For`, `X-Forwarded-Proto`, and `X-Forwarded-Host` when using a subpath or OIDC. +- Preserve the original port in `Host` and `X-Forwarded-Host` by using `$http_host` rather than `$host` when Shelfmark is exposed on a custom port. This is especially relevant for Nginx Proxy Manager or custom advanced config snippets that add websocket headers globally. diff --git a/shelfmark/main.py b/shelfmark/main.py index 46fdbff9..0cb94d11 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -118,7 +118,7 @@ BASE_PATH = normalize_base_path(normalize_optional_text(app_config.get("URL_BASE app = Flask(__name__) app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 0 # Disable caching app.config["APPLICATION_ROOT"] = BASE_PATH or "/" -wsgi_app = cast(Any, ProxyFix(app.wsgi_app)) +wsgi_app = cast(Any, ProxyFix(app.wsgi_app, x_host=1, x_port=1)) if BASE_PATH: wsgi_app = cast(Any, PrefixMiddleware(wsgi_app, BASE_PATH, bypass_paths={"/api/health"})) app.wsgi_app = wsgi_app diff --git a/tests/core/test_proxy_headers.py b/tests/core/test_proxy_headers.py new file mode 100644 index 00000000..629b9cc6 --- /dev/null +++ b/tests/core/test_proxy_headers.py @@ -0,0 +1,46 @@ +import importlib +from unittest.mock import Mock, patch + +import pytest + + +@pytest.fixture(scope="module") +def main_module(): + with patch("shelfmark.download.orchestrator.start"): + import shelfmark.main as main + + importlib.reload(main) + return main + + +@pytest.mark.parametrize( + "headers", + [ + { + "X-Forwarded-Proto": "https", + "X-Forwarded-Host": "library.example.com:12345", + }, + { + "X-Forwarded-Proto": "https", + "X-Forwarded-Host": "library.example.com", + "X-Forwarded-Port": "12345", + }, + ], +) +def test_oidc_redirect_uses_forwarded_external_port(main_module, headers): + oidc_client = Mock() + oidc_client.authorize_redirect.return_value = ("", 302) + + with patch( + "shelfmark.core.oidc_routes._get_oidc_client", + return_value=(oidc_client, {}), + ): + response = main_module.app.test_client().get( + "/api/auth/oidc/login", + headers=headers, + ) + + assert response.status_code == 302 + oidc_client.authorize_redirect.assert_called_once_with( + "https://library.example.com:12345/api/auth/oidc/callback" + )