mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-02 07:57:15 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47288d4559 | ||
|
|
d78754bff1 |
@@ -50,18 +50,18 @@ repos:
|
||||
- 'prettier-plugin-organize-imports@4.3.0'
|
||||
# Python hooks
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.16.1
|
||||
rev: v0.16.4
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
- id: ruff-format
|
||||
- repo: https://github.com/tox-dev/pyproject-fmt
|
||||
rev: "v2.26.0"
|
||||
rev: "v2.28.1"
|
||||
hooks:
|
||||
- id: pyproject-fmt
|
||||
additional_dependencies: [tomli]
|
||||
# Dockerfile hooks
|
||||
- repo: https://github.com/AleksaC/hadolint-py
|
||||
rev: v2.14.0
|
||||
rev: v2.15.1
|
||||
hooks:
|
||||
- id: hadolint
|
||||
# Shell script hooks
|
||||
|
||||
@@ -2088,6 +2088,12 @@ password. All of these options come from their similarly-named [Django settings]
|
||||
|
||||
Defaults to "always".
|
||||
|
||||
#### [`PAPERLESS_REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS=<bool>`](#PAPERLESS_REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS) {#PAPERLESS_REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS}
|
||||
|
||||
: If set to false, Paperless blocks remote OCR endpoint URLs that resolve to non-public addresses (e.g., localhost, etc).
|
||||
|
||||
Defaults to True.
|
||||
|
||||
## AI {#ai}
|
||||
|
||||
#### [`PAPERLESS_AI_ENABLED=<bool>`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED}
|
||||
|
||||
@@ -1063,3 +1063,79 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("non-public address", str(response.data).lower())
|
||||
|
||||
@override_settings(REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS=False)
|
||||
def test_update_remote_ocr_endpoint_blocks_internal_endpoint_when_disallowed(
|
||||
self,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Internal remote OCR endpoints are disallowed
|
||||
WHEN:
|
||||
- The config is updated with a remote OCR endpoint resolving internally
|
||||
THEN:
|
||||
- The request is rejected
|
||||
"""
|
||||
response = self.client.patch(
|
||||
f"{self.ENDPOINT}1/",
|
||||
json.dumps(
|
||||
{
|
||||
"remote_ocr_endpoint": "http://127.0.0.1:5000",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("non-public address", str(response.data).lower())
|
||||
|
||||
@override_settings(REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS=True)
|
||||
def test_update_remote_ocr_endpoint_allows_internal_endpoint_by_default(
|
||||
self,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Internal remote OCR endpoints are allowed (the default)
|
||||
WHEN:
|
||||
- The config is updated with a remote OCR endpoint resolving internally
|
||||
THEN:
|
||||
- The request is accepted, preserving existing self-hosted deployments
|
||||
"""
|
||||
response = self.client.patch(
|
||||
f"{self.ENDPOINT}1/",
|
||||
json.dumps(
|
||||
{
|
||||
"remote_ocr_endpoint": "http://127.0.0.1:5000",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(
|
||||
response.data["remote_ocr_endpoint"],
|
||||
"http://127.0.0.1:5000",
|
||||
)
|
||||
|
||||
@override_settings(REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS=False)
|
||||
def test_update_remote_ocr_endpoint_empty_value_skips_validation(
|
||||
self,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Internal remote OCR endpoints are disallowed
|
||||
WHEN:
|
||||
- The config is updated with an empty remote OCR endpoint
|
||||
THEN:
|
||||
- The request is accepted; clearing the field never needs
|
||||
outbound URL validation
|
||||
"""
|
||||
response = self.client.patch(
|
||||
f"{self.ENDPOINT}1/",
|
||||
json.dumps(
|
||||
{
|
||||
"remote_ocr_endpoint": "",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["remote_ocr_endpoint"], "")
|
||||
|
||||
@@ -32,6 +32,8 @@ if TYPE_CHECKING:
|
||||
import datetime
|
||||
from types import TracebackType
|
||||
|
||||
from azure.core.pipeline import PipelineRequest
|
||||
|
||||
from paperless.parsers import MetadataEntry
|
||||
from paperless.parsers import ParserContext
|
||||
|
||||
@@ -436,9 +438,45 @@ class RemoteDocumentParser:
|
||||
from azure.ai.documentintelligence.models import DocumentContentFormat
|
||||
from azure.core.credentials import AzureKeyCredential
|
||||
|
||||
from paperless.network import validate_outbound_http_url
|
||||
|
||||
allow_internal = settings.REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS
|
||||
|
||||
try:
|
||||
validate_outbound_http_url(config.endpoint, allow_internal=allow_internal)
|
||||
except ValueError as e:
|
||||
raise ParseError(f"Invalid remote OCR endpoint: {e}") from e
|
||||
|
||||
def _revalidate_request_host(request: PipelineRequest) -> None:
|
||||
"""Re-validates the destination host of every request sent.
|
||||
|
||||
The check above only covers the moment the client is built. A
|
||||
single analysis involves several requests spread over the
|
||||
polling loop below, and any one of them can be redirected.
|
||||
Wiring this through ``raw_request_hook`` (Azure's built-in
|
||||
CustomHookPolicy) rather than a custom policy means it runs
|
||||
*after* RedirectPolicy in the pipeline, so it sees - and
|
||||
re-checks - every actual outbound URL, including redirect
|
||||
targets, not just the original request.
|
||||
"""
|
||||
validate_outbound_http_url(
|
||||
request.http_request.url,
|
||||
allow_internal=allow_internal,
|
||||
)
|
||||
|
||||
client = DocumentIntelligenceClient(
|
||||
endpoint=config.endpoint,
|
||||
credential=AzureKeyCredential(config.api_key),
|
||||
raw_request_hook=_revalidate_request_host,
|
||||
# AzureKeyCredential is sent as Ocp-Apim-Subscription-Key, which
|
||||
# Azure's default SensitiveHeaderCleanupPolicy does not strip on
|
||||
# a cross-domain redirect (only Authorization and
|
||||
# x-ms-authorization-auxiliary are, by default).
|
||||
blocked_redirect_headers=[
|
||||
"Authorization",
|
||||
"x-ms-authorization-auxiliary",
|
||||
"Ocp-Apim-Subscription-Key",
|
||||
],
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -305,6 +305,22 @@ class ApplicationConfigurationSerializer(
|
||||
|
||||
validate_llm_embedding_endpoint = validate_llm_endpoint
|
||||
|
||||
def validate_remote_ocr_endpoint(self, value: str | None) -> str | None:
|
||||
if not value:
|
||||
return value
|
||||
|
||||
try:
|
||||
validate_outbound_http_url(
|
||||
value,
|
||||
allow_internal=settings.REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise serializers.ValidationError(
|
||||
f"Invalid remote OCR endpoint: {e.args[0]}, see logs for details",
|
||||
) from e
|
||||
|
||||
return value
|
||||
|
||||
class Meta:
|
||||
model = ApplicationConfiguration
|
||||
fields = "__all__"
|
||||
|
||||
@@ -1208,6 +1208,10 @@ REMOTE_OCR_MODE = get_choice_from_env(
|
||||
{"always", "workflow_only"},
|
||||
default="always",
|
||||
)
|
||||
REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS = get_bool_from_env(
|
||||
"PAPERLESS_REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS",
|
||||
"true",
|
||||
)
|
||||
|
||||
################################################################################
|
||||
# AI Settings #
|
||||
|
||||
@@ -4,11 +4,11 @@ requires-python = ">=3.11"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.15' and sys_platform == 'darwin'",
|
||||
"python_full_version >= '3.15' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"python_full_version >= '3.12' and python_full_version < '3.15' and sys_platform == 'darwin'",
|
||||
"python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')",
|
||||
"python_full_version < '3.12' and sys_platform == 'darwin'",
|
||||
"python_full_version < '3.12' and sys_platform == 'linux'",
|
||||
@@ -2718,7 +2718,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "nltk"
|
||||
version = "3.10.3"
|
||||
version = "3.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -2727,9 +2727,9 @@ dependencies = [
|
||||
{ name = "regex" },
|
||||
{ name = "tqdm" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e0/e6/fe51d2bb1a3b446f59c5c8165999a9fee208bc346af90a7cbf7657bc0d75/nltk-3.10.3.tar.gz", hash = "sha256:bb9327a461c3811c2fa4900e03840401f2126adfb30c0072827c433bd2444ea4", size = 5137152, upload-time = "2026-08-12T23:46:37.258Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/02/df4f105b28a7c16b0e41423bc09cf0f1b8a305df4ef0b10ca74a2e4c648c/nltk-3.10.0.tar.gz", hash = "sha256:4fbac1d98203cbcd1b5d94a2877fb822300072d80604a5e7fae49d2c5f84e8c1", size = 3089244, upload-time = "2026-07-08T02:39:13.562Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/6d/ebd2af4640b12168fdf0cb74b6118df2f32a2f62ec7e0c06fbfd80706639/nltk-3.10.3-py3-none-any.whl", hash = "sha256:ff9598a8e20518ee0d557745890cc4435b9578489e2dcbc69c4f81fa060caf7c", size = 1798643, upload-time = "2026-08-12T23:44:13.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/89/a0b0f35e2820d6a99d75ea1c11977ee6d5c9e6658eceb45b0c7620881faa/nltk-3.10.0-py3-none-any.whl", hash = "sha256:54ff84d4916d3ef127e8953bee0023f6a6b320b75d634a19e06ef056d3d244bf", size = 1716144, upload-time = "2026-07-08T02:39:09.753Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5014,10 +5014,10 @@ version = "2.13.0+cpu"
|
||||
source = { registry = "https://download.pytorch.org/whl/cpu" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.15' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')",
|
||||
"python_full_version < '3.12' and sys_platform == 'linux'",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user