diff --git a/docs/configuration.md b/docs/configuration.md index 72c10b83bb..111c49c974 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1157,6 +1157,9 @@ they use underscores instead of dashes. be changed by trusted users. This applies to the `AppConfig` permission as well, which allows setting these arguments from the UI. + Arguments paperless manages itself (`input_file_or_options`, `output_file` + and `sidecar`) are rejected by the API and ignored if set here. + Specify arguments as a JSON dictionary. Keep note of lower case booleans and double quoted parameter names and strings. Examples: diff --git a/src/documents/tests/test_api_app_config.py b/src/documents/tests/test_api_app_config.py index 109a196619..86f15d2b08 100644 --- a/src/documents/tests/test_api_app_config.py +++ b/src/documents/tests/test_api_app_config.py @@ -287,6 +287,28 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase): self.assertEqual(config.user_args, user_args) self.assertEqual(config.barcode_tag_mapping, barcode_tag_mapping) + def test_api_update_config_rejects_reserved_user_args(self) -> None: + """ + GIVEN: + - API request to update app config with user_args which set an + argument the parser manages itself + WHEN: + - API is called + THEN: + - Request is rejected with a 400 naming the argument + - Config is not updated + """ + response = self.client.patch( + f"{self.ENDPOINT}1/", + json.dumps({"user_args": json.dumps({"sidecar": "/tmp/x.py"})}), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("sidecar", str(response.data["user_args"])) + config = ApplicationConfiguration.objects.first() + assert config is not None + self.assertIsNone(config.user_args) + def test_api_update_config_rejects_invalid_json_objects(self) -> None: """ GIVEN: diff --git a/src/paperless/config.py b/src/paperless/config.py index 4627a2c93f..7b2434d11f 100644 --- a/src/paperless/config.py +++ b/src/paperless/config.py @@ -12,6 +12,17 @@ from paperless.models import ModeChoices from paperless.models import OutputTypeChoices from paperless.models import RemoteOCRMode +# OCRmyPDF arguments the parser sets itself, pointing into its own temporary +# directory. The parser reads these files back after OCR, so user supplied +# values can only break consumption (or write to arbitrary paths), never help. +OCR_RESERVED_USER_ARGS: frozenset[str] = frozenset( + { + "input_file_or_options", + "output_file", + "sidecar", + }, +) + @dataclasses.dataclass class BaseConfig: diff --git a/src/paperless/parsers/tesseract.py b/src/paperless/parsers/tesseract.py index e8ff0f3b59..3775109787 100644 --- a/src/paperless/parsers/tesseract.py +++ b/src/paperless/parsers/tesseract.py @@ -21,6 +21,7 @@ from documents.parsers import make_thumbnail_from_pdf from documents.utils import copy_file_with_basic_stats from documents.utils import maybe_override_pixel_limit from documents.utils import remove_alpha_channel +from paperless.config import OCR_RESERVED_USER_ARGS from paperless.config import OcrConfig from paperless.models import CleanChoices from paperless.models import ModeChoices @@ -351,7 +352,22 @@ class RasterisedDocumentParser: if self.settings.user_args is not None: try: - ocrmypdf_args = {**ocrmypdf_args, **self.settings.user_args} + reserved = sorted( + OCR_RESERVED_USER_ARGS & self.settings.user_args.keys(), + ) + if reserved: + self.log.warning( + f"Ignoring OCR user arguments managed by paperless: " + f"{', '.join(reserved)}", + ) + ocrmypdf_args = { + **ocrmypdf_args, + **{ + k: v + for k, v in self.settings.user_args.items() + if k not in OCR_RESERVED_USER_ARGS + }, + } except Exception as e: self.log.warning( f"There is an issue with PAPERLESS_OCR_USER_ARGS, so " diff --git a/src/paperless/serialisers.py b/src/paperless/serialisers.py index 6430df889e..fa1a48c6b0 100644 --- a/src/paperless/serialisers.py +++ b/src/paperless/serialisers.py @@ -19,6 +19,7 @@ from PIL import Image from rest_framework import serializers from rest_framework.authtoken.serializers import AuthTokenSerializer +from paperless.config import OCR_RESERVED_USER_ARGS from paperless.models import ApplicationConfiguration from paperless.network import validate_outbound_http_url from paperless.validators import reject_dangerous_svg @@ -251,6 +252,13 @@ class ApplicationConfigurationSerializer( def validate_user_args(self, value): self._require_json_object("user_args", value) + if value is not None: + reserved = sorted(OCR_RESERVED_USER_ARGS & value.keys()) + if reserved: + raise serializers.ValidationError( + f"user_args may not set arguments managed by paperless: " + f"{', '.join(reserved)}.", + ) return value def validate_barcode_tag_mapping(self, value): diff --git a/src/paperless/tests/parsers/test_tesseract_custom_settings.py b/src/paperless/tests/parsers/test_tesseract_custom_settings.py index e5dd3a03b3..244fd4651b 100644 --- a/src/paperless/tests/parsers/test_tesseract_custom_settings.py +++ b/src/paperless/tests/parsers/test_tesseract_custom_settings.py @@ -243,6 +243,34 @@ class TestParserSettingsFromDb(DirectoriesMixin, FileSystemAssertsMixin, TestCas "UseDeviceIndependentColor", ) + def test_ocr_user_args_cannot_override_reserved(self) -> None: + """ + GIVEN: + - User arguments which attempt to override arguments the parser + manages itself, alongside a legitimate argument + WHEN: + - OCR parameters are constructed + THEN: + - Reserved arguments keep the parser supplied values + - The legitimate argument is still applied + """ + instance = ApplicationConfiguration.objects.all().first() + assert instance is not None + instance.user_args = { + "sidecar": "/tmp/evil.py", + "output_file": "/tmp/out.pdf", + "input_file_or_options": "/etc/passwd", + "unpaper_args": "--pre-rotate 90", + } + instance.save() + + params = self.get_params() + + self.assertEqual(params["sidecar"], "sidecar.txt") + self.assertEqual(params["output_file"], "output.pdf") + self.assertEqual(params["input_file_or_options"], "input.pdf") + self.assertEqual(params["unpaper_args"], "--pre-rotate 90") + def test_ocr_user_args(self) -> None: """ GIVEN: