diff --git a/README.md b/README.md index 30d6895..452f35e 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ Environment variables set the configuration. **Restart the container after a cha | `PROXY_AUTH_TRUSTED_IPS` | The addresses and networks that can send the user header. See "Logins". | Loopback and private addresses | | `PROXY_AUTH_LOGOUT_URL` | Where the Log out button goes in proxy mode | None | | `BINDERY_API_KEY` | A key for scripts. See "Scripts". | None | +| `ALLOWED_HOSTS` | The host names that Bindery answers to. See "Host names". | None | | `SESSION_COOKIE_SECURE` | `true` if users reach Bindery only over HTTPS | `false` | | `TRUST_PROXY` | `true` if a reverse proxy sends the client address. See "Lockouts and sessions". | `false` | | `URL_BASE` | A path prefix, such as `/bindery`. See "Reverse proxy". | None | @@ -209,6 +210,28 @@ open session until that session ends. To stop a CWA user at once, remove the use `CWA_ALLOWED_USERS` and restart Bindery. To end all sessions, delete `/config/.flask_secret` and restart Bindery. +### Host names + +Without a login, a web page that you visit can try to reach Bindery from your browser, under a host +name of its own that points at the address of Bindery. To stop this, the `none` login mode answers +only to these host names: + +- An address, such as `192.168.1.5`. +- `localhost`, and a name without a dot, such as `nas` or the name of a container. +- A name of a private network. These end in `.local`, `.lan`, `.home`, `.home.arpa`, `.internal`, + `.localhost` or `.localdomain`. + +For each other name, Bindery answers with an error that shows the name. If you use such a name, for +example behind a reverse proxy, list it in `ALLOWED_HOSTS`: + +- `ALLOWED_HOSTS=bindery.example.com` allows that name. Bindery then answers only to the names in + the list, in each login mode. Write the names without a port. +- A leading dot, as in `.example.com`, also allows the subdomains. +- `ALLOWED_HOSTS=*` allows each name. + +The other login modes answer to each name, unless you set a list. The health check at `/api/health` +works under each name. + ### Scripts Scripts can use the JSON API of the web interface. diff --git a/bindery/auth.py b/bindery/auth.py index e43062e..626cafb 100644 --- a/bindery/auth.py +++ b/bindery/auth.py @@ -15,6 +15,7 @@ from collections.abc import Mapping from dataclasses import dataclass from functools import lru_cache from typing import Any +from urllib.parse import urlsplit from werkzeug.security import check_password_hash @@ -27,6 +28,8 @@ AUTH_NONE, AUTH_LOCAL, AUTH_CWA, AUTH_PROXY = "none", "local", "cwa", "proxy" MAX_FAILURES = 5 # for one user name from one address MAX_ADDRESS_FAILURES = 20 # for all user names from one address LOCKOUT_SECONDS = 15 * 60 +# Name endings that only a private network can resolve. +LOCAL_SUFFIXES = (".local", ".lan", ".home", ".home.arpa", ".internal", ".localhost", ".localdomain") class AuthError(Exception): @@ -72,6 +75,31 @@ def _trusted_proxy(address: str | None) -> bool: return any(ip.version == n.version and ip in n for n in _networks(tuple(config.PROXY_AUTH_TRUSTED_IPS))) +def host_allowed(host: str) -> bool: + """Whether Bindery answers a request with this Host header. + + Without a login, each web page that the user visits could reach Bindery under + a name of its own that points at the address of Bindery (DNS rebinding), and + read and change everything. Such a name is a public name, so the "none" mode + answers only to addresses and to names of a private network. + """ + try: + name = (urlsplit(f"//{host}").hostname or "").rstrip(".") + except ValueError: + return False + if config.ALLOWED_HOSTS: + return any( + h == "*" or name == h.lstrip(".") or (h.startswith(".") and name.endswith(h)) for h in config.ALLOWED_HOSTS + ) + if config.AUTH_METHOD != AUTH_NONE: + return True + try: + ipaddress.ip_address(name) + return True + except ValueError: + return bool(name) and ("." not in name or name.endswith(LOCAL_SUFFIXES)) + + def local_password_version() -> str: """Invalidate local sessions when the configured password hash changes.""" return hashlib.sha256(config.LOCAL_PASSWORD_HASH.encode()).hexdigest() diff --git a/bindery/config.py b/bindery/config.py index 843ccc5..8cf2be3 100644 --- a/bindery/config.py +++ b/bindery/config.py @@ -81,6 +81,9 @@ PROXY_AUTH_TRUSTED_IPS = _list( "PROXY_AUTH_TRUSTED_IPS", "127.0.0.0/8, ::1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7, fe80::/10" ) API_KEY = os.getenv("BINDERY_API_KEY", "").strip() +# The host names that Bindery answers to. A leading dot also allows the subdomains, and * allows every name. +# Without a list, the "none" login mode answers only to addresses and local names. The other modes answer to all. +ALLOWED_HOSTS = [h.lower() for h in _list("ALLOWED_HOSTS")] SESSION_COOKIE_SECURE = _bool("SESSION_COOKIE_SECURE") # Take the client address from the last entry of X-Forwarded-For, which a reverse proxy adds. TRUST_PROXY = _bool("TRUST_PROXY") diff --git a/bindery/web.py b/bindery/web.py index 297ea7d..d931ecc 100644 --- a/bindery/web.py +++ b/bindery/web.py @@ -139,6 +139,13 @@ def _identity() -> auth.Identity | None: return auth.current_identity(request.headers, session, request.remote_addr) +@app.before_request +def check_host() -> Any: + if request.path == "/api/health" or auth.host_allowed(request.host): + return None + return jsonify({"error": f"Bindery does not answer to the host name {request.host}. Add it to ALLOWED_HOSTS."}), 400 + + @app.before_request def check_login() -> Any: if not request.path.startswith("/api/") or request.path == "/api/health": diff --git a/docker-compose.yml b/docker-compose.yml index a1a8695..1c73533 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,8 @@ services: # CWA_DB_PATH: /cwa-config/app.db # CALIBRE_DB_PATH: /calibre-library/metadata.db # URL_BASE: /bindery + # The host names that Bindery answers to. AUTH_METHOD=none needs this for a public name such as this one. + # ALLOWED_HOSTS: bindery.example.com # true only if clients reach Bindery through a reverse proxy alone. The login lockout then uses X-Forwarded-For. # TRUST_PROXY: "true" # For AUTH_METHOD=proxy: the address of the proxy. The default trusts each private address. diff --git a/tests/test_api.py b/tests/test_api.py index 5a5131f..acea35e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -10,6 +10,7 @@ import pytest from fake_cwa import FakeCwa from itsdangerous import TimestampSigner from test_build import PNG +from werkzeug.security import generate_password_hash from bindery import auth, config, orchestrator, web from bindery.store import Store @@ -242,6 +243,51 @@ def test_incomplete_login_settings_refuse_every_request(client, monkeypatch, set assert client.get("/").status_code == 200 +@pytest.mark.parametrize( + ("mode", "allowed", "host", "accepted"), + [ + # Without a login, only an address or a name of a private network. + ("none", [], "localhost", True), + ("none", [], "127.0.0.1:8085", True), + ("none", [], "192.168.1.5:8085", True), + ("none", [], "[fd00::5]:8085", True), + ("none", [], "bindery:8085", True), + ("none", [], "nas.local", True), + ("none", [], "NAS.Home.Arpa.", True), + ("none", [], "bindery.example.com", False), + ("none", [], "rebind.attacker.example:8085", False), + ("none", [], "notlocal", True), + ("none", [], "evil.com.local.attacker.example", False), + # With a login, each name. + ("local", [], "bindery.example.com", True), + ("proxy", [], "bindery.example.com", True), + # A list counts in each mode. + ("none", ["bindery.example.com"], "bindery.example.com:443", True), + ("none", ["bindery.example.com"], "localhost", False), + ("none", ["bindery.example.com"], "x.bindery.example.com", False), + ("local", [".example.com"], "bindery.example.com", True), + ("local", [".example.com"], "example.com", True), + ("local", [".example.com"], "badexample.com", False), + ("local", ["example.com"], "attacker.example", False), + ("none", ["*"], "anything.example", True), + ], +) +def test_bindery_answers_only_to_its_host_names(client, monkeypatch, mode, allowed, host, accepted): + monkeypatch.setattr(config, "AUTH_METHOD", mode) + monkeypatch.setattr(config, "LOCAL_USERNAME", "reader") + monkeypatch.setattr(config, "LOCAL_PASSWORD_HASH", generate_password_hash("pw", method="pbkdf2:sha256:1000")) + monkeypatch.setattr(config, "ALLOWED_HOSTS", allowed) + for path in ("/", "/api/auth/check", "/static/style.css"): + response = client.get(path, headers={"Host": host}) + assert response.status_code == (200 if accepted else 400), path + if not accepted: + assert response.json == { + "error": f"Bindery does not answer to the host name {host}. Add it to ALLOWED_HOSTS." + } + # The health check of the container works under each name. + assert client.get("/api/health", headers={"Host": host}).status_code == 200 + + def test_api_key(client, cwa, monkeypatch): monkeypatch.setattr(config, "API_KEY", "k" * 32) assert client.get("/api/stories", headers={"X-Api-Key": "wrong"}).status_code == 401 diff --git a/tests/test_config.py b/tests/test_config.py index 8a943fe..071a40f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -75,7 +75,9 @@ def test_values_from_the_environment(monkeypatch, reload_config): monkeypatch.setenv("CALIBRE_DB_PATH", "/library/metadata.db") monkeypatch.setenv("BINDERY_USERNAME", " reader ") monkeypatch.setenv("BINDERY_PASSWORD_HASH", " hash ") + monkeypatch.setenv("ALLOWED_HOSTS", "Bindery.Example.com, .home.example") reload_config() + assert config.ALLOWED_HOSTS == ["bindery.example.com", ".home.example"] assert config.URL_BASE == "/bindery" assert config.AUTH_METHOD == "cwa" assert config.UPDATE_INTERVAL_HOURS == 1.5 @@ -98,6 +100,7 @@ def test_defaults(monkeypatch, reload_config): "LOG_LEVEL", "PROXY_AUTH_USER_HEADER", "PROXY_AUTH_TRUSTED_IPS", + "ALLOWED_HOSTS", "SESSION_COOKIE_SECURE", "TRUST_PROXY", "CWA_DB_PATH", @@ -123,6 +126,7 @@ def test_defaults(monkeypatch, reload_config): "fc00::/7", "fe80::/10", ] + assert config.ALLOWED_HOSTS == [] assert config.SESSION_COOKIE_SECURE is False assert config.TRUST_PROXY is False assert config.CWA_DB_PATH is None