Answer only to the host names of Bindery

Without a login, a web page that the user visits could reach Bindery
under a name of its own that points at the address of Bindery, and read
and change everything. The "none" mode now answers only to addresses
and to names of a private network. ALLOWED_HOSTS sets the names for
each mode.
This commit is contained in:
tim
2026-10-02 23:37:33 +02:00
parent 2d9796d77c
commit 751b52da77
7 changed files with 113 additions and 0 deletions
+23
View File
@@ -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_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 | | `PROXY_AUTH_LOGOUT_URL` | Where the Log out button goes in proxy mode | None |
| `BINDERY_API_KEY` | A key for scripts. See "Scripts". | 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` | | `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` | | `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 | | `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 `CWA_ALLOWED_USERS` and restart Bindery. To end all sessions, delete `/config/.flask_secret` and
restart Bindery. 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
Scripts can use the JSON API of the web interface. Scripts can use the JSON API of the web interface.
+28
View File
@@ -15,6 +15,7 @@ from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from functools import lru_cache from functools import lru_cache
from typing import Any from typing import Any
from urllib.parse import urlsplit
from werkzeug.security import check_password_hash 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_FAILURES = 5 # for one user name from one address
MAX_ADDRESS_FAILURES = 20 # for all user names from one address MAX_ADDRESS_FAILURES = 20 # for all user names from one address
LOCKOUT_SECONDS = 15 * 60 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): 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))) 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: def local_password_version() -> str:
"""Invalidate local sessions when the configured password hash changes.""" """Invalidate local sessions when the configured password hash changes."""
return hashlib.sha256(config.LOCAL_PASSWORD_HASH.encode()).hexdigest() return hashlib.sha256(config.LOCAL_PASSWORD_HASH.encode()).hexdigest()
+3
View File
@@ -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" "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() 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") SESSION_COOKIE_SECURE = _bool("SESSION_COOKIE_SECURE")
# Take the client address from the last entry of X-Forwarded-For, which a reverse proxy adds. # Take the client address from the last entry of X-Forwarded-For, which a reverse proxy adds.
TRUST_PROXY = _bool("TRUST_PROXY") TRUST_PROXY = _bool("TRUST_PROXY")
+7
View File
@@ -139,6 +139,13 @@ def _identity() -> auth.Identity | None:
return auth.current_identity(request.headers, session, request.remote_addr) 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 @app.before_request
def check_login() -> Any: def check_login() -> Any:
if not request.path.startswith("/api/") or request.path == "/api/health": if not request.path.startswith("/api/") or request.path == "/api/health":
+2
View File
@@ -21,6 +21,8 @@ services:
# CWA_DB_PATH: /cwa-config/app.db # CWA_DB_PATH: /cwa-config/app.db
# CALIBRE_DB_PATH: /calibre-library/metadata.db # CALIBRE_DB_PATH: /calibre-library/metadata.db
# URL_BASE: /bindery # 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. # true only if clients reach Bindery through a reverse proxy alone. The login lockout then uses X-Forwarded-For.
# TRUST_PROXY: "true" # TRUST_PROXY: "true"
# For AUTH_METHOD=proxy: the address of the proxy. The default trusts each private address. # For AUTH_METHOD=proxy: the address of the proxy. The default trusts each private address.
+46
View File
@@ -10,6 +10,7 @@ import pytest
from fake_cwa import FakeCwa from fake_cwa import FakeCwa
from itsdangerous import TimestampSigner from itsdangerous import TimestampSigner
from test_build import PNG from test_build import PNG
from werkzeug.security import generate_password_hash
from bindery import auth, config, orchestrator, web from bindery import auth, config, orchestrator, web
from bindery.store import Store 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 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): def test_api_key(client, cwa, monkeypatch):
monkeypatch.setattr(config, "API_KEY", "k" * 32) monkeypatch.setattr(config, "API_KEY", "k" * 32)
assert client.get("/api/stories", headers={"X-Api-Key": "wrong"}).status_code == 401 assert client.get("/api/stories", headers={"X-Api-Key": "wrong"}).status_code == 401
+4
View File
@@ -75,7 +75,9 @@ def test_values_from_the_environment(monkeypatch, reload_config):
monkeypatch.setenv("CALIBRE_DB_PATH", "/library/metadata.db") monkeypatch.setenv("CALIBRE_DB_PATH", "/library/metadata.db")
monkeypatch.setenv("BINDERY_USERNAME", " reader ") monkeypatch.setenv("BINDERY_USERNAME", " reader ")
monkeypatch.setenv("BINDERY_PASSWORD_HASH", " hash ") monkeypatch.setenv("BINDERY_PASSWORD_HASH", " hash ")
monkeypatch.setenv("ALLOWED_HOSTS", "Bindery.Example.com, .home.example")
reload_config() reload_config()
assert config.ALLOWED_HOSTS == ["bindery.example.com", ".home.example"]
assert config.URL_BASE == "/bindery" assert config.URL_BASE == "/bindery"
assert config.AUTH_METHOD == "cwa" assert config.AUTH_METHOD == "cwa"
assert config.UPDATE_INTERVAL_HOURS == 1.5 assert config.UPDATE_INTERVAL_HOURS == 1.5
@@ -98,6 +100,7 @@ def test_defaults(monkeypatch, reload_config):
"LOG_LEVEL", "LOG_LEVEL",
"PROXY_AUTH_USER_HEADER", "PROXY_AUTH_USER_HEADER",
"PROXY_AUTH_TRUSTED_IPS", "PROXY_AUTH_TRUSTED_IPS",
"ALLOWED_HOSTS",
"SESSION_COOKIE_SECURE", "SESSION_COOKIE_SECURE",
"TRUST_PROXY", "TRUST_PROXY",
"CWA_DB_PATH", "CWA_DB_PATH",
@@ -123,6 +126,7 @@ def test_defaults(monkeypatch, reload_config):
"fc00::/7", "fc00::/7",
"fe80::/10", "fe80::/10",
] ]
assert config.ALLOWED_HOSTS == []
assert config.SESSION_COOKIE_SECURE is False assert config.SESSION_COOKIE_SECURE is False
assert config.TRUST_PROXY is False assert config.TRUST_PROXY is False
assert config.CWA_DB_PATH is None assert config.CWA_DB_PATH is None