mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-01-12 21:44:21 -06:00
Merge branch 'dev' into feature-ai
This commit is contained in:
@@ -186,7 +186,11 @@ class BarcodePlugin(ConsumeTaskPlugin):
|
||||
|
||||
# Update/overwrite an ASN if possible
|
||||
# After splitting, as otherwise each split document gets the same ASN
|
||||
if self.settings.barcode_enable_asn and (located_asn := self.asn) is not None:
|
||||
if (
|
||||
self.settings.barcode_enable_asn
|
||||
and not self.metadata.skip_asn
|
||||
and (located_asn := self.asn) is not None
|
||||
):
|
||||
logger.info(f"Found ASN in barcode: {located_asn}")
|
||||
self.metadata.asn = located_asn
|
||||
|
||||
|
||||
@@ -433,6 +433,8 @@ def merge(
|
||||
|
||||
if user is not None:
|
||||
overrides.owner_id = user.id
|
||||
# Avoid copying or detecting ASN from merged PDFs to prevent collision
|
||||
overrides.skip_asn = True
|
||||
|
||||
logger.info("Adding merged document to the task queue.")
|
||||
|
||||
|
||||
@@ -696,7 +696,7 @@ class ConsumerPlugin(
|
||||
pk=self.metadata.storage_path_id,
|
||||
)
|
||||
|
||||
if self.metadata.asn is not None:
|
||||
if self.metadata.asn is not None and not self.metadata.skip_asn:
|
||||
document.archive_serial_number = self.metadata.asn
|
||||
|
||||
if self.metadata.owner_id:
|
||||
@@ -812,8 +812,8 @@ class ConsumerPreflightPlugin(
|
||||
"""
|
||||
Check that if override_asn is given, it is unique and within a valid range
|
||||
"""
|
||||
if self.metadata.asn is None:
|
||||
# check not necessary in case no ASN gets set
|
||||
if self.metadata.skip_asn or self.metadata.asn is None:
|
||||
# if skip is set or ASN is None
|
||||
return
|
||||
# Validate the range is above zero and less than uint32_t max
|
||||
# otherwise, Whoosh can't handle it in the index
|
||||
|
||||
@@ -30,6 +30,7 @@ class DocumentMetadataOverrides:
|
||||
change_users: list[int] | None = None
|
||||
change_groups: list[int] | None = None
|
||||
custom_fields: dict | None = None
|
||||
skip_asn: bool = False
|
||||
|
||||
def update(self, other: "DocumentMetadataOverrides") -> "DocumentMetadataOverrides":
|
||||
"""
|
||||
@@ -49,6 +50,8 @@ class DocumentMetadataOverrides:
|
||||
self.storage_path_id = other.storage_path_id
|
||||
if other.owner_id is not None:
|
||||
self.owner_id = other.owner_id
|
||||
if other.skip_asn:
|
||||
self.skip_asn = True
|
||||
|
||||
# merge
|
||||
if self.tag_ids is None:
|
||||
|
||||
@@ -20,6 +20,7 @@ from documents.models import Tag
|
||||
from documents.models import Workflow
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.permissions import get_objects_for_user_owner_aware
|
||||
from documents.regex import safe_regex_search
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.db.models import QuerySet
|
||||
@@ -152,7 +153,7 @@ def match_storage_paths(document: Document, classifier: DocumentClassifier, user
|
||||
|
||||
|
||||
def matches(matching_model: MatchingModel, document: Document):
|
||||
search_kwargs = {}
|
||||
search_flags = 0
|
||||
|
||||
document_content = document.content
|
||||
|
||||
@@ -161,14 +162,18 @@ def matches(matching_model: MatchingModel, document: Document):
|
||||
return False
|
||||
|
||||
if matching_model.is_insensitive:
|
||||
search_kwargs = {"flags": re.IGNORECASE}
|
||||
search_flags = re.IGNORECASE
|
||||
|
||||
if matching_model.matching_algorithm == MatchingModel.MATCH_NONE:
|
||||
return False
|
||||
|
||||
elif matching_model.matching_algorithm == MatchingModel.MATCH_ALL:
|
||||
for word in _split_match(matching_model):
|
||||
search_result = re.search(rf"\b{word}\b", document_content, **search_kwargs)
|
||||
search_result = re.search(
|
||||
rf"\b{word}\b",
|
||||
document_content,
|
||||
flags=search_flags,
|
||||
)
|
||||
if not search_result:
|
||||
return False
|
||||
log_reason(
|
||||
@@ -180,7 +185,7 @@ def matches(matching_model: MatchingModel, document: Document):
|
||||
|
||||
elif matching_model.matching_algorithm == MatchingModel.MATCH_ANY:
|
||||
for word in _split_match(matching_model):
|
||||
if re.search(rf"\b{word}\b", document_content, **search_kwargs):
|
||||
if re.search(rf"\b{word}\b", document_content, flags=search_flags):
|
||||
log_reason(matching_model, document, f"it contains this word: {word}")
|
||||
return True
|
||||
return False
|
||||
@@ -190,7 +195,7 @@ def matches(matching_model: MatchingModel, document: Document):
|
||||
re.search(
|
||||
rf"\b{re.escape(matching_model.match)}\b",
|
||||
document_content,
|
||||
**search_kwargs,
|
||||
flags=search_flags,
|
||||
),
|
||||
)
|
||||
if result:
|
||||
@@ -202,16 +207,11 @@ def matches(matching_model: MatchingModel, document: Document):
|
||||
return result
|
||||
|
||||
elif matching_model.matching_algorithm == MatchingModel.MATCH_REGEX:
|
||||
try:
|
||||
match = re.search(
|
||||
re.compile(matching_model.match, **search_kwargs),
|
||||
document_content,
|
||||
)
|
||||
except re.error:
|
||||
logger.error(
|
||||
f"Error while processing regular expression {matching_model.match}",
|
||||
)
|
||||
return False
|
||||
match = safe_regex_search(
|
||||
matching_model.match,
|
||||
document_content,
|
||||
flags=search_flags,
|
||||
)
|
||||
if match:
|
||||
log_reason(
|
||||
matching_model,
|
||||
|
||||
50
src/documents/regex.py
Normal file
50
src/documents/regex.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import textwrap
|
||||
|
||||
import regex
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger("paperless.regex")
|
||||
|
||||
REGEX_TIMEOUT_SECONDS: float = getattr(settings, "MATCH_REGEX_TIMEOUT_SECONDS", 0.1)
|
||||
|
||||
|
||||
def validate_regex_pattern(pattern: str) -> None:
|
||||
"""
|
||||
Validate user provided regex for basic compile errors.
|
||||
Raises ValueError on validation failure.
|
||||
"""
|
||||
|
||||
try:
|
||||
regex.compile(pattern)
|
||||
except regex.error as exc:
|
||||
raise ValueError(exc.msg) from exc
|
||||
|
||||
|
||||
def safe_regex_search(pattern: str, text: str, *, flags: int = 0):
|
||||
"""
|
||||
Run a regex search with a timeout. Returns a match object or None.
|
||||
Validation errors and timeouts are logged and treated as no match.
|
||||
"""
|
||||
|
||||
try:
|
||||
validate_regex_pattern(pattern)
|
||||
compiled = regex.compile(pattern, flags=flags)
|
||||
except (regex.error, ValueError) as exc:
|
||||
logger.error(
|
||||
"Error while processing regular expression %s: %s",
|
||||
textwrap.shorten(pattern, width=80, placeholder="…"),
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
return compiled.search(text, timeout=REGEX_TIMEOUT_SECONDS)
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
"Regular expression matching timed out for pattern %s",
|
||||
textwrap.shorten(pattern, width=80, placeholder="…"),
|
||||
)
|
||||
return None
|
||||
@@ -21,6 +21,7 @@ from django.core.validators import MaxLengthValidator
|
||||
from django.core.validators import RegexValidator
|
||||
from django.core.validators import integer_validator
|
||||
from django.db.models import Count
|
||||
from django.db.models.functions import Lower
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.dateparse import parse_datetime
|
||||
from django.utils.text import slugify
|
||||
@@ -38,6 +39,7 @@ from guardian.utils import get_user_obj_perms_model
|
||||
from rest_framework import fields
|
||||
from rest_framework import serializers
|
||||
from rest_framework.fields import SerializerMethodField
|
||||
from rest_framework.filters import OrderingFilter
|
||||
|
||||
if settings.AUDIT_LOG_ENABLED:
|
||||
from auditlog.context import set_actor
|
||||
@@ -69,6 +71,7 @@ from documents.parsers import is_mime_type_supported
|
||||
from documents.permissions import get_document_count_filter_for_user
|
||||
from documents.permissions import get_groups_with_only_permission
|
||||
from documents.permissions import set_permissions_for_object
|
||||
from documents.regex import validate_regex_pattern
|
||||
from documents.templating.filepath import validate_filepath_template_and_render
|
||||
from documents.templating.utils import convert_format_str_to_template_format
|
||||
from documents.validators import uri_validator
|
||||
@@ -139,10 +142,11 @@ class MatchingModelSerializer(serializers.ModelSerializer):
|
||||
and self.initial_data["matching_algorithm"] == MatchingModel.MATCH_REGEX
|
||||
):
|
||||
try:
|
||||
re.compile(match)
|
||||
except re.error as e:
|
||||
validate_regex_pattern(match)
|
||||
except ValueError as e:
|
||||
logger.debug(f"Invalid regular expression: {e!s}")
|
||||
raise serializers.ValidationError(
|
||||
_("Invalid regular expression: %(error)s") % {"error": str(e.msg)},
|
||||
"Invalid regular expression, see log for details.",
|
||||
)
|
||||
return match
|
||||
|
||||
@@ -575,15 +579,29 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
|
||||
)
|
||||
def get_children(self, obj):
|
||||
filter_q = self.context.get("document_count_filter")
|
||||
request = self.context.get("request")
|
||||
if filter_q is None:
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None) if request else None
|
||||
filter_q = get_document_count_filter_for_user(user)
|
||||
self.context["document_count_filter"] = filter_q
|
||||
serializer = TagSerializer(
|
||||
|
||||
children_queryset = (
|
||||
obj.get_children_queryset()
|
||||
.select_related("owner")
|
||||
.annotate(document_count=Count("documents", filter=filter_q)),
|
||||
.annotate(document_count=Count("documents", filter=filter_q))
|
||||
)
|
||||
|
||||
view = self.context.get("view")
|
||||
ordering = (
|
||||
OrderingFilter().get_ordering(request, children_queryset, view)
|
||||
if request and view
|
||||
else None
|
||||
)
|
||||
ordering = ordering or (Lower("name"),)
|
||||
children_queryset = children_queryset.order_by(*ordering)
|
||||
|
||||
serializer = TagSerializer(
|
||||
children_queryset,
|
||||
many=True,
|
||||
user=self.user,
|
||||
full_perms=self.full_perms,
|
||||
|
||||
@@ -30,6 +30,7 @@ from documents.caching import invalidate_llm_suggestions_cache
|
||||
from documents.data_models import ConsumableDocument
|
||||
from documents.file_handling import create_source_path_directory
|
||||
from documents.file_handling import delete_empty_directories
|
||||
from documents.file_handling import generate_filename
|
||||
from documents.file_handling import generate_unique_filename
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
@@ -44,6 +45,7 @@ from documents.models import WorkflowAction
|
||||
from documents.models import WorkflowRun
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.permissions import get_objects_for_user_owner_aware
|
||||
from documents.templating.utils import convert_format_str_to_template_format
|
||||
from documents.workflows.actions import build_workflow_action_context
|
||||
from documents.workflows.actions import execute_email_action
|
||||
from documents.workflows.actions import execute_webhook_action
|
||||
@@ -392,6 +394,19 @@ class CannotMoveFilesException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _filename_template_uses_custom_fields(doc: Document) -> bool:
|
||||
template = None
|
||||
if doc.storage_path is not None:
|
||||
template = doc.storage_path.path
|
||||
elif settings.FILENAME_FORMAT is not None:
|
||||
template = convert_format_str_to_template_format(settings.FILENAME_FORMAT)
|
||||
|
||||
if not template:
|
||||
return False
|
||||
|
||||
return "custom_fields" in template
|
||||
|
||||
|
||||
# should be disabled in /src/documents/management/commands/document_importer.py handle
|
||||
@receiver(models.signals.post_save, sender=CustomFieldInstance, weak=False)
|
||||
@receiver(models.signals.m2m_changed, sender=Document.tags.through, weak=False)
|
||||
@@ -402,6 +417,8 @@ def update_filename_and_move_files(
|
||||
**kwargs,
|
||||
):
|
||||
if isinstance(instance, CustomFieldInstance):
|
||||
if not _filename_template_uses_custom_fields(instance.document):
|
||||
return
|
||||
instance = instance.document
|
||||
|
||||
def validate_move(instance, old_path: Path, new_path: Path):
|
||||
@@ -439,21 +456,47 @@ def update_filename_and_move_files(
|
||||
old_filename = instance.filename
|
||||
old_source_path = instance.source_path
|
||||
|
||||
candidate_filename = generate_filename(instance)
|
||||
candidate_source_path = (
|
||||
settings.ORIGINALS_DIR / candidate_filename
|
||||
).resolve()
|
||||
if candidate_filename == Path(old_filename):
|
||||
new_filename = Path(old_filename)
|
||||
elif (
|
||||
candidate_source_path.exists()
|
||||
and candidate_source_path != old_source_path
|
||||
):
|
||||
# Only fall back to unique search when there is an actual conflict
|
||||
new_filename = generate_unique_filename(instance)
|
||||
else:
|
||||
new_filename = candidate_filename
|
||||
|
||||
# Need to convert to string to be able to save it to the db
|
||||
instance.filename = str(generate_unique_filename(instance))
|
||||
instance.filename = str(new_filename)
|
||||
move_original = old_filename != instance.filename
|
||||
|
||||
old_archive_filename = instance.archive_filename
|
||||
old_archive_path = instance.archive_path
|
||||
|
||||
if instance.has_archive_version:
|
||||
# Need to convert to string to be able to save it to the db
|
||||
instance.archive_filename = str(
|
||||
generate_unique_filename(
|
||||
archive_candidate = generate_filename(instance, archive_filename=True)
|
||||
archive_candidate_path = (
|
||||
settings.ARCHIVE_DIR / archive_candidate
|
||||
).resolve()
|
||||
if archive_candidate == Path(old_archive_filename):
|
||||
new_archive_filename = Path(old_archive_filename)
|
||||
elif (
|
||||
archive_candidate_path.exists()
|
||||
and archive_candidate_path != old_archive_path
|
||||
):
|
||||
new_archive_filename = generate_unique_filename(
|
||||
instance,
|
||||
archive_filename=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
else:
|
||||
new_archive_filename = archive_candidate
|
||||
|
||||
instance.archive_filename = str(new_archive_filename)
|
||||
|
||||
move_archive = old_archive_filename != instance.archive_filename
|
||||
else:
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
|
||||
<text x="10" y="20">Hello</text>
|
||||
<script>alert('XSS')</script>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 140 B |
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -207,17 +208,396 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
||||
THEN:
|
||||
- SVG is rejected with 400
|
||||
"""
|
||||
path = Path(__file__).parent / "samples" / "malicious.svg"
|
||||
with path.open("rb") as f:
|
||||
response = self.client.patch(
|
||||
f"{self.ENDPOINT}1/",
|
||||
{"app_logo": f},
|
||||
format="multipart",
|
||||
)
|
||||
malicious_svg = b"""<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
|
||||
<text x="10" y="20">Hello</text>
|
||||
<script>alert('XSS')</script>
|
||||
</svg>
|
||||
"""
|
||||
|
||||
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"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" style="background: url(javascript:alert('XSS'));" fill="red"/>
|
||||
</svg>"""
|
||||
|
||||
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"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" style="width: expression(alert('XSS'));" fill="blue"/>
|
||||
</svg>"""
|
||||
|
||||
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"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<style><![CDATA[
|
||||
rect { background: url("javascript:alert('XSS')"); }
|
||||
]]></style>
|
||||
<rect width="100" height="100" fill="purple"/>
|
||||
</svg>"""
|
||||
|
||||
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"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" style="@import url('http://evil.com/malicious.css');" fill="green"/>
|
||||
</svg>"""
|
||||
|
||||
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"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" style="fill: #ff6b6b; stroke: #333; stroke-width: 2;"/>
|
||||
<circle cx="50" cy="50" r="30" style="fill: white; opacity: 0.8;"/>
|
||||
</svg>"""
|
||||
|
||||
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 <style> tag
|
||||
WHEN:
|
||||
- Uploaded to app config
|
||||
THEN:
|
||||
- SVG is accepted with 200
|
||||
"""
|
||||
|
||||
safe_svg = b"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<style>
|
||||
rect { fill: #ff6b6b; stroke: #333; stroke-width: 2; }
|
||||
circle { fill: white; opacity: 0.8; }
|
||||
</style>
|
||||
<rect width="100" height="100"/>
|
||||
<circle cx="50" cy="50" r="30"/>
|
||||
</svg>"""
|
||||
|
||||
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"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" fill="red" onclick="alert('XSS')"/>
|
||||
</svg>"""
|
||||
|
||||
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"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<script>alert('XSS')</script>
|
||||
<rect width="100" height="100" fill="blue"/>
|
||||
</svg>"""
|
||||
|
||||
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"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<rect id="a" width="10" height="10" />
|
||||
</defs>
|
||||
<use href="javascript:alert('XSS')" />
|
||||
</svg>"""
|
||||
|
||||
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"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 100 100">
|
||||
<use xlink:href="javascript:alert('XSS')" />
|
||||
</svg>"""
|
||||
|
||||
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"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<rect id="r" width="100" height="100" fill="purple"/>
|
||||
</defs>
|
||||
<use href="javascript:alert(1)" />
|
||||
</svg>"""
|
||||
|
||||
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"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:hack="http://example.com/hack"
|
||||
viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" hack:fill="red" />
|
||||
</svg>"""
|
||||
|
||||
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"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<use href="http://evil.com/logo.svg" />
|
||||
</svg>"""
|
||||
|
||||
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:
|
||||
|
||||
@@ -602,11 +602,13 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
||||
expected_filename,
|
||||
)
|
||||
self.assertEqual(consume_file_args[1].title, None)
|
||||
self.assertTrue(consume_file_args[1].skip_asn)
|
||||
|
||||
# With metadata_document_id overrides
|
||||
result = bulk_edit.merge(doc_ids, metadata_document_id=metadata_document_id)
|
||||
consume_file_args, _ = mock_consume_file.call_args
|
||||
self.assertEqual(consume_file_args[1].title, "A (merged)")
|
||||
self.assertTrue(consume_file_args[1].skip_asn)
|
||||
|
||||
self.assertEqual(result, "OK")
|
||||
|
||||
@@ -647,6 +649,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
||||
expected_filename,
|
||||
)
|
||||
self.assertEqual(consume_file_args[1].title, None)
|
||||
self.assertTrue(consume_file_args[1].skip_asn)
|
||||
|
||||
delete_documents_args, _ = mock_delete_documents.call_args
|
||||
self.assertEqual(
|
||||
|
||||
@@ -412,6 +412,14 @@ class TestConsumer(
|
||||
self.assertEqual(document.archive_serial_number, 123)
|
||||
self._assert_first_last_send_progress()
|
||||
|
||||
def testMetadataOverridesSkipAsnPropagation(self):
|
||||
overrides = DocumentMetadataOverrides()
|
||||
incoming = DocumentMetadataOverrides(skip_asn=True)
|
||||
|
||||
overrides.update(incoming)
|
||||
|
||||
self.assertTrue(overrides.skip_asn)
|
||||
|
||||
def testOverrideTitlePlaceholders(self):
|
||||
c = Correspondent.objects.create(name="Correspondent Name")
|
||||
dt = DocumentType.objects.create(name="DocType Name")
|
||||
|
||||
@@ -16,6 +16,7 @@ from django.utils import timezone
|
||||
from documents.file_handling import create_source_path_directory
|
||||
from documents.file_handling import delete_empty_directories
|
||||
from documents.file_handling import generate_filename
|
||||
from documents.file_handling import generate_unique_filename
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
@@ -1632,6 +1633,73 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestCustomFieldFilenameUpdates(
|
||||
DirectoriesMixin,
|
||||
FileSystemAssertsMixin,
|
||||
TestCase,
|
||||
):
|
||||
def setUp(self):
|
||||
self.cf = CustomField.objects.create(
|
||||
name="flavor",
|
||||
data_type=CustomField.FieldDataType.STRING,
|
||||
)
|
||||
self.doc = Document.objects.create(
|
||||
title="document",
|
||||
mime_type="application/pdf",
|
||||
checksum="abc123",
|
||||
)
|
||||
self.cfi = CustomFieldInstance.objects.create(
|
||||
field=self.cf,
|
||||
document=self.doc,
|
||||
value_text="initial",
|
||||
)
|
||||
return super().setUp()
|
||||
|
||||
@override_settings(FILENAME_FORMAT=None)
|
||||
def test_custom_field_not_in_template_skips_filename_work(self):
|
||||
storage_path = StoragePath.objects.create(path="{{created}}/{{ title }}")
|
||||
self.doc.storage_path = storage_path
|
||||
self.doc.save()
|
||||
initial_filename = generate_filename(self.doc)
|
||||
Document.objects.filter(pk=self.doc.pk).update(filename=str(initial_filename))
|
||||
self.doc.refresh_from_db()
|
||||
Path(self.doc.source_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(self.doc.source_path).touch()
|
||||
|
||||
with mock.patch("documents.signals.handlers.generate_unique_filename") as m:
|
||||
m.side_effect = generate_unique_filename
|
||||
self.cfi.value_text = "updated"
|
||||
self.cfi.save()
|
||||
|
||||
self.doc.refresh_from_db()
|
||||
self.assertEqual(Path(self.doc.filename), initial_filename)
|
||||
self.assertEqual(m.call_count, 0)
|
||||
|
||||
@override_settings(FILENAME_FORMAT=None)
|
||||
def test_custom_field_in_template_triggers_filename_update(self):
|
||||
storage_path = StoragePath.objects.create(
|
||||
path="{{ custom_fields|get_cf_value('flavor') }}/{{ title }}",
|
||||
)
|
||||
self.doc.storage_path = storage_path
|
||||
self.doc.save()
|
||||
initial_filename = generate_filename(self.doc)
|
||||
Document.objects.filter(pk=self.doc.pk).update(filename=str(initial_filename))
|
||||
self.doc.refresh_from_db()
|
||||
Path(self.doc.source_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(self.doc.source_path).touch()
|
||||
|
||||
with mock.patch("documents.signals.handlers.generate_unique_filename") as m:
|
||||
m.side_effect = generate_unique_filename
|
||||
self.cfi.value_text = "updated"
|
||||
self.cfi.save()
|
||||
|
||||
self.doc.refresh_from_db()
|
||||
expected_filename = Path("updated/document.pdf")
|
||||
self.assertEqual(Path(self.doc.filename), expected_filename)
|
||||
self.assertTrue(Path(self.doc.source_path).is_file())
|
||||
self.assertLessEqual(m.call_count, 1)
|
||||
|
||||
|
||||
class TestPathDateLocalization:
|
||||
"""
|
||||
Groups all tests related to the `localize_date` function.
|
||||
|
||||
@@ -206,6 +206,22 @@ class TestMatching(_TestMatchingBase):
|
||||
def test_tach_invalid_regex(self):
|
||||
self._test_matching("[", "MATCH_REGEX", [], ["Don't match this"])
|
||||
|
||||
def test_match_regex_timeout_returns_false(self):
|
||||
tag = Tag.objects.create(
|
||||
name="slow",
|
||||
match=r"(a+)+$",
|
||||
matching_algorithm=Tag.MATCH_REGEX,
|
||||
)
|
||||
document = Document(content=("a" * 5000) + "X")
|
||||
|
||||
with self.assertLogs("paperless.regex", level="WARNING") as cm:
|
||||
self.assertFalse(matching.matches(tag, document))
|
||||
|
||||
self.assertTrue(
|
||||
any("timed out" in message for message in cm.output),
|
||||
f"Expected timeout log, got {cm.output}",
|
||||
)
|
||||
|
||||
def test_match_fuzzy(self):
|
||||
self._test_matching(
|
||||
"Springfield, Miss.",
|
||||
|
||||
@@ -17,6 +17,7 @@ from django.utils import timezone
|
||||
from guardian.shortcuts import assign_perm
|
||||
from guardian.shortcuts import get_groups_with_perms
|
||||
from guardian.shortcuts import get_users_with_perms
|
||||
from httpx import ConnectError
|
||||
from httpx import HTTPError
|
||||
from httpx import HTTPStatusError
|
||||
from pytest_httpx import HTTPXMock
|
||||
@@ -3428,7 +3429,7 @@ class TestWorkflows(
|
||||
expected_str = "Error occurred parsing webhook headers"
|
||||
self.assertIn(expected_str, cm.output[1])
|
||||
|
||||
@mock.patch("httpx.post")
|
||||
@mock.patch("httpx.Client.post")
|
||||
def test_workflow_webhook_send_webhook_task(self, mock_post):
|
||||
mock_post.return_value = mock.Mock(
|
||||
status_code=200,
|
||||
@@ -3449,8 +3450,6 @@ class TestWorkflows(
|
||||
content="Test message",
|
||||
headers={},
|
||||
files=None,
|
||||
follow_redirects=False,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
expected_str = "Webhook sent to http://paperless-ngx.com"
|
||||
@@ -3468,11 +3467,9 @@ class TestWorkflows(
|
||||
data={"message": "Test message"},
|
||||
headers={},
|
||||
files=None,
|
||||
follow_redirects=False,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
@mock.patch("httpx.post")
|
||||
@mock.patch("httpx.Client.post")
|
||||
def test_workflow_webhook_send_webhook_retry(self, mock_http):
|
||||
mock_http.return_value.raise_for_status = mock.Mock(
|
||||
side_effect=HTTPStatusError(
|
||||
@@ -3668,7 +3665,7 @@ class TestWebhookSecurity:
|
||||
- ValueError is raised
|
||||
"""
|
||||
resolve_to("127.0.0.1")
|
||||
with pytest.raises(ValueError):
|
||||
with pytest.raises(ConnectError):
|
||||
send_webhook(
|
||||
"http://paperless-ngx.com",
|
||||
data="",
|
||||
@@ -3698,7 +3695,8 @@ class TestWebhookSecurity:
|
||||
)
|
||||
|
||||
req = httpx_mock.get_request()
|
||||
assert req.url.host == "paperless-ngx.com"
|
||||
assert req.url.host == "52.207.186.75"
|
||||
assert req.headers["host"] == "paperless-ngx.com"
|
||||
|
||||
def test_follow_redirects_disabled(self, httpx_mock: HTTPXMock, resolve_to):
|
||||
"""
|
||||
|
||||
@@ -10,26 +10,98 @@ from django.conf import settings
|
||||
logger = logging.getLogger("paperless.workflows.webhooks")
|
||||
|
||||
|
||||
def _is_public_ip(ip: str) -> bool:
|
||||
try:
|
||||
obj = ipaddress.ip_address(ip)
|
||||
return not (
|
||||
obj.is_private
|
||||
or obj.is_loopback
|
||||
or obj.is_link_local
|
||||
or obj.is_multicast
|
||||
or obj.is_unspecified
|
||||
class WebhookTransport(httpx.HTTPTransport):
|
||||
"""
|
||||
Transport that resolves/validates hostnames and rewrites to a vetted IP
|
||||
while keeping Host/SNI as the original hostname.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hostname: str,
|
||||
*args,
|
||||
allow_internal: bool = False,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.hostname = hostname
|
||||
self.allow_internal = allow_internal
|
||||
|
||||
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
||||
hostname = request.url.host
|
||||
|
||||
if not hostname:
|
||||
raise httpx.ConnectError("No hostname in request URL")
|
||||
|
||||
try:
|
||||
addr_info = socket.getaddrinfo(hostname, None)
|
||||
except socket.gaierror as e:
|
||||
raise httpx.ConnectError(f"Could not resolve hostname: {hostname}") from e
|
||||
|
||||
ips = [info[4][0] for info in addr_info if info and info[4]]
|
||||
if not ips:
|
||||
raise httpx.ConnectError(f"Could not resolve hostname: {hostname}")
|
||||
|
||||
if not self.allow_internal:
|
||||
for ip_str in ips:
|
||||
if not WebhookTransport.is_public_ip(ip_str):
|
||||
raise httpx.ConnectError(
|
||||
f"Connection blocked: {hostname} resolves to a non-public address",
|
||||
)
|
||||
|
||||
ip_str = ips[0]
|
||||
formatted_ip = self._format_ip_for_url(ip_str)
|
||||
|
||||
new_headers = httpx.Headers(request.headers)
|
||||
if "host" in new_headers:
|
||||
del new_headers["host"]
|
||||
new_headers["Host"] = hostname
|
||||
new_url = request.url.copy_with(host=formatted_ip)
|
||||
|
||||
request = httpx.Request(
|
||||
method=request.method,
|
||||
url=new_url,
|
||||
headers=new_headers,
|
||||
content=request.stream,
|
||||
extensions=request.extensions,
|
||||
)
|
||||
except ValueError: # pragma: no cover
|
||||
return False
|
||||
request.extensions["sni_hostname"] = hostname
|
||||
|
||||
return super().handle_request(request)
|
||||
|
||||
def _resolve_first_ip(host: str) -> str | None:
|
||||
try:
|
||||
info = socket.getaddrinfo(host, None)
|
||||
return info[0][4][0] if info else None
|
||||
except Exception: # pragma: no cover
|
||||
return None
|
||||
def _format_ip_for_url(self, ip: str) -> str:
|
||||
"""
|
||||
Format IP address for use in URL (wrap IPv6 in brackets)
|
||||
"""
|
||||
try:
|
||||
ip_obj = ipaddress.ip_address(ip)
|
||||
if ip_obj.version == 6:
|
||||
return f"[{ip}]"
|
||||
return ip
|
||||
except ValueError:
|
||||
return ip
|
||||
|
||||
@staticmethod
|
||||
def is_public_ip(ip: str | int) -> bool:
|
||||
try:
|
||||
obj = ipaddress.ip_address(ip)
|
||||
return not (
|
||||
obj.is_private
|
||||
or obj.is_loopback
|
||||
or obj.is_link_local
|
||||
or obj.is_multicast
|
||||
or obj.is_unspecified
|
||||
)
|
||||
except ValueError: # pragma: no cover
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def resolve_first_ip(host: str) -> str | None:
|
||||
try:
|
||||
info = socket.getaddrinfo(host, None)
|
||||
return info[0][4][0] if info else None
|
||||
except Exception: # pragma: no cover
|
||||
return None
|
||||
|
||||
|
||||
@shared_task(
|
||||
@@ -59,12 +131,10 @@ def send_webhook(
|
||||
logger.warning("Webhook blocked: port not permitted")
|
||||
raise ValueError("Destination port not permitted.")
|
||||
|
||||
ip = _resolve_first_ip(p.hostname)
|
||||
if not ip or (
|
||||
not _is_public_ip(ip) and not settings.WEBHOOKS_ALLOW_INTERNAL_REQUESTS
|
||||
):
|
||||
logger.warning("Webhook blocked: destination not allowed")
|
||||
raise ValueError("Destination host is not allowed.")
|
||||
transport = WebhookTransport(
|
||||
hostname=p.hostname,
|
||||
allow_internal=settings.WEBHOOKS_ALLOW_INTERNAL_REQUESTS,
|
||||
)
|
||||
|
||||
try:
|
||||
post_args = {
|
||||
@@ -73,8 +143,6 @@ def send_webhook(
|
||||
k: v for k, v in (headers or {}).items() if k.lower() != "host"
|
||||
},
|
||||
"files": files or None,
|
||||
"timeout": 5.0,
|
||||
"follow_redirects": False,
|
||||
}
|
||||
if as_json:
|
||||
post_args["json"] = data
|
||||
@@ -83,14 +151,21 @@ def send_webhook(
|
||||
else:
|
||||
post_args["content"] = data
|
||||
|
||||
httpx.post(
|
||||
**post_args,
|
||||
).raise_for_status()
|
||||
logger.info(
|
||||
f"Webhook sent to {url}",
|
||||
)
|
||||
with httpx.Client(
|
||||
transport=transport,
|
||||
timeout=5.0,
|
||||
follow_redirects=False,
|
||||
) as client:
|
||||
client.post(
|
||||
**post_args,
|
||||
).raise_for_status()
|
||||
logger.info(
|
||||
f"Webhook sent to {url}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed attempt sending webhook to {url}: {e}",
|
||||
)
|
||||
raise e
|
||||
finally:
|
||||
transport.close()
|
||||
|
||||
Reference in New Issue
Block a user