From 4fe879ddd2413a6597b5ce1d9f6fcbfd8792601b Mon Sep 17 00:00:00 2001 From: Normann Date: Sat, 26 Sep 2026 16:56:53 +0200 Subject: [PATCH] fix: allow EOSdash through an external reverse proxy (#1355) Support externally proxied EOSdash (#1320) * test: verify optional dashboard port stays unpublished * docs: describe optional proxy access and public URL --- config.yaml | 6 +- docs/_generated/configexample.md | 1 + docs/_generated/configserver.md | 2 + docs/akkudoktoreos/serverapi.md | 21 +++ docs/develop/install.md | 19 +++ openapi.json | 15 +++ src/akkudoktoreos/server/eos.py | 103 +++++++++++---- src/akkudoktoreos/server/rest/error.py | 37 +++++- src/akkudoktoreos/server/server.py | 38 ++++++ tests/test_homeassistant.py | 16 +-- tests/test_server.py | 172 +++++++++++++++++++++++-- 11 files changed, 382 insertions(+), 48 deletions(-) diff --git a/config.yaml b/config.yaml index 1d4f1b73..028352be 100644 --- a/config.yaml +++ b/config.yaml @@ -30,13 +30,15 @@ homeassistant: true homeassistant_api: true # Ports exposed by the add-on +# 8504 is listed without a host port, so it stays unpublished by default. Users that run +# an external reverse proxy can map it in the add-on network settings. ports: 8503/tcp: 8503 -# 8504/tcp: 8504 + 8504/tcp: null ports_description: 8503/tcp: "EOS REST server" -# 8504/tcp: "EOSdash dashboard server" + 8504/tcp: "EOSdash dashboard server (optional, needed for an external reverse proxy)" # EOSdash interface (if not ingress) # webui: "http://[HOST]:[PORT:8504]" diff --git a/docs/_generated/configexample.md b/docs/_generated/configexample.md index 1d46008d..71aca761 100644 --- a/docs/_generated/configexample.md +++ b/docs/_generated/configexample.md @@ -392,6 +392,7 @@ "startup_eosdash": true, "eosdash_host": "127.0.0.1", "eosdash_port": 8504, + "eosdash_public_url": "https://energy.example.com/dashboard", "eosdash_supervise_interval_sec": 10, "run_as_user": null, "reload": true diff --git a/docs/_generated/configserver.md b/docs/_generated/configserver.md index 6890ebba..4ff57aee 100644 --- a/docs/_generated/configserver.md +++ b/docs/_generated/configserver.md @@ -9,6 +9,7 @@ | ---- | -------------------- | ---- | --------- | ------- | ----------- | | eosdash_host | `EOS_SERVER__EOSDASH_HOST` | `str` | `rw` | `127.0.0.1` | EOSdash server IP address. Defaults to EOS server IP address. | | eosdash_port | `EOS_SERVER__EOSDASH_PORT` | `int` | `rw` | `8504` | EOSdash server IP port number. Defaults to 8504. | +| eosdash_public_url | `EOS_SERVER__EOSDASH_PUBLIC_URL` | `Optional[str]` | `rw` | `None` | Public EOSdash base URL for redirects and error-page links, including an optional proxy path prefix. Set this for reverse proxies or mapped ports; it does not change the bind address. Without it, direct access uses the request host and EOSdash port. Raw forwarded headers are not used. | | eosdash_supervise_interval_sec | `EOS_SERVER__EOSDASH_SUPERVISE_INTERVAL_SEC` | `int` | `rw` | `10` | Supervision interval for EOS server to supervise EOSdash [seconds]. | | host | `EOS_SERVER__HOST` | `str` | `rw` | `127.0.0.1` | EOS server IP address. Defaults to 127.0.0.1. | | port | `EOS_SERVER__PORT` | `int` | `rw` | `8503` | EOS server IP port number. Defaults to 8503. | @@ -33,6 +34,7 @@ "startup_eosdash": true, "eosdash_host": "127.0.0.1", "eosdash_port": 8504, + "eosdash_public_url": "https://energy.example.com/dashboard", "eosdash_supervise_interval_sec": 10, "run_as_user": null, "reload": true diff --git a/docs/akkudoktoreos/serverapi.md b/docs/akkudoktoreos/serverapi.md index 7f0dfdaa..2e84da26 100644 --- a/docs/akkudoktoreos/serverapi.md +++ b/docs/akkudoktoreos/serverapi.md @@ -8,3 +8,24 @@ :relative-docs: .. :relative-images: ``` + +## Dashboard redirects behind a reverse proxy + +For direct access, EOS redirects to the request host with the configured +`server.eosdash_port`. IPv4, hostnames and bracketed IPv6 addresses are supported. + +If a reverse proxy exposes EOSdash through HTTPS, another public port or a path +prefix, set `server.eosdash_public_url` to the externally reachable dashboard base +URL, for example `https://energy.example.com` or +`https://energy.example.com:9443/dashboard`. EOS preserves this scheme, port and +prefix for redirects and the dashboard link on error pages. Configure the proxy +to route that base URL to EOSdash; this setting does not configure the proxy or +change the dashboard's bind address. + +The base URL must not include credentials, a query or a fragment. A request for +`/eosdash/health` with the second example redirects to +`https://energy.example.com:9443/dashboard/eosdash/health`. Raw +`X-Forwarded-Host` and `X-Forwarded-Proto` headers do not override the configured +URL. Without an explicit public URL, scheme handling follows the ASGI server's +trusted-proxy configuration; EOS cannot infer an external dashboard route from +forwarding headers. diff --git a/docs/develop/install.md b/docs/develop/install.md index ed5a5d65..3899428c 100644 --- a/docs/develop/install.md +++ b/docs/develop/install.md @@ -337,6 +337,25 @@ In the dashboard, go to: Config ``` +### 6) Access EOSdash through an external reverse proxy (M5) + +Home Assistant Ingress needs no further setup. An external reverse proxy needs two +settings, because EOSdash runs on its own port: + +1. Map the optional add-on port `8504` in: + + ```bash + Settings → Add-ons → Akkudoktor-EOS → Configuration → Network + ``` + + The port is unpublished by default. Route the proxy to it and set + `server.eosdash_host` to `0.0.0.0`, so EOSdash accepts connections from the proxy. + +2. Set `server.eosdash_public_url` to the address the browser uses, for example + `https://eos.example.com:8504`. EOS redirects to that address instead of guessing one + from the request. Without it, EOS only redirects to hosts it knows, such as + `localhost` or its own IP address, and answers with an error page otherwise. + ## Helpful Docker Commands ### View logs diff --git a/openapi.json b/openapi.json index 362f12ee..661705d0 100644 --- a/openapi.json +++ b/openapi.json @@ -15076,6 +15076,21 @@ 8504 ] }, + "eosdash_public_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Eosdash Public Url", + "description": "Public EOSdash base URL for redirects and error-page links, including an optional proxy path prefix. Set this for reverse proxies or mapped ports; it does not change the bind address. Without it, direct access uses the request host and EOSdash port. Raw forwarded headers are not used.", + "examples": [ + "https://energy.example.com/dashboard" + ] + }, "eosdash_supervise_interval_sec": { "type": "integer", "title": "Eosdash Supervise Interval Sec", diff --git a/src/akkudoktoreos/server/eos.py b/src/akkudoktoreos/server/eos.py index 8bf834fd..621e98dc 100755 --- a/src/akkudoktoreos/server/eos.py +++ b/src/akkudoktoreos/server/eos.py @@ -10,6 +10,7 @@ import traceback from contextlib import asynccontextmanager from enum import Enum from typing import Annotated, Any, AsyncGenerator, Dict, List, Optional, Union +from urllib.parse import urlsplit import psutil import uvicorn @@ -2376,38 +2377,96 @@ def _sanitize_redirect_path(path: str) -> Optional[str]: return "/".join(parts) +def _trusted_request_hosts() -> set[str]: + """Host names that may be taken from a request to address EOSdash. + + The `Host` header is sent by the client and is not trusted. Reflecting it into an + absolute redirect would turn every EOS server into an open redirect. Only hosts that + the configuration already knows are accepted. Deployments behind a reverse proxy + announce their public address by `server.eosdash_public_url` instead. + + Returns: + set[str]: Lower case host names, without brackets around IPv6 addresses. + """ + settings = get_config().server + hosts = {"localhost", "127.0.0.1", "::1"} + for host in (settings.host, settings.eosdash_host): + if host and str(host) not in ("0.0.0.0", "::"): # noqa: S104 + hosts.add(str(host).lower()) + hosts.add(get_host_ip()) + return hosts + + +def _eosdash_base_url(request: Request) -> Optional[str]: + """Return the configured public dashboard URL or a direct-access URL. + + A proxy's public dashboard route cannot be inferred from its EOS API route. + Configure eosdash_public_url for TLS termination, port mappings or prefixes. + + Args: + request: The request to take the host from for direct access. + + Returns: + Optional[str]: Base URL of EOSdash without trailing slash. `None` if the request + host is not a trusted host and no public URL is configured. + """ + settings = get_config().server + if settings.eosdash_public_url: + return settings.eosdash_public_url.rstrip("/") + port = settings.eosdash_port or 8504 + scheme = request.url.scheme + if scheme not in ("http", "https"): + scheme = "http" + # Request.url honours the Host header and parses bracketed IPv6 correctly. + # Proxy scheme handling belongs to the ASGI server's trusted proxy middleware; + # do not trust arbitrary raw X-Forwarded-* headers here. + host = urlsplit(str(request.url)).hostname or "" + if not host or host in ("0.0.0.0", "::"): # noqa: S104 + host = str(settings.eosdash_host or settings.host) + if host in ("0.0.0.0", "::"): # noqa: S104 + host = get_host_ip() + if host.lower() not in _trusted_request_hosts(): + return None + if ":" in host: + host = f"[{host}]" + return f"{scheme}://{host}:{port}" + + +def _eosdash_unknown_page(request: Request, status_code: int) -> HTMLResponse: + """Error page for a request that can not be answered with an EOSdash address.""" + error_page = create_error_page( + status_code=str(status_code), + error_title="EOSdash Address Unknown", + error_message=( + f"URL is unknown: '{request.url}'. EOSdash can not be addressed for host " + f"'{request.url.netloc}'. Set 'server.eosdash_public_url' to the public " + "address of EOSdash." + ), + error_details="Untrusted request host", + ) + return HTMLResponse(content=error_page, status_code=status_code) + + def redirect(request: Request, path: str) -> Union[HTMLResponse, RedirectResponse]: + base_url = _eosdash_base_url(request) + # Path is not for EOSdash if not (path.startswith("eosdash") or path == ""): - host = get_config().server.eosdash_host - if host is None: - host = get_config().server.host - host = str(host) - port = get_config().server.eosdash_port - if port is None: - port = 8504 - if host == "0.0.0.0": # noqa: S104 - # Use IP of EOS host - host = get_host_ip() - url = f"http://{host}:{port}/" + if base_url is None: + return _eosdash_unknown_page(request, 404) error_page = create_error_page( status_code="404", error_title="Page Not Found", - error_message=f"""
-URL is unknown: '{request.url}'
-Did you want to connect to EOSdash?
-
-""", + error_message=f"URL is unknown: '{request.url}'. Did you want to connect to EOSdash?", error_details="Unknown URL", + link_url=f"{base_url}/", + link_label="Open EOSdash", ) return HTMLResponse(content=error_page, status_code=404) - host = str(get_config().server.eosdash_host) - if host == "0.0.0.0": # noqa: S104 - # Use IP of EOS host - host = get_host_ip() - if host and get_config().server.eosdash_port: - base_url = f"http://{host}:{get_config().server.eosdash_port}" + if get_config().server.eosdash_port: + if base_url is None: + return _eosdash_unknown_page(request, 404) safe_path = _sanitize_redirect_path(path) or "" url = f"{base_url}/{safe_path}" return RedirectResponse(url=url, status_code=303) diff --git a/src/akkudoktoreos/server/rest/error.py b/src/akkudoktoreos/server/rest/error.py index 651f7234..8969d0b1 100644 --- a/src/akkudoktoreos/server/rest/error.py +++ b/src/akkudoktoreos/server/rest/error.py @@ -1,7 +1,7 @@ import html import traceback from dataclasses import dataclass -from typing import cast +from typing import Optional, cast from fastapi import FastAPI, Request from fastapi.exceptions import HTTPException, RequestValidationError @@ -186,6 +186,7 @@ ERROR_PAGE_TEMPLATE = """

ERROR_TITLE

ERROR_MESSAGE

ERROR_DETAILS
+ ERROR_LINK Back to Home @@ -194,11 +195,39 @@ ERROR_PAGE_TEMPLATE = """ def create_error_page( - status_code: str, error_title: str, error_message: str, error_details: str + status_code: str, + error_title: str, + error_message: str, + error_details: str, + link_url: Optional[str] = None, + link_label: str = "Open link", ) -> str: - """Create an error page by replacing placeholders in the template.""" + """Create an error page by replacing placeholders in the template. + + The message and the details are escaped, so they are always shown as text. Markup in + them is not rendered. Use `link_url` to offer a link on the page. + + Args: + status_code: The HTTP status code to display. + error_title: The title of the error. + error_message: The error message, shown as text. + error_details: The error details, shown as text. + link_url: Target of an extra link on the page. No link is added for `None`. + link_label: The label of the extra link. + + Returns: + str: The error page as HTML. + """ + link = "" + if link_url: + link = ( + f'' + f"{html.escape(link_label)}" + ) + # Insert the link first, so escaped text of the other placeholders is never replaced. return ( - ERROR_PAGE_TEMPLATE.replace("STATUS_CODE", status_code) + ERROR_PAGE_TEMPLATE.replace("ERROR_LINK", link) + .replace("STATUS_CODE", status_code) .replace("ERROR_TITLE", error_title) .replace("ERROR_MESSAGE", html.escape(error_message)) .replace("ERROR_DETAILS", html.escape(error_details)) diff --git a/src/akkudoktoreos/server/server.py b/src/akkudoktoreos/server/server.py index df0b4a91..84abff63 100644 --- a/src/akkudoktoreos/server/server.py +++ b/src/akkudoktoreos/server/server.py @@ -8,6 +8,7 @@ import socket import sys import time from typing import Any, Optional +from urllib.parse import urlsplit try: # Only available on Linux/Unix type systems @@ -429,6 +430,18 @@ class ServerCommonSettings(SettingsBaseModel): ], }, ) + eosdash_public_url: Optional[str] = Field( + default=None, + json_schema_extra={ + "description": ( + "Public EOSdash base URL for redirects and error-page links, including an " + "optional proxy path prefix. Set this for reverse proxies or mapped ports; " + "it does not change the bind address. Without it, direct access uses the " + "request host and EOSdash port. Raw forwarded headers are not used." + ), + "examples": ["https://energy.example.com/dashboard"], + }, + ) eosdash_supervise_interval_sec: int = Field( default=10, json_schema_extra={ @@ -464,6 +477,31 @@ class ServerCommonSettings(SettingsBaseModel): }, ) + @field_validator("eosdash_public_url") + @classmethod + def validate_eosdash_public_url(cls, value: Optional[str]) -> Optional[str]: + """Require an absolute HTTP(S) base URL without credentials or query data.""" + if value is None: + return None + parsed = urlsplit(value) + if ( + parsed.scheme not in ("http", "https") + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + or "?" in value + or "#" in value + or "\\" in value + or any(character.isspace() or ord(character) < 32 for character in value) + ): + raise ValueError("EOSdash public URL must be an absolute HTTP(S) base URL.") + # Accessing port also validates its numeric range. + if parsed.port == 0: + raise ValueError("EOSdash public URL port must be positive.") + return value.rstrip("/") + @field_validator("host", "eosdash_host", mode="before") def validate_server_host(cls, value: Optional[str]) -> Optional[str]: if isinstance(value, str): diff --git a/tests/test_homeassistant.py b/tests/test_homeassistant.py index 12b48ed3..ef8798d4 100644 --- a/tests/test_homeassistant.py +++ b/tests/test_homeassistant.py @@ -284,11 +284,11 @@ class TestHomeAssistantAddon: assert isinstance(ingress_port, int), "ingress_port must be an integer" assert 1 <= ingress_port <= 65535, "ingress_port must be a valid port number" - # Ingress port should NOT be in ports section + # The dashboard port is optional and has no host mapping by default. ports = cfg.get("ports", {}) port_key = f"{ingress_port}/tcp" - assert port_key not in ports, \ - f"Port {ingress_port} is used for ingress and should not be in 'ports' section" + assert port_key in ports, f"Port {ingress_port} must be configurable in 'ports'" + assert ports[port_key] is None, "Ingress port must be unpublished by default" # Validate URL if present if "url" in cfg: @@ -330,14 +330,10 @@ class TestHomeAssistantAddon: ingress_port = cfg["ingress_port"] - # The ingress port should NOT be in the ports section + # A null mapping lets users opt in to host publication while preserving ingress. ports = cfg.get("ports", {}) port_key = f"{ingress_port}/tcp" - - if port_key in ports: - pytest.fail( - f"Port {ingress_port} is used for ingress but also listed in 'ports' section. " - f"Remove it from 'ports' to avoid conflicts." - ) + assert port_key in ports, f"Port {ingress_port} must be configurable in 'ports'" + assert ports[port_key] is None, "Ingress port must be unpublished by default" print(f"✓ Ingress configuration valid (port {ingress_port})") diff --git a/tests/test_server.py b/tests/test_server.py index 1a521683..71f3fd28 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,6 +1,7 @@ import asyncio import json import os +import re import time from http import HTTPStatus from pathlib import Path @@ -18,6 +19,7 @@ from akkudoktoreos.server.server import ( ServerCommonSettings, get_default_host, get_default_port, + get_host_ip, wait_for_port_free, ) @@ -56,23 +58,27 @@ class TestServerSettingsValidation: def test_ha_addon_default_ports_ok(self, config_eos, monkeypatch): """Default ports are accepted in HA addon mode.""" - monkeypatch.setattr('akkudoktoreos.server.server.is_home_assistant_addon', lambda: True) - assert config_eos.server.port == get_default_port() # 8503 + monkeypatch.setattr("akkudoktoreos.server.server.is_home_assistant_addon", lambda: True) + assert config_eos.server.port == get_default_port() # 8503 assert config_eos.server.eosdash_port == get_default_port() + 1 # 8504 def test_server_port_restriction_in_ha_addon(self, config_eos, monkeypatch): """Server port must be the default (8503) in HA addon mode.""" - monkeypatch.setattr('akkudoktoreos.server.server.is_home_assistant_addon', lambda: True) + monkeypatch.setattr("akkudoktoreos.server.server.is_home_assistant_addon", lambda: True) with pytest.raises(ValidationError) as excinfo: config_eos.server.port = 9000 - assert "Server port number `8503` for Home Assistant add-on can not be changed" in str(excinfo.value) + assert "Server port number `8503` for Home Assistant add-on can not be changed" in str( + excinfo.value + ) def test_eosdash_port_restriction_in_ha_addon(self, config_eos, monkeypatch): """EOSdash port must be the default (8504) in HA addon mode.""" - monkeypatch.setattr('akkudoktoreos.server.server.is_home_assistant_addon', lambda: True) + monkeypatch.setattr("akkudoktoreos.server.server.is_home_assistant_addon", lambda: True) with pytest.raises(ValidationError) as excinfo: config_eos.server.eosdash_port = 9001 - assert "EOSdash port number `8504` for Home Assistant add-on can not be changed" in str(excinfo.value) + assert "EOSdash port number `8504` for Home Assistant add-on can not be changed" in str( + excinfo.value + ) def test_ports_allowed_when_not_ha_addon(self, config_eos): """Custom ports are allowed when not in HA addon mode.""" @@ -84,7 +90,6 @@ class TestServerSettingsValidation: class TestServerStartStop: - @pytest.mark.asyncio async def test_forward_stream_truncates_very_long_line(self, monkeypatch, tmp_path): """Test logging from EOSdash can also handle very long lines.""" @@ -180,7 +185,9 @@ class TestServerStartStop: eosdash_server = f"http://{config_eos.server.eosdash_host}:{config_eos.server.eosdash_port}" # Port may be blocked - assert wait_for_port_free(config_eos.server.eosdash_port, timeout=120, waiting_app_name="EOSdash") + assert wait_for_port_free( + config_eos.server.eosdash_port, timeout=120, waiting_app_name="EOSdash" + ) owned_processes: list[psutil.Process] = [] try: @@ -359,7 +366,9 @@ class TestServerWithEnv: """Ensure server is started with environment passed to configuration.""" server = server_setup_for_class["server"] - assert server_setup_for_class["eosdash_port"] == int(self.eos_env["EOS_SERVER__EOSDASH_PORT"]) + assert server_setup_for_class["eosdash_port"] == int( + self.eos_env["EOS_SERVER__EOSDASH_PORT"] + ) result = requests.get(f"{server}/v1/config") assert result.status_code == HTTPStatus.OK @@ -368,4 +377,147 @@ class TestServerWithEnv: config_json = result.json() # Assure config got configuration from environment - assert config_json["server"]["eosdash_port"] == int(self.eos_env["EOS_SERVER__EOSDASH_PORT"]) + assert config_json["server"]["eosdash_port"] == int( + self.eos_env["EOS_SERVER__EOSDASH_PORT"] + ) + + +class TestEosdashRedirect: + """Redirects to EOSdash must target an address the client can reach. + + See https://github.com/Akkudoktor-EOS/EOS/issues/1320: the redirect was built from + the EOSdash bind address, so remote clients were sent to their own localhost. + """ + + @pytest.fixture + def client(self, config_eos): + from fastapi.testclient import TestClient + + from akkudoktoreos.server.eos import app + + config_eos.server.eosdash_host = "127.0.0.1" + config_eos.server.eosdash_port = 8504 + return TestClient(app, follow_redirects=False) + + @pytest.mark.parametrize("host", ["localhost:8503", "127.0.0.1:8503"]) + def test_root_redirect_uses_request_host(self, client, host): + """The root redirect points to the host the client used, not to the bind address.""" + response = client.get("/", headers={"Host": host}) + assert response.status_code == HTTPStatus.SEE_OTHER + assert response.headers["location"] == f"http://{host.split(':')[0]}:8504/" + + def test_root_redirect_uses_host_ip_of_the_eos_machine(self, client): + """Access by the IP address of the EOS machine redirects to that address.""" + host_ip = get_host_ip() + response = client.get("/", headers={"Host": f"{host_ip}:8503"}) + assert response.status_code == HTTPStatus.SEE_OTHER + assert response.headers["location"] == f"http://{host_ip}:8504/" + + def test_root_redirect_ignores_untrusted_forwarded_headers(self, client): + """Raw forwarding headers cannot override the public dashboard address.""" + response = client.get( + "/", + headers={ + "Host": "localhost", + "X-Forwarded-Host": "eos.example.com", + "X-Forwarded-Proto": "https", + }, + ) + assert response.status_code == HTTPStatus.SEE_OTHER + assert response.headers["location"] == "http://localhost:8504/" + + def test_root_redirect_keeps_local_host(self, client): + """Local access still redirects to the local EOSdash.""" + response = client.get("/", headers={"Host": "127.0.0.1:8503"}) + assert response.status_code == HTTPStatus.SEE_OTHER + assert response.headers["location"] == "http://127.0.0.1:8504/" + + def test_untrusted_request_host_is_not_reflected(self, client): + """An unknown Host header must not become the redirect target.""" + response = client.get("/", headers={"Host": "attacker.example"}) + assert response.status_code == HTTPStatus.NOT_FOUND + assert "attacker.example:8504" not in response.text + assert "eosdash_public_url" in response.text + + def test_untrusted_request_host_on_unknown_path(self, client): + """The 404 page offers no link for an unknown Host header.""" + response = client.get("/no-such-page", headers={"Host": "attacker.example"}) + assert response.status_code == HTTPStatus.NOT_FOUND + assert "attacker.example:8504" not in response.text + + def test_eosdash_path_redirect_keeps_path(self, client): + """The path is preserved when redirecting to EOSdash.""" + response = client.get("/eosdash/health", headers={"Host": "localhost:8503"}) + assert response.status_code == HTTPStatus.SEE_OTHER + assert response.headers["location"] == "http://localhost:8504/eosdash/health" + + def test_unknown_path_error_page_links_to_request_host(self, client): + """The 404 page links to EOSdash on the host the client used.""" + response = client.get("/no-such-page", headers={"Host": "localhost:8503"}) + assert response.status_code == HTTPStatus.NOT_FOUND + # The link must be real HTML, the error page escapes the message it is given. + assert "<a href" not in response.text + # Compare the whole link target, a substring check would also accept a foreign host. + hrefs = [href for href in re.findall(r'href="([^"]*)"', response.text) if href != "/docs"] + assert hrefs == ["http://localhost:8504/"] + + def test_error_page_escapes_request_url(self, client): + """A crafted URL is shown as text, never as markup.""" + response = client.get( + "/%3Cscript%3Ealert(1)%3C/script%3E", headers={"Host": "localhost:8503"} + ) + assert response.status_code == HTTPStatus.NOT_FOUND + assert "" not in response.text + + @pytest.mark.parametrize("host", ["[::1]", "[::1]:8503"]) + def test_direct_ipv6_preserves_address(self, client, host): + """An IPv6 address keeps its brackets in the redirect.""" + response = client.get("/", headers={"Host": host}) + assert response.headers["location"] == "http://[::1]:8504/" + + def test_untrusted_ipv6_host_is_not_reflected(self, client): + """An IPv6 address that the configuration does not know is not reflected.""" + response = client.get("/", headers={"Host": "[2001:db8::1234]:8503"}) + assert response.status_code == HTTPStatus.NOT_FOUND + assert "2001:db8::1234" not in response.headers.get("location", "") + + @pytest.mark.parametrize( + "public_url", + [ + "https://energy.example.com", + "https://energy.example.com:443", + "https://energy.example.com:9443/dashboard", + "https://[2001:db8::1234]/dashboard", + ], + ) + def test_public_url_preserves_proxy_port_and_prefix(self, client, config_eos, public_url): + config_eos.server.eosdash_public_url = public_url + "/" + headers = {"Host": "internal:8503", "X-Forwarded-Host": "wrong.example"} + response = client.get("/", headers=headers) + assert response.headers["location"] == public_url + "/" + response = client.get("/eosdash/health", headers=headers) + assert response.headers["location"] == public_url + "/eosdash/health" + response = client.get("/missing", headers=headers) + hrefs = [href for href in re.findall(r'href="([^"]*)"', response.text) if href != "/docs"] + assert hrefs == [public_url + "/"] + + @pytest.mark.parametrize( + "value", + [ + "", + "/dashboard", + "//example.com", + "ftp://example.com", + "https://user:password@example.com", + "https://example.com?token=a", + "https://example.com#fragment", + "https://example.com:99999", + "https://example.com:0", + "https://example.com\\evil", + "https://example.com/\nheader", + "https://example.com/a b", + ], + ) + def test_invalid_public_url_rejected(self, config_eos, value): + with pytest.raises(ValueError): + config_eos.server.eosdash_public_url = value