import json from io import BytesIO from pathlib import Path from django.contrib.auth.models import User from django.core.files.uploadedfile import SimpleUploadedFile from rest_framework import status from rest_framework.test import APITestCase from documents.tests.utils import DirectoriesMixin from paperless.models import ApplicationConfiguration from paperless.models import ColorConvertChoices class TestApiAppConfig(DirectoriesMixin, APITestCase): ENDPOINT = "/api/config/" def setUp(self) -> None: super().setUp() user = User.objects.create_superuser(username="temp_admin") self.client.force_authenticate(user=user) def test_api_get_config(self): """ GIVEN: - API request to get app config WHEN: - API is called THEN: - Existing config """ response = self.client.get(self.ENDPOINT, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) self.maxDiff = None self.assertDictEqual( response.data[0], { "id": 1, "output_type": None, "pages": None, "language": None, "mode": None, "skip_archive_file": None, "image_dpi": None, "unpaper_clean": None, "deskew": None, "rotate_pages": None, "rotate_pages_threshold": None, "max_image_pixels": None, "color_conversion_strategy": None, "user_args": None, "app_title": None, "app_logo": None, "barcodes_enabled": None, "barcode_enable_tiff_support": None, "barcode_string": None, "barcode_retain_split_pages": None, "barcode_enable_asn": None, "barcode_asn_prefix": None, "barcode_upscale": None, "barcode_dpi": None, "barcode_max_pages": None, "barcode_enable_tag": None, "barcode_tag_mapping": None, }, ) def test_api_get_ui_settings_with_config(self): """ GIVEN: - Existing config with app_title, app_logo specified WHEN: - API to retrieve uisettings is called THEN: - app_title and app_logo are included """ config = ApplicationConfiguration.objects.first() config.app_title = "Fancy New Title" config.app_logo = "/logo/example.jpg" config.save() response = self.client.get("/api/ui_settings/", format="json") self.assertDictEqual( response.data["settings"], { "app_title": config.app_title, "app_logo": config.app_logo, } | response.data["settings"], ) def test_api_update_config(self): """ GIVEN: - API request to update app config WHEN: - API is called THEN: - Correct HTTP response - Config is updated """ response = self.client.patch( f"{self.ENDPOINT}1/", json.dumps( { "color_conversion_strategy": ColorConvertChoices.RGB, }, ), content_type="application/json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) config = ApplicationConfiguration.objects.first() self.assertEqual(config.color_conversion_strategy, ColorConvertChoices.RGB) def test_api_update_config_empty_fields(self): """ GIVEN: - API request to update app config with empty string for user_args JSONField and language field WHEN: - API is called THEN: - Correct HTTP response - user_args is set to None """ response = self.client.patch( f"{self.ENDPOINT}1/", json.dumps( { "user_args": "", "language": "", "barcode_tag_mapping": "", }, ), content_type="application/json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) config = ApplicationConfiguration.objects.first() self.assertEqual(config.user_args, None) self.assertEqual(config.language, None) self.assertEqual(config.barcode_tag_mapping, None) def test_api_replace_app_logo(self): """ GIVEN: - Existing config with app_logo specified WHEN: - API to replace app_logo is called THEN: - old app_logo file is deleted """ admin = User.objects.create_superuser(username="admin") self.client.force_login(user=admin) response = self.client.get("/logo/") self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) self.client.patch( f"{self.ENDPOINT}1/", { "app_logo": SimpleUploadedFile( name="simple.jpg", content=( Path(__file__).parent / "samples" / "simple.jpg" ).read_bytes(), content_type="image/jpeg", ), }, ) # Logo exists at /logo/simple.jpg response = self.client.get("/logo/simple.jpg") self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIn("image/jpeg", response["Content-Type"]) config = ApplicationConfiguration.objects.first() old_logo = config.app_logo self.assertTrue(Path(old_logo.path).exists()) self.client.patch( f"{self.ENDPOINT}1/", { "app_logo": SimpleUploadedFile( name="simple.png", content=( Path(__file__).parent / "samples" / "simple.png" ).read_bytes(), content_type="image/png", ), }, ) self.assertFalse(Path(old_logo.path).exists()) def test_api_rejects_malicious_svg_logo(self): """ GIVEN: - An SVG logo containing a """ svg_file = BytesIO(malicious_svg) svg_file.name = "malicious_script.svg" response = self.client.patch( f"{self.ENDPOINT}1/", {"app_logo": svg_file}, format="multipart", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn("disallowed svg tag", str(response.data).lower()) def test_api_rejects_malicious_svg_with_style_javascript(self): """ GIVEN: - An SVG logo containing javascript: in style attribute WHEN: - Uploaded via PATCH to app config THEN: - SVG is rejected with 400 """ malicious_svg = b""" """ svg_file = BytesIO(malicious_svg) svg_file.name = "malicious_style.svg" response = self.client.patch( f"{self.ENDPOINT}1/", {"app_logo": svg_file}, format="multipart", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn( "disallowed pattern in style attribute", str(response.data).lower(), ) self.assertIn("style", str(response.data).lower()) def test_api_rejects_svg_with_style_expression(self): """ GIVEN: - An SVG logo containing CSS expression() in style WHEN: - Uploaded via PATCH to app config THEN: - SVG is rejected with 400 """ malicious_svg = b""" """ svg_file = BytesIO(malicious_svg) svg_file.name = "expression_style.svg" response = self.client.patch( f"{self.ENDPOINT}1/", {"app_logo": svg_file}, format="multipart", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn("disallowed", str(response.data).lower()) def test_api_rejects_svg_with_style_cdata_javascript(self): """ GIVEN: - An SVG logo with javascript: hidden in a CDATA style block WHEN: - Uploaded via PATCH to app config THEN: - SVG is rejected with 400 """ malicious_svg = b""" """ svg_file = BytesIO(malicious_svg) svg_file.name = "cdata_style.svg" response = self.client.patch( f"{self.ENDPOINT}1/", {"app_logo": svg_file}, format="multipart", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn("disallowed", str(response.data).lower()) def test_api_rejects_svg_with_style_import(self): """ GIVEN: - An SVG logo containing @import in style WHEN: - Uploaded via PATCH to app config THEN: - SVG is rejected with 400 """ malicious_svg = b""" """ svg_file = BytesIO(malicious_svg) svg_file.name = "import_style.svg" response = self.client.patch( f"{self.ENDPOINT}1/", {"app_logo": svg_file}, format="multipart", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn("disallowed", str(response.data).lower()) def test_api_accepts_valid_svg_with_safe_style(self): """ GIVEN: - A valid SVG logo with safe style attributes WHEN: - Uploaded via PATCH to app config THEN: - SVG is accepted with 200 """ safe_svg = b""" """ svg_file = BytesIO(safe_svg) svg_file.name = "safe_logo.svg" response = self.client.patch( f"{self.ENDPOINT}1/", {"app_logo": svg_file}, format="multipart", ) self.assertEqual(response.status_code, status.HTTP_200_OK) def test_api_accepts_valid_svg_with_safe_style_tag(self): """ GIVEN: - A valid SVG logo with an embedded """ svg_file = BytesIO(safe_svg) svg_file.name = "safe_logo_with_style.svg" response = self.client.patch( f"{self.ENDPOINT}1/", {"app_logo": svg_file}, format="multipart", ) self.assertEqual(response.status_code, status.HTTP_200_OK) def test_api_rejects_svg_with_disallowed_attribute(self): """ GIVEN: - An SVG with a disallowed attribute (onclick) WHEN: - Uploaded via PATCH to app config THEN: - SVG is rejected with 400 """ malicious_svg = b""" """ svg_file = BytesIO(malicious_svg) svg_file.name = "onclick_attribute.svg" response = self.client.patch( f"{self.ENDPOINT}1/", {"app_logo": svg_file}, format="multipart", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn("disallowed", str(response.data).lower()) self.assertIn("attribute", str(response.data).lower()) def test_api_rejects_svg_with_disallowed_tag(self): """ GIVEN: - An SVG with a disallowed tag (script) WHEN: - Uploaded via PATCH to app config THEN: - SVG is rejected with 400 """ malicious_svg = b""" """ svg_file = BytesIO(malicious_svg) svg_file.name = "script_tag.svg" response = self.client.patch( f"{self.ENDPOINT}1/", {"app_logo": svg_file}, format="multipart", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn("disallowed", str(response.data).lower()) self.assertIn("tag", str(response.data).lower()) def test_api_rejects_svg_with_javascript_href(self): """ GIVEN: - An SVG with javascript: in href attribute WHEN: - Uploaded via PATCH to app config THEN: - SVG is rejected with 400 """ malicious_svg = b""" """ svg_file = BytesIO(malicious_svg) svg_file.name = "javascript_href.svg" response = self.client.patch( f"{self.ENDPOINT}1/", {"app_logo": svg_file}, format="multipart", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn("disallowed", str(response.data).lower()) self.assertIn("javascript", str(response.data).lower()) def test_api_rejects_svg_with_javascript_xlink_href(self): """ GIVEN: - An SVG with javascript: in xlink:href attribute WHEN: - Uploaded via PATCH to app config THEN: - SVG is rejected with 400 """ malicious_svg = b""" """ svg_file = BytesIO(malicious_svg) svg_file.name = "javascript_xlink_href.svg" response = self.client.patch( f"{self.ENDPOINT}1/", {"app_logo": svg_file}, format="multipart", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn("disallowed", str(response.data).lower()) self.assertIn("javascript", str(response.data).lower()) def test_api_rejects_svg_with_data_text_html_href(self): """ GIVEN: - An SVG with data:text/html in href attribute WHEN: - Uploaded via PATCH to app config THEN: - SVG is rejected with 400 """ malicious_svg = b""" """ svg_file = BytesIO(malicious_svg) svg_file.name = "data_html_href.svg" response = self.client.patch( f"{self.ENDPOINT}1/", {"app_logo": svg_file}, format="multipart", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) # This will now catch "Disallowed URI scheme" self.assertIn("disallowed", str(response.data).lower()) def test_api_rejects_svg_with_unknown_namespace_attribute(self): """ GIVEN: - An SVG with an attribute in an unknown/custom namespace WHEN: - Uploaded via PATCH to app config THEN: - SVG is rejected with 400 - Error message identifies the namespaced attribute as disallowed """ # Define a custom namespace "my:hack" and try to use it malicious_svg = b""" """ svg_file = BytesIO(malicious_svg) svg_file.name = "unknown_namespace.svg" response = self.client.patch( f"{self.ENDPOINT}1/", {"app_logo": svg_file}, format="multipart", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) # The error message should show the full Clark notation (curly braces) # because the validator's 'else' block kept the raw lxml name. error_msg = str(response.data).lower() self.assertIn("disallowed svg attribute", error_msg) self.assertIn("{http://example.com/hack}fill", error_msg) def test_api_rejects_svg_with_external_http_href(self) -> None: """ GIVEN: - An SVG with an external URI (http://) in a safe tag's href attribute. WHEN: - Uploaded via PATCH to app config THEN: - SVG is rejected with 400 because http:// is not a safe_prefix. """ from io import BytesIO # http:// is not in dangerous_schemes, but it is not in safe_prefixes. malicious_svg = b""" """ svg_file = BytesIO(malicious_svg) svg_file.name = "external_http_href.svg" response = self.client.patch( f"{self.ENDPOINT}1/", {"app_logo": svg_file}, format="multipart", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) # Check for the error message raised by the safe_prefixes check self.assertIn("uri scheme not allowed", str(response.data).lower()) def test_create_not_allowed(self): """ GIVEN: - API request to create a new app config WHEN: - API is called THEN: - Correct HTTP response - No new config is created """ response = self.client.post( self.ENDPOINT, json.dumps( { "output_type": "pdf", }, ), content_type="application/json", ) self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) self.assertEqual(ApplicationConfiguration.objects.count(), 1)