Compare commits

..
Author SHA1 Message Date
shamoon 7b3daa313a Fix: fix allowed_hosts default 2026-11-10 07:53:53 -08:00
8 changed files with 92 additions and 84 deletions

No files matched your search

-2
View File
@@ -565,8 +565,6 @@ Specify `rebuild` to build the index from scratch from all documents in the data
this the first time you enable the feature, or after changing the embedding backend or
model.
When using the `huggingface` embedding backend, a model that is already downloaded is loaded from the local cache without contacting the Hugging Face Hub. Only `rebuild` checks the Hub for a newer revision of the model, so a model update is picked up together with re-embedding every document. If the model is not cached yet, it is downloaded on first use.
Specify `update` to incrementally index new and changed documents. This is what the
scheduled task runs.
+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}
: 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=<comma-separated-list>`](#PAPERLESS_CORS_ALLOWED_HOSTS) {#PAPERLESS_CORS_ALLOWED_HOSTS}
+9 -4
View File
@@ -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
+66 -4
View File
@@ -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()
+5 -20
View File
@@ -1,5 +1,4 @@
import re
from contextlib import suppress
from typing import TYPE_CHECKING
from django.conf import settings
@@ -21,17 +20,7 @@ OCR_LEADER_REGEX = re.compile(r"[._\-\u00b7]{4,}")
HORIZONTAL_WHITESPACE_REGEX = re.compile(r"[ \t\u00a0]+")
def get_embedding_model(
config: AIConfig,
*,
refresh: bool = False,
) -> "BaseEmbedding":
"""
Build the embedding model for the configured backend.
For Hugging Face, a cached model loads offline by default, saving seconds per
call. Pass ``refresh=True`` to check the Hub for a newer model revision.
"""
def get_embedding_model(config: AIConfig) -> "BaseEmbedding":
match config.llm_embedding_backend:
case LLMEmbeddingBackend.OPENAI_LIKE:
from llama_index.embeddings.openai_like import OpenAILikeEmbedding
@@ -63,15 +52,11 @@ def get_embedding_model(
case LLMEmbeddingBackend.HUGGINGFACE:
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
hf_kwargs = {
"model_name": config.llm_embedding_model
return HuggingFaceEmbedding(
model_name=config.llm_embedding_model
or "sentence-transformers/all-MiniLM-L6-v2",
"cache_folder": str(settings.DATA_DIR / "hf_cache"),
}
if not refresh:
with suppress(OSError):
return HuggingFaceEmbedding(**hf_kwargs, local_files_only=True)
return HuggingFaceEmbedding(**hf_kwargs)
cache_folder=str(settings.DATA_DIR / "hf_cache"),
)
case LLMEmbeddingBackend.OLLAMA:
from llama_index.embeddings.ollama import OllamaEmbedding
from ollama import AsyncClient
+1 -1
View File
@@ -470,7 +470,7 @@ def update_llm_index(
logger.warning("No documents found to index.")
chunk_size = config.llm_embedding_chunk_size
embed_model = get_embedding_model(config, refresh=rebuild)
embed_model = get_embedding_model(config)
with write_store(embed_model_name=model_name) as store:
if rebuild or not store.table_exists():
@@ -212,10 +212,6 @@ def test_update_llm_index(
ai_config.assert_called_once()
build_document_node.assert_called_once_with(real_document, chunk_size=512)
indexing.get_embedding_model.assert_called_once_with(
mock_config,
refresh=True,
)
@pytest.mark.django_db
@@ -340,8 +336,6 @@ def test_update_llm_index_partial_update(
indexing.update_llm_index(rebuild=False)
assert indexing.get_embedding_model.call_args.kwargs == {"refresh": False}
with indexing.get_vector_store() as store:
assert store.table_exists(), (
"Expected the vector store table to exist after incremental update"
-36
View File
@@ -1,6 +1,5 @@
from unittest.mock import ANY
from unittest.mock import MagicMock
from unittest.mock import call
from unittest.mock import patch
import pytest
@@ -168,45 +167,10 @@ def test_get_embedding_model_huggingface(mock_ai_config):
MockHuggingFaceEmbedding.assert_called_once_with(
model_name="sentence-transformers/all-MiniLM-L6-v2",
cache_folder=str(settings.DATA_DIR / "hf_cache"),
local_files_only=True,
)
assert model == MockHuggingFaceEmbedding.return_value
def test_get_embedding_model_huggingface_downloads_when_not_cached(mock_ai_config):
mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.HUGGINGFACE
mock_ai_config.return_value.llm_embedding_model = "some/model"
downloaded = MagicMock()
with patch(
"llama_index.embeddings.huggingface.HuggingFaceEmbedding",
side_effect=[OSError("not cached"), downloaded],
) as MockHuggingFaceEmbedding:
model = get_embedding_model(mock_ai_config.return_value)
cache_folder = str(settings.DATA_DIR / "hf_cache")
assert MockHuggingFaceEmbedding.call_args_list == [
call(model_name="some/model", cache_folder=cache_folder, local_files_only=True),
call(model_name="some/model", cache_folder=cache_folder),
]
assert model is downloaded
def test_get_embedding_model_huggingface_refresh_checks_the_hub(mock_ai_config):
mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.HUGGINGFACE
mock_ai_config.return_value.llm_embedding_model = "some/model"
with patch(
"llama_index.embeddings.huggingface.HuggingFaceEmbedding",
) as MockHuggingFaceEmbedding:
get_embedding_model(mock_ai_config.return_value, refresh=True)
MockHuggingFaceEmbedding.assert_called_once_with(
model_name="some/model",
cache_folder=str(settings.DATA_DIR / "hf_cache"),
)
def test_get_embedding_model_ollama(mock_ai_config):
mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.OLLAMA
mock_ai_config.return_value.llm_embedding_model = "embeddinggemma"