From d78754bff124557743f6ec7c6f89b9a01b9a4df8 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:22:10 -0700 Subject: [PATCH] Security: validate remote OCR endpoint against internal SSRF (#13897) * Security: validate remote OCR endpoint against internal SSRF Adds PAPERLESS_REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS (default true) and validates remote_ocr_endpoint via validate_outbound_http_url on the config serializer, matching the existing LLM endpoint handling. * Validates te outbound url again right before use * cover empty-value branch of validate_remote_ocr_endpoint because coverage * re-validate remote OCR endpoint on every outbound request --- docs/configuration.md | 6 ++ src/documents/tests/test_api_app_config.py | 76 ++++++++++++++++++++++ src/paperless/parsers/remote.py | 38 +++++++++++ src/paperless/serialisers.py | 16 +++++ src/paperless/settings/__init__.py | 4 ++ 5 files changed, 140 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index 188abb24a..37a552f1a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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=`](#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=`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED} diff --git a/src/documents/tests/test_api_app_config.py b/src/documents/tests/test_api_app_config.py index 2b9d56d32..c3fb416c2 100644 --- a/src/documents/tests/test_api_app_config.py +++ b/src/documents/tests/test_api_app_config.py @@ -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"], "") diff --git a/src/paperless/parsers/remote.py b/src/paperless/parsers/remote.py index 403ca0a10..8b60b57ca 100644 --- a/src/paperless/parsers/remote.py +++ b/src/paperless/parsers/remote.py @@ -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: diff --git a/src/paperless/serialisers.py b/src/paperless/serialisers.py index 39b0a58aa..38baf1be5 100644 --- a/src/paperless/serialisers.py +++ b/src/paperless/serialisers.py @@ -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__" diff --git a/src/paperless/settings/__init__.py b/src/paperless/settings/__init__.py index 401f52959..7cd504af1 100644 --- a/src/paperless/settings/__init__.py +++ b/src/paperless/settings/__init__.py @@ -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 #