Compare commits

...
Author SHA1 Message Date
shamoon d618a61788 Make testing easier 2026-11-10 15:21:06 -08:00
shamoon 7b3daa313a Fix: fix allowed_hosts default 2026-11-10 07:53:53 -08:00
3 changed files with 118 additions and 22 deletions

No files matched your search

+11 -11
View File
@@ -501,20 +501,20 @@ See the [Django project documentation on the settings](https://docs.djangoprojec
#### [`PAPERLESS_ALLOWED_HOSTS=<comma-separated-list>`](#PAPERLESS_ALLOWED_HOSTS) {#PAPERLESS_ALLOWED_HOSTS} #### [`PAPERLESS_ALLOWED_HOSTS=<comma-separated-list>`](#PAPERLESS_ALLOWED_HOSTS) {#PAPERLESS_ALLOWED_HOSTS}
: If you're planning on putting Paperless on the open internet, then : If you expose Paperless to the internet, set `PAPERLESS_URL` to its public URL
you really should set this value to the domain name you're using. or list its public hostname in `PAPERLESS_ALLOWED_HOSTS` to help protect against
Failing to do so leaves you open to HTTP host header attacks. 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) 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 Separate multiple hostnames with commas, as in `example.com,www.example.com`.
"example.com" is fine, as is "example.com,www.example.com", but
NOT " example.com" or "example.com,"
Can also be set using PAPERLESS_URL (see above). - With neither setting configured, Paperless accepts any host.
- With only `PAPERLESS_URL` set, Paperless accepts the hostname from that URL
"localhost" is always allowed for docker healthcheck and `localhost` (used by the Docker health check).
- `PAPERLESS_ALLOWED_HOSTS` adds hosts to `localhost` and the hostname from
Defaults to "\*", which is all hosts. `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=<comma-separated-list>`](#PAPERLESS_CORS_ALLOWED_HOSTS) {#PAPERLESS_CORS_ALLOWED_HOSTS} #### [`PAPERLESS_CORS_ALLOWED_HOSTS=<comma-separated-list>`](#PAPERLESS_CORS_ALLOWED_HOSTS) {#PAPERLESS_CORS_ALLOWED_HOSTS}
+18 -7
View File
@@ -450,23 +450,34 @@ CORS_EXPOSE_HEADERS = [
"Content-Disposition", "Content-Disposition",
] ]
ALLOWED_HOSTS = get_list_from_env("PAPERLESS_ALLOWED_HOSTS", default=["*"])
if ALLOWED_HOSTS != ["*"]: def _get_allowed_hosts() -> list[str]:
# always allow localhost. Necessary e.g. for healthcheck in docker. if "PAPERLESS_ALLOWED_HOSTS" in os.environ or os.getenv("PAPERLESS_URL"):
ALLOWED_HOSTS.append("localhost") 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(): def _parse_paperless_url():
global CSRF_TRUSTED_ORIGINS, CORS_ALLOWED_ORIGINS, ALLOWED_HOSTS global CSRF_TRUSTED_ORIGINS, CORS_ALLOWED_ORIGINS, ALLOWED_HOSTS
url = os.getenv("PAPERLESS_URL") url = os.getenv("PAPERLESS_URL")
if url: if url:
hostname = urlparse(url).hostname hostname = (urlparse(url).hostname or "").removesuffix(".")
if not hostname: if not hostname or hostname == "*" or hostname.startswith("."):
raise ImproperlyConfigured( 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) CSRF_TRUSTED_ORIGINS.append(url)
CORS_ALLOWED_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) ALLOWED_HOSTS.append(hostname)
return url return url
+89 -4
View File
@@ -3,9 +3,14 @@ from unittest import TestCase
from unittest import mock from unittest import mock
import pytest import pytest
from django.core.exceptions import DisallowedHost
from django.core.exceptions import ImproperlyConfigured 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_allauth_trusted_proxy_count
from paperless.settings import _get_allowed_hosts
from paperless.settings import _get_classifier_language_setting from paperless.settings import _get_classifier_language_setting
from paperless.settings import _get_search_language_setting from paperless.settings import _get_search_language_setting
from paperless.settings import _parse_paperless_url from paperless.settings import _parse_paperless_url
@@ -168,8 +173,88 @@ class TestPaperlessURLSettings(TestCase):
self.assertIn(url, settings.CORS_ALLOWED_ORIGINS) self.assertIn(url, settings.CORS_ALLOWED_ORIGINS)
@pytest.mark.parametrize("url", ["example.com", "https://", "/just/a/path"]) @pytest.mark.parametrize(
def test_paperless_url_without_hostname_is_rejected( ("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, monkeypatch: pytest.MonkeyPatch,
url: str, url: str,
) -> None: ) -> None:
@@ -179,9 +264,9 @@ def test_paperless_url_without_hostname_is_rejected(
WHEN: WHEN:
- The URL is parsed - The URL is parsed
THEN: 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) monkeypatch.setenv("PAPERLESS_URL", url)
with pytest.raises(ImproperlyConfigured, match="no hostname"): with pytest.raises(ImproperlyConfigured, match="no valid hostname"):
_parse_paperless_url() _parse_paperless_url()