mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-30 03:25:43 +00:00
Security: fixes for v3 beta (#12838)
This commit is contained in:
1 parent
da3e845b8b
commit
01d8fad622
15 files changed
+434
-93
No files matched your search
@@ -198,6 +198,7 @@ class ShareLinksAdmin(GuardedModelAdmin):
|
||||
class ShareLinkBundleAdmin(GuardedModelAdmin):
|
||||
list_display = ("created", "status", "expiration", "owner", "slug")
|
||||
list_filter = ("status", "created", "expiration", "owner")
|
||||
readonly_fields = ("file_path",)
|
||||
search_fields = ("slug",)
|
||||
|
||||
def get_queryset(self, request): # pragma: no cover
|
||||
|
||||
+11
-1
@@ -1019,7 +1019,17 @@ class ShareLinkBundle(models.Model):
|
||||
def absolute_file_path(self) -> Path | None:
|
||||
if not self.file_path:
|
||||
return None
|
||||
return (settings.SHARE_LINK_BUNDLE_DIR / Path(self.file_path)).resolve()
|
||||
relative_path = Path(self.file_path)
|
||||
if relative_path.is_absolute():
|
||||
return None
|
||||
|
||||
bundle_dir = settings.SHARE_LINK_BUNDLE_DIR.resolve()
|
||||
absolute_path = (bundle_dir / relative_path).resolve()
|
||||
try:
|
||||
absolute_path.relative_to(bundle_dir)
|
||||
except ValueError:
|
||||
return None
|
||||
return absolute_path
|
||||
|
||||
def remove_file(self) -> None:
|
||||
if self.absolute_file_path is not None and self.absolute_file_path.exists():
|
||||
|
||||
@@ -124,7 +124,7 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
|
||||
self.assertIn("document_ids", response.data)
|
||||
|
||||
def test_download_ready_bundle_streams_file(self) -> None:
|
||||
bundle_file = Path(self.dirs.media_dir) / "bundles" / "ready.zip"
|
||||
bundle_file = settings.SHARE_LINK_BUNDLE_DIR / "bundles" / "ready.zip"
|
||||
bundle_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
bundle_file.write_bytes(b"binary-zip-content")
|
||||
|
||||
@@ -132,7 +132,7 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
|
||||
slug="readyslug",
|
||||
file_version=ShareLink.FileVersion.ARCHIVE,
|
||||
status=ShareLinkBundle.Status.READY,
|
||||
file_path=str(bundle_file),
|
||||
file_path=str(bundle_file.relative_to(settings.SHARE_LINK_BUNDLE_DIR)),
|
||||
)
|
||||
bundle.documents.set([self.document])
|
||||
|
||||
@@ -199,11 +199,11 @@ class ShareLinkBundleTaskTests(DirectoriesMixin, APITestCase):
|
||||
self.document = DocumentFactory.create()
|
||||
|
||||
def test_cleanup_expired_share_link_bundles(self) -> None:
|
||||
expired_path = Path(self.dirs.media_dir) / "expired.zip"
|
||||
expired_path = settings.SHARE_LINK_BUNDLE_DIR / "expired.zip"
|
||||
expired_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
expired_path.write_bytes(b"expired")
|
||||
|
||||
active_path = Path(self.dirs.media_dir) / "active.zip"
|
||||
active_path = settings.SHARE_LINK_BUNDLE_DIR / "active.zip"
|
||||
active_path.write_bytes(b"active")
|
||||
|
||||
expired_bundle = ShareLinkBundle.objects.create(
|
||||
@@ -211,7 +211,7 @@ class ShareLinkBundleTaskTests(DirectoriesMixin, APITestCase):
|
||||
file_version=ShareLink.FileVersion.ARCHIVE,
|
||||
status=ShareLinkBundle.Status.READY,
|
||||
expiration=timezone.now() - timedelta(days=1),
|
||||
file_path=str(expired_path),
|
||||
file_path=expired_path.name,
|
||||
)
|
||||
expired_bundle.documents.set([self.document])
|
||||
|
||||
@@ -220,7 +220,7 @@ class ShareLinkBundleTaskTests(DirectoriesMixin, APITestCase):
|
||||
file_version=ShareLink.FileVersion.ARCHIVE,
|
||||
status=ShareLinkBundle.Status.READY,
|
||||
expiration=timezone.now() + timedelta(days=1),
|
||||
file_path=str(active_path),
|
||||
file_path=active_path.name,
|
||||
)
|
||||
active_bundle.documents.set([self.document])
|
||||
|
||||
@@ -424,7 +424,7 @@ class ShareLinkBundleFilterSetTests(DirectoriesMixin, APITestCase):
|
||||
|
||||
|
||||
class ShareLinkBundleModelTests(DirectoriesMixin, APITestCase):
|
||||
def test_absolute_file_path_handles_relative_and_absolute(self) -> None:
|
||||
def test_absolute_file_path_handles_relative_path(self) -> None:
|
||||
relative_path = Path("relative.zip")
|
||||
bundle = ShareLinkBundle.objects.create(
|
||||
slug="relative-bundle",
|
||||
@@ -437,10 +437,23 @@ class ShareLinkBundleModelTests(DirectoriesMixin, APITestCase):
|
||||
(settings.SHARE_LINK_BUNDLE_DIR / relative_path).resolve(),
|
||||
)
|
||||
|
||||
absolute_path = Path(self.dirs.media_dir) / "absolute.zip"
|
||||
bundle.file_path = str(absolute_path)
|
||||
def test_absolute_file_path_rejects_absolute_path(self) -> None:
|
||||
bundle = ShareLinkBundle.objects.create(
|
||||
slug="absolute-bundle",
|
||||
file_version=ShareLink.FileVersion.ORIGINAL,
|
||||
file_path=str(Path(self.dirs.media_dir) / "absolute.zip"),
|
||||
)
|
||||
|
||||
self.assertEqual(bundle.absolute_file_path.resolve(), absolute_path.resolve())
|
||||
self.assertIsNone(bundle.absolute_file_path)
|
||||
|
||||
def test_absolute_file_path_rejects_traversal_outside_bundle_dir(self) -> None:
|
||||
bundle = ShareLinkBundle.objects.create(
|
||||
slug="traversal-bundle",
|
||||
file_version=ShareLink.FileVersion.ORIGINAL,
|
||||
file_path="../escaped.zip",
|
||||
)
|
||||
|
||||
self.assertIsNone(bundle.absolute_file_path)
|
||||
|
||||
def test_str_returns_translated_slug(self) -> None:
|
||||
bundle = ShareLinkBundle.objects.create(
|
||||
|
||||
@@ -437,8 +437,14 @@ class TestAIChatStreamingView(DirectoriesMixin, TestCase):
|
||||
)
|
||||
super().setUp()
|
||||
|
||||
def grant_view_document_permission(self) -> None:
|
||||
self.user.user_permissions.add(
|
||||
*Permission.objects.filter(codename="view_document"),
|
||||
)
|
||||
|
||||
@override_settings(AI_ENABLED=False)
|
||||
def test_post_ai_disabled(self) -> None:
|
||||
self.grant_view_document_permission()
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
data='{"q": "question"}',
|
||||
@@ -451,6 +457,7 @@ class TestAIChatStreamingView(DirectoriesMixin, TestCase):
|
||||
@patch("documents.views.get_objects_for_user_owner_aware")
|
||||
@override_settings(AI_ENABLED=True)
|
||||
def test_post_no_document_id(self, mock_get_objects, mock_stream_chat) -> None:
|
||||
self.grant_view_document_permission()
|
||||
mock_get_objects.return_value = [self.document]
|
||||
mock_stream_chat.return_value = iter([b"data"])
|
||||
response = self.client.post(
|
||||
@@ -464,6 +471,7 @@ class TestAIChatStreamingView(DirectoriesMixin, TestCase):
|
||||
@patch("documents.views.stream_chat_with_documents")
|
||||
@override_settings(AI_ENABLED=True)
|
||||
def test_post_with_document_id(self, mock_stream_chat) -> None:
|
||||
self.grant_view_document_permission()
|
||||
mock_stream_chat.return_value = iter([b"data"])
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
@@ -475,6 +483,7 @@ class TestAIChatStreamingView(DirectoriesMixin, TestCase):
|
||||
|
||||
@override_settings(AI_ENABLED=True)
|
||||
def test_post_with_invalid_document_id(self) -> None:
|
||||
self.grant_view_document_permission()
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
data='{"q": "question", "document_id": 999999}',
|
||||
@@ -486,6 +495,7 @@ class TestAIChatStreamingView(DirectoriesMixin, TestCase):
|
||||
@patch("documents.views.has_perms_owner_aware")
|
||||
@override_settings(AI_ENABLED=True)
|
||||
def test_post_with_document_id_no_permission(self, mock_has_perms) -> None:
|
||||
self.grant_view_document_permission()
|
||||
mock_has_perms.return_value = False
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
@@ -494,3 +504,31 @@ class TestAIChatStreamingView(DirectoriesMixin, TestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertIn(b"Insufficient permissions", response.content)
|
||||
|
||||
@patch("documents.views.stream_chat_with_documents")
|
||||
@override_settings(AI_ENABLED=True)
|
||||
def test_post_no_document_id_requires_view_document_permission(
|
||||
self,
|
||||
mock_stream_chat,
|
||||
) -> None:
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
data='{"q": "question"}',
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
mock_stream_chat.assert_not_called()
|
||||
|
||||
@patch("documents.views.stream_chat_with_documents")
|
||||
@override_settings(AI_ENABLED=True)
|
||||
def test_post_with_document_id_requires_view_document_permission(
|
||||
self,
|
||||
mock_stream_chat,
|
||||
) -> None:
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
data=f'{{"q": "question", "document_id": {self.document.pk}}}',
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
mock_stream_chat.assert_not_called()
|
||||
@@ -2150,7 +2150,7 @@ class ChatStreamingSerializer(serializers.Serializer[dict[str, Any]]):
|
||||
name="dispatch",
|
||||
)
|
||||
class ChatStreamingView(GenericAPIView[Any]):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
permission_classes = (IsAuthenticated, ViewDocumentsPermissions)
|
||||
serializer_class = ChatStreamingSerializer
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
@@ -4,70 +4,12 @@ import httpx
|
||||
from celery import shared_task
|
||||
from django.conf import settings
|
||||
|
||||
from paperless.network import format_host_for_url
|
||||
from paperless.network import is_public_ip
|
||||
from paperless.network import resolve_hostname_ips
|
||||
from paperless.network import PinnedHostHTTPTransport
|
||||
from paperless.network import validate_outbound_http_url
|
||||
|
||||
logger = logging.getLogger("paperless.workflows.webhooks")
|
||||
|
||||
|
||||
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:
|
||||
ips = resolve_hostname_ips(hostname)
|
||||
except ValueError as e:
|
||||
raise httpx.ConnectError(str(e)) from e
|
||||
|
||||
if not self.allow_internal:
|
||||
for ip_str in ips:
|
||||
if not is_public_ip(ip_str):
|
||||
raise httpx.ConnectError(
|
||||
f"Connection blocked: {hostname} resolves to a non-public address",
|
||||
)
|
||||
|
||||
ip_str = ips[0]
|
||||
formatted_ip = format_host_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,
|
||||
)
|
||||
request.extensions["sni_hostname"] = hostname
|
||||
|
||||
return super().handle_request(request)
|
||||
|
||||
|
||||
@shared_task(
|
||||
retry_backoff=True,
|
||||
autoretry_for=(httpx.HTTPStatusError,),
|
||||
@@ -83,7 +25,7 @@ def send_webhook(
|
||||
as_json: bool = False,
|
||||
):
|
||||
try:
|
||||
parsed = validate_outbound_http_url(
|
||||
validate_outbound_http_url(
|
||||
url,
|
||||
allowed_schemes=settings.WEBHOOKS_ALLOWED_SCHEMES,
|
||||
allowed_ports=settings.WEBHOOKS_ALLOWED_PORTS,
|
||||
@@ -94,12 +36,7 @@ def send_webhook(
|
||||
logger.warning("Webhook blocked: %s", e)
|
||||
raise
|
||||
|
||||
hostname = parsed.hostname
|
||||
if hostname is None: # pragma: no cover
|
||||
raise ValueError("Invalid URL scheme or hostname.")
|
||||
|
||||
transport = WebhookTransport(
|
||||
hostname=hostname,
|
||||
transport = PinnedHostHTTPTransport(
|
||||
allow_internal=settings.WEBHOOKS_ALLOW_INTERNAL_REQUESTS,
|
||||
)
|
||||
|
||||
|
||||
Reference in new issue
Block a user