snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-15 15:14:58 +05:00
commit 23059a1de3
5252 changed files with 573834 additions and 0 deletions

View File

@@ -0,0 +1,143 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""
Unit tests for the log cleanup tasks.
Verifies that API activity logs past the retention window are hard-deleted
(removed from PostgreSQL, not soft-deleted) and that fresh logs are retained.
"""
from datetime import timedelta
import pytest
from django.conf import settings
from django.utils import timezone
from uuid import uuid4
from plane.bgtasks.cleanup_task import (
delete_api_logs,
delete_email_notification_logs,
delete_webhook_logs,
process_cleanup_task,
)
from plane.db.models import APIActivityLog, EmailNotificationLog, WebhookLog
from plane.tests.factories import UserFactory, WorkspaceFactory
def _make_api_log(created_at):
log = APIActivityLog.objects.create(
token_identifier="hashed-token",
path="/api/v1/workspaces/",
method="GET",
response_code=200,
)
# created_at is auto-set on insert, so backdate it explicitly afterwards.
APIActivityLog.all_objects.filter(pk=log.pk).update(created_at=created_at)
return log
def _make_webhook_log(workspace, created_at):
log = WebhookLog.objects.create(
workspace=workspace,
webhook=uuid4(),
event_type="issue",
request_method="POST",
response_status="200",
)
WebhookLog.all_objects.filter(pk=log.pk).update(created_at=created_at)
return log
def _make_email_log(user, sent_at):
return EmailNotificationLog.objects.create(
receiver=user,
triggered_by=user,
entity_name="issue",
entity="issue",
sent_at=sent_at,
)
@pytest.mark.unit
@pytest.mark.django_db
class TestDeleteApiLogs:
def test_expired_logs_are_hard_deleted(self):
retention_days = settings.API_ACTIVITY_LOG_RETENTION_DAYS
expired = _make_api_log(timezone.now() - timedelta(days=retention_days + 1))
delete_api_logs()
# Hard delete: the row must be gone even from the unfiltered manager.
assert not APIActivityLog.all_objects.filter(pk=expired.pk).exists()
def test_recent_logs_are_retained(self):
retention_days = settings.API_ACTIVITY_LOG_RETENTION_DAYS
recent = _make_api_log(timezone.now() - timedelta(days=retention_days - 1))
delete_api_logs()
assert APIActivityLog.all_objects.filter(pk=recent.pk).exists()
@pytest.mark.unit
@pytest.mark.django_db
class TestDeleteWebhookLogs:
def test_expired_logs_are_hard_deleted(self):
workspace = WorkspaceFactory()
retention_days = settings.WEBHOOK_LOG_RETENTION_DAYS
expired = _make_webhook_log(workspace, timezone.now() - timedelta(days=retention_days + 1))
delete_webhook_logs()
assert not WebhookLog.all_objects.filter(pk=expired.pk).exists()
def test_recent_logs_are_retained(self):
workspace = WorkspaceFactory()
retention_days = settings.WEBHOOK_LOG_RETENTION_DAYS
recent = _make_webhook_log(workspace, timezone.now() - timedelta(days=retention_days - 1))
delete_webhook_logs()
assert WebhookLog.all_objects.filter(pk=recent.pk).exists()
@pytest.mark.unit
@pytest.mark.django_db
class TestDeleteEmailLogs:
def test_expired_logs_are_hard_deleted(self):
user = UserFactory()
retention_days = settings.EMAIL_LOG_RETENTION_DAYS
expired = _make_email_log(user, timezone.now() - timedelta(days=retention_days + 1))
delete_email_notification_logs()
assert not EmailNotificationLog.all_objects.filter(pk=expired.pk).exists()
def test_recent_logs_are_retained(self):
user = UserFactory()
retention_days = settings.EMAIL_LOG_RETENTION_DAYS
recent = _make_email_log(user, timezone.now() - timedelta(days=retention_days - 1))
delete_email_notification_logs()
assert EmailNotificationLog.all_objects.filter(pk=recent.pk).exists()
@pytest.mark.unit
class TestProcessCleanupTaskErrorHandling:
def test_batch_delete_failure_is_swallowed(self):
"""A failing batch is logged and skipped; the run does not raise."""
class _BoomManager:
@staticmethod
def filter(**kwargs):
raise RuntimeError("db unavailable")
class _BoomModel:
all_objects = _BoomManager()
# Should not raise even though the delete blows up.
process_cleanup_task(lambda: iter([1, 2, 3]), _BoomModel, "Boom")

View File

@@ -0,0 +1,175 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import pytest
from plane.db.models import Project, ProjectMember, Issue, FileAsset
from unittest.mock import patch, MagicMock
from plane.bgtasks.copy_s3_object import (
copy_s3_objects_of_description_and_assets,
copy_assets,
)
import base64
@pytest.mark.unit
class TestCopyS3Objects:
"""Test the copy_s3_objects_of_description_and_assets function"""
@pytest.fixture
def project(self, create_user, workspace):
project = Project.objects.create(name="Test Project", identifier="test-project", workspace=workspace)
ProjectMember.objects.create(project=project, member=create_user)
return project
@pytest.fixture
def issue(self, workspace, project):
return Issue.objects.create(
name="Test Issue",
workspace=workspace,
project_id=project.id,
description_html='<div><image-component src="35e8b958-6ee5-43ce-ae56-fb0e776f421e"></image-component><image-component src="97988198-274f-4dfe-aa7a-4c0ffc684214"></image-component></div>', # noqa: E501
)
@pytest.fixture
def file_asset(self, workspace, project, issue):
return FileAsset.objects.create(
issue=issue,
workspace=workspace,
project=project,
asset="workspace1/test-asset-1.jpg",
attributes={
"name": "test-asset-1.jpg",
"size": 100,
"type": "image/jpeg",
},
id="35e8b958-6ee5-43ce-ae56-fb0e776f421e",
entity_type="ISSUE_DESCRIPTION",
)
@pytest.mark.django_db
@patch("plane.bgtasks.copy_s3_object.S3Storage")
def test_copy_s3_objects_of_description_and_assets(
self, mock_s3_storage, create_user, workspace, project, issue, file_asset
):
FileAsset.objects.create(
issue=issue,
workspace=workspace,
project=project,
asset="workspace1/test-asset-2.pdf",
attributes={
"name": "test-asset-2.pdf",
"size": 100,
"type": "application/pdf",
},
id="97988198-274f-4dfe-aa7a-4c0ffc684214",
entity_type="ISSUE_DESCRIPTION",
)
issue.save()
# Set up mock S3 storage
mock_storage_instance = MagicMock()
mock_s3_storage.return_value = mock_storage_instance
# Mock the external service call to avoid actual HTTP requests
with patch("plane.bgtasks.copy_s3_object.sync_with_external_service") as mock_sync:
mock_sync.return_value = {
"description": "test description",
"description_binary": base64.b64encode(b"test binary").decode(),
"description_json": {"type": "doc", "content": []},
}
# Call the actual function (not .delay())
copy_s3_objects_of_description_and_assets("ISSUE", issue.id, project.id, "test-workspace", create_user.id)
# Assert that copy_object was called for each asset
assert mock_storage_instance.copy_object.call_count == 2
# Get the updated issue and its new assets
updated_issue = Issue.objects.get(id=issue.id)
new_assets = FileAsset.objects.filter(
issue=updated_issue,
entity_type="ISSUE_DESCRIPTION",
)
# Verify new assets were created
assert new_assets.count() == 4 # 2 original + 2 copied
@pytest.mark.django_db
@patch("plane.bgtasks.copy_s3_object.S3Storage")
def test_copy_assets_successful(self, mock_s3_storage, workspace, project, issue, file_asset):
"""Test successful copying of assets"""
# Arrange
mock_storage_instance = MagicMock()
mock_s3_storage.return_value = mock_storage_instance
# Act
result = copy_assets(
entity=issue,
entity_identifier=issue.id,
project_id=project.id,
asset_ids=[file_asset.id],
user_id=issue.created_by_id,
)
# Assert
# Verify S3 copy was called
mock_storage_instance.copy_object.assert_called_once()
# Verify new asset was created
assert len(result) == 1
new_asset_id = result[0]["new_asset_id"]
new_asset = FileAsset.objects.get(id=new_asset_id)
# Verify asset properties were copied correctly
assert new_asset.workspace == workspace
assert new_asset.project_id == project.id
assert new_asset.entity_type == file_asset.entity_type
assert new_asset.attributes == file_asset.attributes
assert new_asset.size == file_asset.size
assert new_asset.is_uploaded is True
@pytest.mark.django_db
@patch("plane.bgtasks.copy_s3_object.S3Storage")
def test_copy_assets_empty_asset_ids(self, mock_s3_storage, workspace, project, issue):
"""Test copying with empty asset_ids list"""
# Arrange
mock_storage_instance = MagicMock()
mock_s3_storage.return_value = mock_storage_instance
# Act
result = copy_assets(
entity=issue,
entity_identifier=issue.id,
project_id=project.id,
asset_ids=[],
user_id=issue.created_by_id,
)
# Assert
assert result == []
mock_storage_instance.copy_object.assert_not_called()
@pytest.mark.django_db
@patch("plane.bgtasks.copy_s3_object.S3Storage")
def test_copy_assets_nonexistent_asset(self, mock_s3_storage, workspace, project, issue):
"""Test copying with non-existent asset ID"""
# Arrange
mock_storage_instance = MagicMock()
mock_s3_storage.return_value = mock_storage_instance
non_existent_id = "00000000-0000-0000-0000-000000000000"
# Act
result = copy_assets(
entity=issue,
entity_identifier=issue.id,
project_id=project.id,
asset_ids=[non_existent_id],
user_id=issue.created_by_id,
)
# Assert
assert result == []
mock_storage_instance.copy_object.assert_not_called()

View File

@@ -0,0 +1,289 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""
Per-advisory SSRF regression tests.
Each test reproduces a published / reported SSRF advisory scenario and asserts
the current code blocks it. This file is the auditable map of "which advisory is
covered where"; the lower-level mechanics (IP classification, pinning, redirect
re-validation) are exercised in detail in ``test_url_security.py`` and
``test_work_item_link_task.py``.
Advisory coverage
-----------------
Webhook delivery
* GHSA-m3f8-q4wj-9grv / CVE-2026-30242 / GHSA-75vf-hh93-h7mx
webhook URL resolves to a private/metadata/loopback IP -> TestWebhookUrlValidation
* GHSA-75fg-f8qg-23wv CGNAT(100.64/10), 6to4, multicast missed -> TestWebhookUrlValidation
* GHSA-6485-m23r-fx8q PATCH serializer context-key bypass -> TestWebhookPatchContextGuard
* GHSA-whh3-5g95-4qhc / -4mjx-q738-87cf / -6p39-x6q9-h3g5 /
-9292-pvg4-7hvm / -fgcv-6h3f-xcx9 webhook DNS-rebinding TOCTOU -> TestWebhookRebinding
* GHSA-6v37-328w-j2wv / -jw6g-h7h5-rfc6 / -mq87-52pf-hm3h
webhook SSRF via HTTP redirect following -> TestWebhookRedirect
Work-item link unfurling / favicon
* GHSA-8wvv-p676-hcw4 / -fv24-3845-646g / -9292-pvg4-7hvm link rebinding
* GHSA-9fr2-pprw-pp9j / CVE-2026-39843 favicon redirect SSRF -> TestFaviconRedirect
* GHSA-3856-6mgg-rx84 favicon DNS-rebinding -> TestFaviconRebinding
OAuth avatar (the still-unresolved family this change adds)
* GHSA-cv9p-325g-wmv5 OAuth avatar redirect SSRF -> static-asset exfil
* GHSA-hx79-5pj5-qh42 Gitea OAuth SSRF (avatar hop) -> TestOAuthAvatarSSRF
"""
import pytest
import requests
from unittest.mock import MagicMock, patch
from bs4 import BeautifulSoup
from plane.utils.ip_address import validate_url
from plane.bgtasks.work_item_link_task import fetch_and_encode_favicon, DEFAULT_FAVICON
from plane.authentication.adapter.base import Adapter
def _addr(ip):
family = 6 if ":" in ip else 2
return (family, None, None, None, (ip, 0))
def _resp(status_code=200, headers=None, content=b"OK"):
resp = MagicMock(spec=requests.Response)
resp.status_code = status_code
resp.headers = headers or {}
resp.content = content
return resp
_BLOCKED = "Access to private/internal networks is not allowed"
# ---------------------------------------------------------------------------
# Webhook URL validation (creation/update-time defense in depth)
# GHSA-m3f8-q4wj-9grv / CVE-2026-30242 / GHSA-75vf-hh93-h7mx / GHSA-75fg-f8qg-23wv
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestWebhookUrlValidation:
@pytest.mark.parametrize(
"ip",
[
"169.254.169.254", # AWS/GCP metadata (CVE-2026-30242 PoC)
"127.0.0.1", # loopback
"10.0.0.1", # private
"172.16.0.1", # private
"192.168.0.1", # private
"::1", # IPv6 loopback
"100.64.0.1", # CGNAT / RFC 6598 (GHSA-75fg)
"2002:7f00:1::", # 6to4 -> 127.0.0.1 (GHSA-75fg)
"224.0.0.1", # multicast (GHSA-75fg)
"::ffff:169.254.169.254", # IPv4-mapped metadata
],
)
def test_webhook_url_to_internal_is_rejected(self, ip):
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
dns.return_value = [_addr(ip)]
with pytest.raises(ValueError, match="private/internal"):
validate_url(
"https://attacker.example.com/hook",
allowed_ips=[],
allowed_hosts=[],
)
def test_legitimate_public_webhook_url_passes(self):
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
dns.return_value = [_addr("93.184.216.34")]
# Should not raise
validate_url("https://hooks.example.com/x", allowed_ips=[], allowed_hosts=[])
# ---------------------------------------------------------------------------
# GHSA-6485-m23r-fx8q — PATCH serializer context-key bypass
# The PATCH view now passes context={"request": request}; with the request in
# context the disallowed-domain / request-host loop-back guard runs on update.
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestWebhookPatchContextGuard:
def _serializer_with_request(self, host):
from plane.app.serializers import WebhookSerializer
request = MagicMock()
request.get_host.return_value = host
return WebhookSerializer(context={"request": request})
def test_request_host_is_blocked_when_context_present(self):
# A webhook pointed at the instance's own host must be rejected — this
# is the guard the PATCH endpoint silently skipped with the wrong key.
ser = self._serializer_with_request("myplane.example.com")
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
dns.return_value = [_addr("93.184.216.34")] # public, so only the host guard can block
with pytest.raises(Exception, match="not allowed"):
ser._validate_webhook_url("https://myplane.example.com/hook")
def test_unrelated_public_host_passes_with_context(self):
ser = self._serializer_with_request("myplane.example.com")
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
dns.return_value = [_addr("93.184.216.34")]
ser._validate_webhook_url("https://hooks.partner.com/x") # should not raise
# ---------------------------------------------------------------------------
# Webhook DNS-rebinding TOCTOU
# GHSA-whh3-5g95-4qhc / -4mjx-q738-87cf / -6p39-x6q9-h3g5 / -9292 / -fgcv
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestWebhookRebinding:
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_connection_pinned_to_validated_ip(self, mock_resolve, mock_session_cls):
from plane.utils.url_security import pinned_fetch
# The validator resolves to a public IP; the connection must go to THAT
# IP literal, so a rebind to an internal IP after validation is moot.
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.return_value = _resp(200)
pinned_fetch("POST", "https://rebinder.example.com/hook", json={})
_, url = session.request.call_args.args
assert url == "https://93.184.216.34:443/hook" # IP literal -> no 2nd DNS lookup
@patch("plane.utils.url_security.resolve_and_validate")
def test_rebind_to_internal_is_blocked(self, mock_resolve):
from plane.utils.url_security import pinned_fetch
mock_resolve.side_effect = ValueError(_BLOCKED)
with pytest.raises(ValueError, match="private/internal"):
pinned_fetch("POST", "https://rebinder.example.com/hook", json={})
# ---------------------------------------------------------------------------
# Webhook SSRF via HTTP redirect following
# GHSA-6v37-328w-j2wv / GHSA-jw6g-h7h5-rfc6 / GHSA-mq87-52pf-hm3h
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestWebhookRedirect:
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_webhook_does_not_follow_redirects(self, mock_resolve, mock_session_cls):
from plane.utils.url_security import pinned_fetch
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
# The endpoint replies 302 -> internal; the webhook client must NOT follow.
session.request.return_value = _resp(
302, headers={"Location": "http://169.254.169.254/latest/meta-data/"}
)
resp = pinned_fetch("POST", "https://hooks.example.com/x", json={})
# The 3xx is returned as-is and only ONE request was made (no follow).
assert resp.status_code == 302
assert session.request.call_count == 1
assert session.request.call_args.kwargs["allow_redirects"] is False
# ---------------------------------------------------------------------------
# Favicon redirect SSRF — GHSA-9fr2-pprw-pp9j / CVE-2026-39843
# A <link rel=icon> whose href is public but 30x-redirects to a private IP must
# NOT exfiltrate internal content; the favicon falls back to the default icon.
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestFaviconRedirect:
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
@patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo")
def test_favicon_redirect_to_private_returns_default(
self, mock_pre_dns, mock_resolve, mock_session_cls
):
# validate_url_ip pre-check (work_item_link_task.socket) sees a public IP.
mock_pre_dns.return_value = [_addr("93.184.216.34")]
# safe_get: hop0 public, hop1 (redirect target) blocked.
mock_resolve.side_effect = [["93.184.216.34"], ValueError(_BLOCKED)]
session = mock_session_cls.return_value
session.request.return_value = _resp(
302, headers={"Location": "http://192.168.8.14:8081/"}
)
soup = BeautifulSoup(
'<link rel="icon" href="https://redirector.example.com/x">',
"html.parser",
)
result = fetch_and_encode_favicon({}, soup, "https://attacker.example.com")
# Blocked -> default icon, NOT the internal response body.
assert result["favicon_base64"] == f"data:image/svg+xml;base64,{DEFAULT_FAVICON}"
# ---------------------------------------------------------------------------
# Favicon DNS rebinding — GHSA-3856-6mgg-rx84
# The favicon host passes the pre-check (public) but resolves to a private IP at
# fetch time; the pinned client re-resolves+validates and blocks it.
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestFaviconRebinding:
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
@patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo")
def test_favicon_rebind_to_private_returns_default(
self, mock_pre_dns, mock_resolve, mock_session_cls
):
mock_pre_dns.return_value = [_addr("93.184.216.34")] # pre-check: public
mock_resolve.side_effect = ValueError(_BLOCKED) # fetch-time: rebound -> blocked
session = mock_session_cls.return_value
session.request.return_value = _resp(200)
soup = BeautifulSoup(
'<link rel="icon" href="http://rebind.example.com:8443/">',
"html.parser",
)
result = fetch_and_encode_favicon({}, soup, "https://attacker.example.com")
assert result["favicon_base64"] == f"data:image/svg+xml;base64,{DEFAULT_FAVICON}"
# ---------------------------------------------------------------------------
# OAuth avatar SSRF — GHSA-cv9p-325g-wmv5 / GHSA-hx79-5pj5-qh42 (avatar hop)
# download_and_upload_avatar must reject avatar URLs that point at, or redirect
# to, internal addresses, returning None (no fetch stored as an asset).
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestOAuthAvatarSSRF:
def _adapter(self):
return Adapter(request=MagicMock(), provider="gitea")
@patch("plane.utils.url_security.resolve_and_validate")
def test_avatar_to_internal_ip_is_blocked(self, mock_resolve):
mock_resolve.side_effect = ValueError(_BLOCKED)
result = self._adapter().download_and_upload_avatar(
"http://169.254.169.254/latest/meta-data/", user=MagicMock()
)
assert result is None
mock_resolve.assert_called() # SSRF validation was actually attempted
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_avatar_redirect_to_internal_is_blocked(self, mock_resolve, mock_session_cls):
# Public avatar URL that 302-redirects to the metadata service.
mock_resolve.side_effect = [["93.184.216.34"], ValueError(_BLOCKED)]
session = mock_session_cls.return_value
session.request.return_value = _resp(
302, headers={"Location": "http://169.254.169.254/imds"}
)
result = self._adapter().download_and_upload_avatar(
"https://evil.example.com/avatar", user=MagicMock()
)
assert result is None
@patch("plane.authentication.adapter.base.pinned_fetch_following_redirects")
def test_avatar_uses_ssrf_safe_client(self, mock_fetch):
# Wiring guard: the avatar path must go through the pinned client, never
# a raw requests.get (which would re-resolve + follow redirects freely).
mock_fetch.side_effect = ValueError(_BLOCKED)
result = self._adapter().download_and_upload_avatar(
"https://cdn.example.com/a.png", user=MagicMock()
)
assert result is None
assert mock_fetch.call_args.args[0] == "GET"
assert mock_fetch.call_args.args[1] == "https://cdn.example.com/a.png"

View File

@@ -0,0 +1,395 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""
SSRF-protection tests for the webhook + link-unfurling clusters (advisories A/B/C):
A — incomplete private-IP validation -> is_blocked_ip hardening
B — DNS-rebinding TOCTOU -> connection pinned to the validated IP
C — SSRF via HTTP redirect following -> redirects re-resolved/re-validated/re-pinned
"""
import ipaddress
import pytest
import requests
from unittest.mock import MagicMock, patch
from plane.utils.ip_address import is_blocked_ip, resolve_and_validate, validate_url
from plane.utils.url_security import (
PinnedIPAdapter,
pinned_fetch,
pinned_fetch_following_redirects,
)
def _addr(ip):
"""Build a single getaddrinfo-style result tuple for an IP string."""
family = 6 if ":" in ip else 2
return (family, None, None, None, (ip, 0))
def _resp(status_code=200, headers=None, content=b"OK"):
resp = MagicMock(spec=requests.Response)
resp.status_code = status_code
resp.headers = headers or {}
resp.content = content
return resp
# ---------------------------------------------------------------------------
# Cluster A — robust IP classification (verified on Python 3.12 semantics)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestIsBlockedIp:
@pytest.mark.parametrize(
"ip",
[
"127.0.0.1", # loopback
"10.0.0.1", # private
"192.168.1.1", # private
"172.16.0.1", # private
"169.254.169.254", # link-local / cloud metadata
"0.0.0.0", # unspecified
"100.64.0.1", # CGNAT / shared (NOT is_private on py3.12!)
"224.0.0.1", # multicast
"239.255.255.250", # SSDP multicast
"255.255.255.255", # limited broadcast
"::1", # IPv6 loopback
"fe80::1", # IPv6 link-local
"fc00::1", # IPv6 unique-local
"ff02::1", # IPv6 multicast
"::ffff:127.0.0.1", # IPv4-mapped loopback
"::ffff:169.254.169.254", # IPv4-mapped metadata
"::ffff:10.0.0.1", # IPv4-mapped private
"64:ff9b::7f00:1", # NAT64 well-known prefix embedding 127.0.0.1
"64:ff9b::a9fe:a9fe", # NAT64 well-known prefix embedding 169.254.169.254
"64:ff9b:1::7f00:1", # NAT64 local-use prefix (RFC 8215, /48)
"64:ff9b:1:0100::1", # NAT64 local-use prefix, outside the /96 subset
"2002:7f00:1::", # 6to4 embedding 127.0.0.1
"2002:a00:1::", # 6to4 embedding 10.0.0.1
],
)
def test_blocks_internal(self, ip):
assert is_blocked_ip(ipaddress.ip_address(ip)) is True
@pytest.mark.parametrize(
"ip",
[
"8.8.8.8",
"93.184.216.34",
"1.1.1.1",
"2606:4700:4700::1111", # public IPv6 (Cloudflare)
"2001:4860:4860::8888", # public IPv6 (Google)
],
)
def test_allows_public(self, ip):
assert is_blocked_ip(ipaddress.ip_address(ip)) is False
# ---------------------------------------------------------------------------
# resolve_and_validate — resolution + validation, returns IPs to pin
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestResolveAndValidate:
def test_returns_public_ips(self):
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
dns.return_value = [_addr("93.184.216.34")]
assert resolve_and_validate("example.com") == ["93.184.216.34"]
def test_raises_on_private(self):
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
dns.return_value = [_addr("10.0.0.1")]
with pytest.raises(ValueError, match="private/internal"):
resolve_and_validate("internal.example.com")
def test_raises_if_any_resolved_ip_is_private(self):
# A hostname that resolves to BOTH a public and a private IP must fail
# closed — an attacker could otherwise steer the connection to the
# private one.
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
dns.return_value = [_addr("93.184.216.34"), _addr("127.0.0.1")]
with pytest.raises(ValueError, match="private/internal"):
resolve_and_validate("rebinder.example.com")
def test_allowlist_permits_private(self):
allowed = [ipaddress.ip_network("10.0.0.0/8")]
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
dns.return_value = [_addr("10.0.0.5")]
assert resolve_and_validate("internal", allowed_ips=allowed) == ["10.0.0.5"]
def test_unresolvable_raises(self):
import socket as _socket
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
dns.side_effect = _socket.gaierror()
with pytest.raises(ValueError, match="could not be resolved"):
resolve_and_validate("nope.invalid")
# ---------------------------------------------------------------------------
# Cluster B — connection pinned to the validated IP (DNS-rebinding TOCTOU)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestPinnedFetch:
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_connects_to_validated_ip_not_hostname(self, mock_resolve, mock_session_cls):
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.return_value = _resp(200)
pinned_fetch("POST", "https://example.com/hook", json={"a": 1})
# The socket target is the validated IP literal — there is no second
# DNS lookup, so a rebind between validation and connection is
# impossible.
method, url = session.request.call_args.args
kwargs = session.request.call_args.kwargs
assert method == "POST"
assert url == "https://93.184.216.34:443/hook"
# Host header + TLS SNI still target the real hostname.
assert kwargs["headers"]["Host"] == "example.com"
assert kwargs["allow_redirects"] is False
assert kwargs["verify"] is True
assert kwargs["json"] == {"a": 1}
# Ambient proxy/env must not be honoured (would bypass pinning).
assert session.trust_env is False
assert kwargs["proxies"] == {"http": None, "https": None}
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_non_default_port_in_host_header(self, mock_resolve, mock_session_cls):
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.return_value = _resp(200)
pinned_fetch("GET", "http://example.com:8080/x")
_, url = session.request.call_args.args
kwargs = session.request.call_args.kwargs
assert url == "http://93.184.216.34:8080/x"
assert kwargs["headers"]["Host"] == "example.com:8080"
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_ipv6_validated_ip_is_bracketed(self, mock_resolve, mock_session_cls):
mock_resolve.return_value = ["2606:4700:4700::1111"]
session = mock_session_cls.return_value
session.request.return_value = _resp(200)
pinned_fetch("GET", "https://example.com/x")
_, url = session.request.call_args.args
assert url == "https://[2606:4700:4700::1111]:443/x"
@patch("plane.utils.url_security.resolve_and_validate")
def test_blocked_target_raises_before_any_request(self, mock_resolve):
mock_resolve.side_effect = ValueError(
"Access to private/internal networks is not allowed"
)
with pytest.raises(ValueError, match="private/internal"):
pinned_fetch("POST", "https://attacker.com/hook")
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_tries_next_ip_on_connection_error(self, mock_resolve, mock_session_cls):
# Dual-stack host: first validated IP is unreachable, second works.
mock_resolve.return_value = ["93.184.216.34", "93.184.216.35"]
session = mock_session_cls.return_value
session.request.side_effect = [
requests.ConnectionError("down"),
_resp(200),
]
resp = pinned_fetch("GET", "https://example.com/x")
assert resp.status_code == 200
assert session.request.call_count == 2
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_allowed_host_skips_block_check_but_still_pins(self, mock_resolve, mock_session_cls):
# Trusted host (e.g. internal docker service) whose IP is private: the
# block check is skipped, but the connection is STILL pinned to the
# resolved IP so it cannot be rebound to a different internal target.
mock_resolve.return_value = ["172.18.0.5"]
session = mock_session_cls.return_value
session.request.return_value = _resp(200)
pinned_fetch(
"POST",
"http://silo:3000/hook",
allowed_hosts=["silo"],
json={"x": 1},
)
# Resolution happens with require_safe=False (trusted, skip block check).
assert mock_resolve.call_args.kwargs.get("require_safe") is False
# ...but the connection is pinned to the resolved IP literal, Host=silo.
_, url = session.request.call_args.args
assert url == "http://172.18.0.5:3000/hook"
assert session.request.call_args.kwargs["headers"]["Host"] == "silo:3000"
assert session.request.call_args.kwargs["allow_redirects"] is False
# ---------------------------------------------------------------------------
# Cluster C — redirects re-resolved / re-validated / re-pinned each hop
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestPinnedFetchRedirects:
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_no_redirect_returns_response(self, mock_resolve, mock_session_cls):
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.return_value = _resp(200)
resp, final = pinned_fetch_following_redirects("GET", "https://example.com/a")
assert resp.status_code == 200
assert final == "https://example.com/a"
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_follows_and_revalidates_each_hop(self, mock_resolve, mock_session_cls):
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.side_effect = [
_resp(301, headers={"Location": "https://other.com/page"}),
_resp(200),
]
resp, final = pinned_fetch_following_redirects("GET", "https://example.com/a")
assert resp.status_code == 200
assert final == "https://other.com/page"
# Re-resolved (and thus re-validated + re-pinned) on each hop.
assert mock_resolve.call_count == 2
assert mock_resolve.call_args_list[0].args[0] == "example.com"
assert mock_resolve.call_args_list[1].args[0] == "other.com"
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_blocks_redirect_to_private_ip(self, mock_resolve, mock_session_cls):
# First hop resolves public; redirect target resolves private -> blocked
mock_resolve.side_effect = [
["93.184.216.34"],
ValueError("Access to private/internal networks is not allowed"),
]
session = mock_session_cls.return_value
session.request.return_value = _resp(
302, headers={"Location": "http://169.254.169.254/latest/meta-data/"}
)
with pytest.raises(ValueError, match="private/internal"):
pinned_fetch_following_redirects("GET", "https://evil.com/r")
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_too_many_redirects(self, mock_resolve, mock_session_cls):
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.return_value = _resp(
302, headers={"Location": "https://example.com/loop"}
)
with pytest.raises(requests.TooManyRedirects):
pinned_fetch_following_redirects(
"GET", "https://example.com/start", max_redirects=3
)
# ---------------------------------------------------------------------------
# PinnedIPAdapter — TLS server_hostname injection (cert verified vs hostname)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestPinnedIPAdapter:
def test_injects_server_hostname_into_pool(self):
adapter = PinnedIPAdapter(server_hostname="example.com")
adapter.build_connection_pool_key_attributes = MagicMock(
return_value=({"scheme": "https", "host": "93.184.216.34", "port": 443}, {})
)
adapter.poolmanager = MagicMock()
request = MagicMock()
adapter.get_connection_with_tls_context(request, verify=True)
_, kwargs = adapter.poolmanager.connection_from_host.call_args
assert kwargs["pool_kwargs"]["server_hostname"] == "example.com"
# ---------------------------------------------------------------------------
# validate_url — create/update-time defense in depth still rejects bypasses
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestValidateUrlHardening:
@pytest.mark.parametrize("ip", ["100.64.0.1", "224.0.0.1", "0.0.0.0"])
def test_rejects_newly_covered_ranges(self, ip):
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
dns.return_value = [_addr(ip)]
with pytest.raises(ValueError, match="private/internal"):
validate_url("http://attacker.example.com")
# ---------------------------------------------------------------------------
# Review-feedback fixes (PR #9163)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestReviewFixes:
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_url_embedded_credentials_become_basic_auth(self, mock_resolve, mock_session_cls):
# user:pass@host -> Basic Auth preserved as auth=, userinfo stripped from URL
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.return_value = _resp(200)
pinned_fetch("GET", "https://user:p%40ss@example.com/hook")
_, url = session.request.call_args.args
kwargs = session.request.call_args.kwargs
assert url == "https://93.184.216.34:443/hook" # no userinfo in the IP URL
assert kwargs["auth"] == ("user", "p@ss") # percent-decoded
assert kwargs["headers"]["Host"] == "example.com"
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_no_credentials_passes_auth_none(self, mock_resolve, mock_session_cls):
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.return_value = _resp(200)
pinned_fetch("GET", "https://example.com/x")
assert session.request.call_args.kwargs["auth"] is None
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_ipv6_literal_host_header_is_bracketed(self, mock_resolve, mock_session_cls):
mock_resolve.return_value = ["2606:4700:4700::1111"]
session = mock_session_cls.return_value
session.request.return_value = _resp(200)
pinned_fetch("GET", "https://[2606:4700:4700::1111]/x")
kwargs = session.request.call_args.kwargs
assert kwargs["headers"]["Host"] == "[2606:4700:4700::1111]"
def test_idna_unicode_error_is_treated_as_unresolvable(self):
# getaddrinfo can raise UnicodeError (IDNA) before any lookup; it must
# surface as ValueError so webhook_send_task records a URL rejection.
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
dns.side_effect = UnicodeError("label empty or too long")
with pytest.raises(ValueError, match="could not be resolved"):
resolve_and_validate("xn--bad-name")
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_stream_defers_session_close_until_response_close(self, mock_resolve, mock_session_cls):
# With stream=True the size cap can bound memory only if the session
# stays open until the body is read; closing the response closes it.
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
resp = _resp(200)
session.request.return_value = resp
out = pinned_fetch("GET", "https://cdn.example.com/a.png", stream=True)
assert session.request.call_args.kwargs["stream"] is True
session.close.assert_not_called() # deferred
out.close()
session.close.assert_called_once()

View File

@@ -0,0 +1,230 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import ipaddress
import pytest
import requests
from unittest.mock import patch, MagicMock
from plane.bgtasks.work_item_link_task import safe_get, validate_url_ip
from plane.utils.ip_address import validate_url
def _make_response(status_code=200, headers=None, is_redirect=False, content=b""):
"""Create a mock requests.Response."""
resp = MagicMock()
resp.status_code = status_code
resp.is_redirect = is_redirect
resp.headers = headers or {}
resp.content = content
return resp
@pytest.mark.unit
class TestValidateUrlIp:
"""Test validate_url_ip blocks private/internal IPs."""
def test_rejects_private_ip(self):
with patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("192.168.1.1", 0))]
with pytest.raises(ValueError, match="private/internal"):
validate_url_ip("http://example.com")
def test_rejects_loopback(self):
with patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("127.0.0.1", 0))]
with pytest.raises(ValueError, match="private/internal"):
validate_url_ip("http://example.com")
def test_rejects_non_http_scheme(self):
with pytest.raises(ValueError, match="Only HTTP and HTTPS"):
validate_url_ip("file:///etc/passwd")
def test_allows_public_ip(self):
with patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("93.184.216.34", 0))]
validate_url_ip("https://example.com") # Should not raise
@pytest.mark.parametrize(
"ip",
[
"100.64.0.1", # CGNAT / shared address space (not is_private on 3.12)
"224.0.0.1", # multicast
"0.0.0.0", # unspecified
"::ffff:169.254.169.254", # IPv4-mapped cloud metadata
"64:ff9b::a9fe:a9fe", # NAT64 embedding 169.254.169.254
],
)
def test_rejects_hardened_bypass_ranges(self, ip):
with patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, (ip, 0))]
with pytest.raises(ValueError, match="private/internal"):
validate_url_ip("http://attacker.example.com")
@pytest.mark.unit
class TestValidateUrlAllowlist:
"""Test validate_url allowlist permits specific private IPs."""
def test_allowlist_permits_private_ip(self):
allowed = [ipaddress.ip_network("192.168.1.0/24")]
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("192.168.1.50", 0))]
validate_url("http://example.com", allowed_ips=allowed) # Should not raise
def test_allowlist_does_not_permit_other_private_ip(self):
allowed = [ipaddress.ip_network("192.168.1.0/24")]
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("10.0.0.1", 0))]
with pytest.raises(ValueError, match="private/internal"):
validate_url("http://example.com", allowed_ips=allowed)
def test_allowlist_permits_loopback_when_explicitly_allowed(self):
allowed = [ipaddress.ip_network("127.0.0.0/8")]
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("127.0.0.1", 0))]
validate_url("http://example.com", allowed_ips=allowed) # Should not raise
def test_allowlist_permits_matching_ipv4_with_mixed_version_networks(self):
allowed = [
ipaddress.ip_network("2001:db8::/32"),
ipaddress.ip_network("192.168.1.0/24"),
]
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("192.168.1.50", 0))]
validate_url("http://example.com", allowed_ips=allowed) # Should not raise
def test_allowlist_blocks_non_matching_ipv4_with_mixed_version_networks(self):
allowed = [
ipaddress.ip_network("2001:db8::/32"),
ipaddress.ip_network("192.168.1.0/24"),
]
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("10.0.0.1", 0))]
with pytest.raises(ValueError, match="private/internal"):
validate_url("http://example.com", allowed_ips=allowed)
def test_allowed_hosts_bypasses_private_ip_check(self):
"""Hostnames in WEBHOOK_ALLOWED_HOSTS skip IP-based blocking — used for
trusted internal services (e.g. Silo) whose IPs are dynamic in
containerised deployments."""
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("172.18.0.5", 0))]
validate_url("http://silo:3000/hook", allowed_hosts=["silo"]) # Should not raise
def test_allowed_hosts_matches_case_insensitively(self):
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("10.0.0.1", 0))]
validate_url(
"http://Silo.Namespace.Svc.Cluster.Local/x",
allowed_hosts=["silo.namespace.svc.cluster.local"],
) # Should not raise
def test_allowed_hosts_skips_dns_lookup(self):
"""When the hostname is explicitly trusted we shouldn't even resolve it —
protects against operators who allowlist a name that isn't resolvable
from the API container."""
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
validate_url("http://silo/hook", allowed_hosts=["silo"])
mock_dns.assert_not_called()
def test_allowed_hosts_requires_exact_match(self):
"""Subdomains of an allowed host must NOT bypass — a hostile
``attacker.silo.internal`` should still be blocked when only
``silo.internal`` is allowed."""
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("192.168.1.1", 0))]
with pytest.raises(ValueError, match="private/internal"):
validate_url(
"http://attacker.silo.internal/x",
allowed_hosts=["silo.internal"],
)
def test_allowed_hosts_empty_does_not_bypass(self):
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("10.0.0.1", 0))]
with pytest.raises(ValueError, match="private/internal"):
validate_url("http://silo/hook", allowed_hosts=[])
@pytest.mark.unit
class TestSafeGet:
"""safe_get now delegates to the pinned SSRF-safe client; assert it resolves,
validates, pins to the validated IP and follows redirects safely. Network is
mocked at the requests.Session boundary inside plane.utils.url_security."""
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_returns_response_for_non_redirect(self, mock_resolve, mock_session_cls):
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.return_value = _make_response(status_code=200, content=b"OK")
response, final_url = safe_get("https://example.com")
assert response.status_code == 200
assert final_url == "https://example.com"
# Pinned to the validated IP literal, not the hostname.
_, url = session.request.call_args.args
assert url == "https://93.184.216.34:443/"
assert session.request.call_args.kwargs["headers"]["Host"] == "example.com"
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_follows_redirect_and_validates_each_hop(self, mock_resolve, mock_session_cls):
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.side_effect = [
_make_response(status_code=301, headers={"Location": "https://other.com/page"}),
_make_response(status_code=200, content=b"OK"),
]
response, final_url = safe_get("https://example.com")
assert response.status_code == 200
assert final_url == "https://other.com/page"
assert mock_resolve.call_count == 2
assert mock_resolve.call_args_list[0].args[0] == "example.com"
assert mock_resolve.call_args_list[1].args[0] == "other.com"
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_blocks_redirect_to_private_ip(self, mock_resolve, mock_session_cls):
mock_resolve.side_effect = [
["93.184.216.34"],
ValueError("Access to private/internal networks is not allowed"),
]
session = mock_session_cls.return_value
session.request.return_value = _make_response(
status_code=302, headers={"Location": "http://192.168.1.1:8080"}
)
with pytest.raises(ValueError, match="private/internal"):
safe_get("https://evil.com/redirect")
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_raises_on_too_many_redirects(self, mock_resolve, mock_session_cls):
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.return_value = _make_response(
status_code=302, headers={"Location": "https://example.com/loop"}
)
with pytest.raises(requests.TooManyRedirects):
safe_get("https://example.com/start")
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_succeeds_at_exact_max_redirects(self, mock_resolve, mock_session_cls):
"""5 redirects then a 200 must succeed (MAX_REDIRECTS == 5)."""
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.side_effect = [
_make_response(status_code=302, headers={"Location": "https://example.com/next"})
] * 5 + [_make_response(status_code=200, content=b"OK")]
response, final_url = safe_get("https://example.com/start")
assert response.status_code == 200