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:
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user