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
This commit is contained in:
Normann
2026-09-26 16:56:53 +02:00
committed by GitHub
parent 3d2daa694b
commit 4fe879ddd2
11 changed files with 382 additions and 48 deletions
+4 -2
View File
@@ -30,13 +30,15 @@ homeassistant: true
homeassistant_api: true homeassistant_api: true
# Ports exposed by the add-on # 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: ports:
8503/tcp: 8503 8503/tcp: 8503
# 8504/tcp: 8504 8504/tcp: null
ports_description: ports_description:
8503/tcp: "EOS REST server" 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) # EOSdash interface (if not ingress)
# webui: "http://[HOST]:[PORT:8504]" # webui: "http://[HOST]:[PORT:8504]"
+1
View File
@@ -392,6 +392,7 @@
"startup_eosdash": true, "startup_eosdash": true,
"eosdash_host": "127.0.0.1", "eosdash_host": "127.0.0.1",
"eosdash_port": 8504, "eosdash_port": 8504,
"eosdash_public_url": "https://energy.example.com/dashboard",
"eosdash_supervise_interval_sec": 10, "eosdash_supervise_interval_sec": 10,
"run_as_user": null, "run_as_user": null,
"reload": true "reload": true
+2
View File
@@ -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_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_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]. | | 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. | | 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. | | port | `EOS_SERVER__PORT` | `int` | `rw` | `8503` | EOS server IP port number. Defaults to 8503. |
@@ -33,6 +34,7 @@
"startup_eosdash": true, "startup_eosdash": true,
"eosdash_host": "127.0.0.1", "eosdash_host": "127.0.0.1",
"eosdash_port": 8504, "eosdash_port": 8504,
"eosdash_public_url": "https://energy.example.com/dashboard",
"eosdash_supervise_interval_sec": 10, "eosdash_supervise_interval_sec": 10,
"run_as_user": null, "run_as_user": null,
"reload": true "reload": true
+21
View File
@@ -8,3 +8,24 @@
:relative-docs: .. :relative-docs: ..
:relative-images: :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.
+19
View File
@@ -337,6 +337,25 @@ In the dashboard, go to:
Config 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 ## Helpful Docker Commands
### View logs ### View logs
+15
View File
@@ -15076,6 +15076,21 @@
8504 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": { "eosdash_supervise_interval_sec": {
"type": "integer", "type": "integer",
"title": "Eosdash Supervise Interval Sec", "title": "Eosdash Supervise Interval Sec",
+81 -22
View File
@@ -10,6 +10,7 @@ import traceback
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from enum import Enum from enum import Enum
from typing import Annotated, Any, AsyncGenerator, Dict, List, Optional, Union from typing import Annotated, Any, AsyncGenerator, Dict, List, Optional, Union
from urllib.parse import urlsplit
import psutil import psutil
import uvicorn import uvicorn
@@ -2376,38 +2377,96 @@ def _sanitize_redirect_path(path: str) -> Optional[str]:
return "/".join(parts) 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]: def redirect(request: Request, path: str) -> Union[HTMLResponse, RedirectResponse]:
base_url = _eosdash_base_url(request)
# Path is not for EOSdash # Path is not for EOSdash
if not (path.startswith("eosdash") or path == ""): if not (path.startswith("eosdash") or path == ""):
host = get_config().server.eosdash_host if base_url is None:
if host is None: return _eosdash_unknown_page(request, 404)
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}/"
error_page = create_error_page( error_page = create_error_page(
status_code="404", status_code="404",
error_title="Page Not Found", error_title="Page Not Found",
error_message=f"""<pre> error_message=f"URL is unknown: '{request.url}'. Did you want to connect to EOSdash?",
URL is unknown: '{request.url}'
Did you want to connect to <a href="{url}" class="back-button">EOSdash</a>?
</pre>
""",
error_details="Unknown URL", error_details="Unknown URL",
link_url=f"{base_url}/",
link_label="Open EOSdash",
) )
return HTMLResponse(content=error_page, status_code=404) return HTMLResponse(content=error_page, status_code=404)
host = str(get_config().server.eosdash_host) if get_config().server.eosdash_port:
if host == "0.0.0.0": # noqa: S104 if base_url is None:
# Use IP of EOS host return _eosdash_unknown_page(request, 404)
host = get_host_ip()
if host and get_config().server.eosdash_port:
base_url = f"http://{host}:{get_config().server.eosdash_port}"
safe_path = _sanitize_redirect_path(path) or "" safe_path = _sanitize_redirect_path(path) or ""
url = f"{base_url}/{safe_path}" url = f"{base_url}/{safe_path}"
return RedirectResponse(url=url, status_code=303) return RedirectResponse(url=url, status_code=303)
+33 -4
View File
@@ -1,7 +1,7 @@
import html import html
import traceback import traceback
from dataclasses import dataclass from dataclasses import dataclass
from typing import cast from typing import Optional, cast
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.exceptions import HTTPException, RequestValidationError from fastapi.exceptions import HTTPException, RequestValidationError
@@ -186,6 +186,7 @@ ERROR_PAGE_TEMPLATE = """
<h2 class="error-title">ERROR_TITLE</h2> <h2 class="error-title">ERROR_TITLE</h2>
<p class="error-message">ERROR_MESSAGE</p> <p class="error-message">ERROR_MESSAGE</p>
<div class="error-details">ERROR_DETAILS</div> <div class="error-details">ERROR_DETAILS</div>
ERROR_LINK
<a href="/docs" class="back-button">Back to Home</a> <a href="/docs" class="back-button">Back to Home</a>
</div> </div>
</body> </body>
@@ -194,11 +195,39 @@ ERROR_PAGE_TEMPLATE = """
def create_error_page( 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: ) -> 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'<a href="{html.escape(link_url, quote=True)}" class="back-button">'
f"{html.escape(link_label)}</a>"
)
# Insert the link first, so escaped text of the other placeholders is never replaced.
return ( 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_TITLE", error_title)
.replace("ERROR_MESSAGE", html.escape(error_message)) .replace("ERROR_MESSAGE", html.escape(error_message))
.replace("ERROR_DETAILS", html.escape(error_details)) .replace("ERROR_DETAILS", html.escape(error_details))
+38
View File
@@ -8,6 +8,7 @@ import socket
import sys import sys
import time import time
from typing import Any, Optional from typing import Any, Optional
from urllib.parse import urlsplit
try: try:
# Only available on Linux/Unix type systems # 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( eosdash_supervise_interval_sec: int = Field(
default=10, default=10,
json_schema_extra={ 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") @field_validator("host", "eosdash_host", mode="before")
def validate_server_host(cls, value: Optional[str]) -> Optional[str]: def validate_server_host(cls, value: Optional[str]) -> Optional[str]:
if isinstance(value, str): if isinstance(value, str):
+6 -10
View File
@@ -284,11 +284,11 @@ class TestHomeAssistantAddon:
assert isinstance(ingress_port, int), "ingress_port must be an integer" assert isinstance(ingress_port, int), "ingress_port must be an integer"
assert 1 <= ingress_port <= 65535, "ingress_port must be a valid port number" 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", {}) ports = cfg.get("ports", {})
port_key = f"{ingress_port}/tcp" port_key = f"{ingress_port}/tcp"
assert port_key not in ports, \ assert port_key in ports, f"Port {ingress_port} must be configurable in 'ports'"
f"Port {ingress_port} is used for ingress and should not be in 'ports' section" assert ports[port_key] is None, "Ingress port must be unpublished by default"
# Validate URL if present # Validate URL if present
if "url" in cfg: if "url" in cfg:
@@ -330,14 +330,10 @@ class TestHomeAssistantAddon:
ingress_port = cfg["ingress_port"] 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", {}) ports = cfg.get("ports", {})
port_key = f"{ingress_port}/tcp" port_key = f"{ingress_port}/tcp"
assert port_key in ports, f"Port {ingress_port} must be configurable in 'ports'"
if port_key in ports: assert ports[port_key] is None, "Ingress port must be unpublished by default"
pytest.fail(
f"Port {ingress_port} is used for ingress but also listed in 'ports' section. "
f"Remove it from 'ports' to avoid conflicts."
)
print(f"✓ Ingress configuration valid (port {ingress_port})") print(f"✓ Ingress configuration valid (port {ingress_port})")
+162 -10
View File
@@ -1,6 +1,7 @@
import asyncio import asyncio
import json import json
import os import os
import re
import time import time
from http import HTTPStatus from http import HTTPStatus
from pathlib import Path from pathlib import Path
@@ -18,6 +19,7 @@ from akkudoktoreos.server.server import (
ServerCommonSettings, ServerCommonSettings,
get_default_host, get_default_host,
get_default_port, get_default_port,
get_host_ip,
wait_for_port_free, wait_for_port_free,
) )
@@ -56,23 +58,27 @@ class TestServerSettingsValidation:
def test_ha_addon_default_ports_ok(self, config_eos, monkeypatch): def test_ha_addon_default_ports_ok(self, config_eos, monkeypatch):
"""Default ports are accepted in HA addon mode.""" """Default ports are accepted 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)
assert config_eos.server.port == get_default_port() # 8503 assert config_eos.server.port == get_default_port() # 8503
assert config_eos.server.eosdash_port == get_default_port() + 1 # 8504 assert config_eos.server.eosdash_port == get_default_port() + 1 # 8504
def test_server_port_restriction_in_ha_addon(self, config_eos, monkeypatch): def test_server_port_restriction_in_ha_addon(self, config_eos, monkeypatch):
"""Server port must be the default (8503) in HA addon mode.""" """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: with pytest.raises(ValidationError) as excinfo:
config_eos.server.port = 9000 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): def test_eosdash_port_restriction_in_ha_addon(self, config_eos, monkeypatch):
"""EOSdash port must be the default (8504) in HA addon mode.""" """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: with pytest.raises(ValidationError) as excinfo:
config_eos.server.eosdash_port = 9001 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): def test_ports_allowed_when_not_ha_addon(self, config_eos):
"""Custom ports are allowed when not in HA addon mode.""" """Custom ports are allowed when not in HA addon mode."""
@@ -84,7 +90,6 @@ class TestServerSettingsValidation:
class TestServerStartStop: class TestServerStartStop:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_forward_stream_truncates_very_long_line(self, monkeypatch, tmp_path): async def test_forward_stream_truncates_very_long_line(self, monkeypatch, tmp_path):
"""Test logging from EOSdash can also handle very long lines.""" """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}" eosdash_server = f"http://{config_eos.server.eosdash_host}:{config_eos.server.eosdash_port}"
# Port may be blocked # 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] = [] owned_processes: list[psutil.Process] = []
try: try:
@@ -359,7 +366,9 @@ class TestServerWithEnv:
"""Ensure server is started with environment passed to configuration.""" """Ensure server is started with environment passed to configuration."""
server = server_setup_for_class["server"] 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") result = requests.get(f"{server}/v1/config")
assert result.status_code == HTTPStatus.OK assert result.status_code == HTTPStatus.OK
@@ -368,4 +377,147 @@ class TestServerWithEnv:
config_json = result.json() config_json = result.json()
# Assure config got configuration from environment # 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 "&lt;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 "<script>alert(1)</script>" 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