From 7b3daa313a9dceadbed9b93ebfecfc3a68cbb0ad Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Tue, 10 Nov 2026 07:53:53 -0800 Subject: [PATCH] Fix: fix allowed_hosts default --- docs/configuration.md | 22 +++--- src/paperless/settings/__init__.py | 13 ++-- src/paperless/tests/settings/test_settings.py | 70 +++++++++++++++++-- 3 files changed, 86 insertions(+), 19 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 39fc30e95f..2208cffa69 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -501,20 +501,20 @@ See the [Django project documentation on the settings](https://docs.djangoprojec #### [`PAPERLESS_ALLOWED_HOSTS=`](#PAPERLESS_ALLOWED_HOSTS) {#PAPERLESS_ALLOWED_HOSTS} -: If you're planning on putting Paperless on the open internet, then -you really should set this value to the domain name you're using. -Failing to do so leaves you open to HTTP host header attacks. +: If you expose Paperless to the internet, set `PAPERLESS_URL` to its public URL +or list its public hostname in `PAPERLESS_ALLOWED_HOSTS` to help protect against +HTTP Host header attacks. You can read more about this in [the Django project's documentation](https://docs.djangoproject.com/en/4.1/topics/security/#host-header-validation) - Just remember that this is a comma-separated list, so - "example.com" is fine, as is "example.com,www.example.com", but - NOT " example.com" or "example.com," + Separate multiple hostnames with commas, as in `example.com,www.example.com`. - Can also be set using PAPERLESS_URL (see above). - - "localhost" is always allowed for docker healthcheck - - Defaults to "\*", which is all hosts. + - With neither setting configured, Paperless accepts any host. + - With only `PAPERLESS_URL` set, Paperless accepts the hostname from that URL + and `localhost` (used by the Docker health check). + - `PAPERLESS_ALLOWED_HOSTS` adds hosts to `localhost` and the hostname from + `PAPERLESS_URL`, if set. A blank list adds no hosts. + - With `PAPERLESS_ALLOWED_HOSTS=*`, Paperless accepts any host, even when + `PAPERLESS_URL` is set. #### [`PAPERLESS_CORS_ALLOWED_HOSTS=`](#PAPERLESS_CORS_ALLOWED_HOSTS) {#PAPERLESS_CORS_ALLOWED_HOSTS} diff --git a/src/paperless/settings/__init__.py b/src/paperless/settings/__init__.py index 1c035844ac..ba7e70d289 100644 --- a/src/paperless/settings/__init__.py +++ b/src/paperless/settings/__init__.py @@ -450,7 +450,10 @@ CORS_EXPOSE_HEADERS = [ "Content-Disposition", ] -ALLOWED_HOSTS = get_list_from_env("PAPERLESS_ALLOWED_HOSTS", default=["*"]) +if "PAPERLESS_ALLOWED_HOSTS" in os.environ or os.getenv("PAPERLESS_URL"): + ALLOWED_HOSTS = get_list_from_env("PAPERLESS_ALLOWED_HOSTS") +else: + ALLOWED_HOSTS = ["*"] if ALLOWED_HOSTS != ["*"]: # always allow localhost. Necessary e.g. for healthcheck in docker. ALLOWED_HOSTS.append("localhost") @@ -460,13 +463,15 @@ def _parse_paperless_url(): global CSRF_TRUSTED_ORIGINS, CORS_ALLOWED_ORIGINS, ALLOWED_HOSTS url = os.getenv("PAPERLESS_URL") if url: - hostname = urlparse(url).hostname - if not hostname: + hostname = (urlparse(url).hostname or "").removesuffix(".") + if not hostname or hostname == "*" or hostname.startswith("."): raise ImproperlyConfigured( - f"PAPERLESS_URL {url!r} has no hostname, expected a URL like https://example.com", + f"PAPERLESS_URL {url!r} has no valid hostname, expected a URL like https://example.com", ) CSRF_TRUSTED_ORIGINS.append(url) CORS_ALLOWED_ORIGINS.append(url) + if ":" in hostname and not hostname.startswith("["): + hostname = f"[{hostname}]" # Add brackets around IPv6 addresses ALLOWED_HOSTS.append(hostname) return url diff --git a/src/paperless/tests/settings/test_settings.py b/src/paperless/tests/settings/test_settings.py index be218a3d7a..e482bb4ec5 100644 --- a/src/paperless/tests/settings/test_settings.py +++ b/src/paperless/tests/settings/test_settings.py @@ -1,4 +1,8 @@ +import json import os +import subprocess +import sys +from pathlib import Path from unittest import TestCase from unittest import mock @@ -168,8 +172,66 @@ class TestPaperlessURLSettings(TestCase): self.assertIn(url, settings.CORS_ALLOWED_ORIGINS) -@pytest.mark.parametrize("url", ["example.com", "https://", "/just/a/path"]) -def test_paperless_url_without_hostname_is_rejected( +@pytest.mark.parametrize( + ("url", "allowed_hosts", "expected"), + [ + (None, None, ["*"]), + (None, "", ["localhost"]), + ("https://example.com:8443", None, ["localhost", "example.com"]), + ("https://example.com.", "", ["localhost", "example.com"]), + ("http://[::1]:8000", None, ["localhost", "[::1]"]), + ("https://example.com", "*", ["*", "example.com"]), + ( + "https://example.com", + "lan.example.com", + ["lan.example.com", "localhost", "example.com"], + ), + ], +) +def test_allowed_hosts_at_startup( + tmp_path: Path, + url: str | None, + allowed_hosts: str | None, + expected: list[str], +) -> None: + config = tmp_path / "paperless.conf" + config.touch() + env = { + **os.environ, + "PAPERLESS_CONFIGURATION_PATH": str(config), + "PAPERLESS_LOGGING_DIR": str(tmp_path), + } + for name, value in ( + ("PAPERLESS_URL", url), + ("PAPERLESS_ALLOWED_HOSTS", allowed_hosts), + ): + if value is None: + env.pop(name, None) + else: + env[name] = value + + result = subprocess.run( + [ + sys.executable, + "-c", + "import json; from paperless.settings import ALLOWED_HOSTS; print(json.dumps(ALLOWED_HOSTS))", + ], + cwd=Path(__file__).resolve().parents[3], + env=env, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == expected + + +@pytest.mark.parametrize( + "url", + ["example.com", "https://", "/just/a/path", "https://*", "https://.example.com"], +) +def test_paperless_url_without_valid_hostname_is_rejected( monkeypatch: pytest.MonkeyPatch, url: str, ) -> None: @@ -179,9 +241,9 @@ def test_paperless_url_without_hostname_is_rejected( WHEN: - The URL is parsed THEN: - - A configuration error is raised instead of adding None to ALLOWED_HOSTS + - A configuration error is raised for missing or wildcard hostnames """ monkeypatch.setenv("PAPERLESS_URL", url) - with pytest.raises(ImproperlyConfigured, match="no hostname"): + with pytest.raises(ImproperlyConfigured, match="no valid hostname"): _parse_paperless_url()