From 00096e0252a16c6857da0f0940c582b573fd0ccb Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:53:49 -0800 Subject: [PATCH] Fix: fix allowed_hosts default (#14735) --- docs/configuration.md | 22 ++--- src/paperless/settings/__init__.py | 25 +++-- src/paperless/tests/settings/test_settings.py | 93 ++++++++++++++++++- 3 files changed, 118 insertions(+), 22 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 72c10b83bb..c9798e905b 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..4a53416771 100644 --- a/src/paperless/settings/__init__.py +++ b/src/paperless/settings/__init__.py @@ -450,23 +450,34 @@ CORS_EXPOSE_HEADERS = [ "Content-Disposition", ] -ALLOWED_HOSTS = get_list_from_env("PAPERLESS_ALLOWED_HOSTS", default=["*"]) -if ALLOWED_HOSTS != ["*"]: - # always allow localhost. Necessary e.g. for healthcheck in docker. - ALLOWED_HOSTS.append("localhost") + +def _get_allowed_hosts() -> list[str]: + 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 for the Docker health check. + allowed_hosts.append("localhost") + return allowed_hosts + + +ALLOWED_HOSTS = _get_allowed_hosts() 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..996b9c037b 100644 --- a/src/paperless/tests/settings/test_settings.py +++ b/src/paperless/tests/settings/test_settings.py @@ -3,9 +3,14 @@ from unittest import TestCase from unittest import mock import pytest +from django.core.exceptions import DisallowedHost from django.core.exceptions import ImproperlyConfigured +from django.http import HttpRequest +from django.test import override_settings +import paperless.settings as paperless_settings from paperless.settings import _get_allauth_trusted_proxy_count +from paperless.settings import _get_allowed_hosts from paperless.settings import _get_classifier_language_setting from paperless.settings import _get_search_language_setting from paperless.settings import _parse_paperless_url @@ -168,8 +173,88 @@ 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", "accepted_host", "rejected_host"), + [ + (None, None, ["*"], "other.example.com", None), + (None, "", ["localhost"], "localhost:8000", "other.example.com"), + ( + None, + "lan.example.com", + ["lan.example.com", "localhost"], + "lan.example.com", + "other.example.com", + ), + (None, "*", ["*"], "other.example.com", None), + ( + "https://example.com:8443", + None, + ["localhost", "example.com"], + "example.com:8443", + "other.example.com", + ), + ( + "https://example.com.", + "", + ["localhost", "example.com"], + "example.com.", + "other.example.com", + ), + ( + "http://[::1]:8000", + None, + ["localhost", "[::1]"], + "[::1]:8000", + "other.example.com", + ), + ("https://example.com", "*", ["*", "example.com"], "other.example.com", None), + ( + "https://example.com", + "lan.example.com", + ["lan.example.com", "localhost", "example.com"], + "lan.example.com", + "other.example.com", + ), + ], +) +def test_allowed_hosts_at_startup( + monkeypatch: pytest.MonkeyPatch, + url: str | None, + allowed_hosts: str | None, + expected: list[str], + accepted_host: str, + rejected_host: str | None, +) -> None: + for name, value in ( + ("PAPERLESS_URL", url), + ("PAPERLESS_ALLOWED_HOSTS", allowed_hosts), + ): + if value is None: + monkeypatch.delenv(name, raising=False) + else: + monkeypatch.setenv(name, value) + + monkeypatch.setattr(paperless_settings, "ALLOWED_HOSTS", _get_allowed_hosts()) + monkeypatch.setattr(paperless_settings, "CSRF_TRUSTED_ORIGINS", []) + monkeypatch.setattr(paperless_settings, "CORS_ALLOWED_ORIGINS", []) + assert _parse_paperless_url() == url + assert expected == paperless_settings.ALLOWED_HOSTS + + request = HttpRequest() + with override_settings(ALLOWED_HOSTS=expected): + request.META["HTTP_HOST"] = accepted_host + assert request.get_host() == accepted_host + if rejected_host: + request.META["HTTP_HOST"] = rejected_host + with pytest.raises(DisallowedHost): + request.get_host() + + +@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 +264,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()