-
Notifications
You must be signed in to change notification settings - Fork 4k
fix: validate redirects in favicon fetching to prevent SSRF #8858
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
sriramveeraghanta
merged 2 commits into
preview
from
fix/ssrf-favicon-redirect-validation
Apr 6, 2026
+178
−24
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
126 changes: 126 additions & 0 deletions
126
apps/api/plane/tests/unit/bg_tasks/test_work_item_link_task.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| # 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 unittest.mock import patch, MagicMock | ||
| from plane.bgtasks.work_item_link_task import safe_get, validate_url_ip | ||
|
|
||
|
|
||
| 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.unit | ||
| class TestSafeGet: | ||
| """Test safe_get follows redirects safely and blocks SSRF.""" | ||
|
|
||
| @patch("plane.bgtasks.work_item_link_task.requests.get") | ||
| @patch("plane.bgtasks.work_item_link_task.validate_url_ip") | ||
| def test_returns_response_for_non_redirect(self, mock_validate, mock_get): | ||
| final_resp = _make_response(status_code=200, content=b"OK") | ||
| mock_get.return_value = final_resp | ||
|
|
||
| response, final_url = safe_get("https://example.com") | ||
|
|
||
| assert response is final_resp | ||
| assert final_url == "https://example.com" | ||
| mock_validate.assert_called_once_with("https://example.com") | ||
|
|
||
| @patch("plane.bgtasks.work_item_link_task.requests.get") | ||
| @patch("plane.bgtasks.work_item_link_task.validate_url_ip") | ||
| def test_follows_redirect_and_validates_each_hop(self, mock_validate, mock_get): | ||
| redirect_resp = _make_response( | ||
| status_code=301, | ||
| is_redirect=True, | ||
| headers={"Location": "https://other.com/page"}, | ||
| ) | ||
| final_resp = _make_response(status_code=200, content=b"OK") | ||
| mock_get.side_effect = [redirect_resp, final_resp] | ||
|
|
||
| response, final_url = safe_get("https://example.com") | ||
|
|
||
| assert response is final_resp | ||
| assert final_url == "https://other.com/page" | ||
| # Should validate both the initial URL and the redirect target | ||
| assert mock_validate.call_count == 2 | ||
| mock_validate.assert_any_call("https://example.com") | ||
| mock_validate.assert_any_call("https://other.com/page") | ||
|
|
||
| @patch("plane.bgtasks.work_item_link_task.requests.get") | ||
| @patch("plane.bgtasks.work_item_link_task.validate_url_ip") | ||
| def test_blocks_redirect_to_private_ip(self, mock_validate, mock_get): | ||
| redirect_resp = _make_response( | ||
| status_code=302, | ||
| is_redirect=True, | ||
| headers={"Location": "http://192.168.1.1:8080"}, | ||
| ) | ||
| mock_get.return_value = redirect_resp | ||
| # First call (initial URL) succeeds, second call (redirect target) fails | ||
| mock_validate.side_effect = [None, ValueError("Access to private/internal networks is not allowed")] | ||
|
|
||
| with pytest.raises(ValueError, match="private/internal"): | ||
| safe_get("https://evil.com/redirect") | ||
|
|
||
| @patch("plane.bgtasks.work_item_link_task.requests.get") | ||
| @patch("plane.bgtasks.work_item_link_task.validate_url_ip") | ||
| def test_raises_on_too_many_redirects(self, mock_validate, mock_get): | ||
| redirect_resp = _make_response( | ||
| status_code=302, | ||
| is_redirect=True, | ||
| headers={"Location": "https://example.com/loop"}, | ||
| ) | ||
| mock_get.return_value = redirect_resp | ||
|
|
||
| with pytest.raises(RuntimeError, match="Too many redirects"): | ||
| safe_get("https://example.com/start") | ||
|
|
||
| @patch("plane.bgtasks.work_item_link_task.requests.get") | ||
| @patch("plane.bgtasks.work_item_link_task.validate_url_ip") | ||
| def test_succeeds_at_exact_max_redirects(self, mock_validate, mock_get): | ||
| """After exactly MAX_REDIRECTS hops, if the final response is 200, it should succeed.""" | ||
| redirect_resp = _make_response( | ||
| status_code=302, | ||
| is_redirect=True, | ||
| headers={"Location": "https://example.com/next"}, | ||
| ) | ||
| final_resp = _make_response(status_code=200, content=b"OK") | ||
| # 5 redirects then a 200 | ||
| mock_get.side_effect = [redirect_resp] * 5 + [final_resp] | ||
|
|
||
| response, final_url = safe_get("https://example.com/start") | ||
|
|
||
| assert response is final_resp | ||
| assert not response.is_redirect |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: makeplane/plane
Length of output: 6259
🏁 Script executed:
Repository: makeplane/plane
Length of output: 100
🏁 Script executed:
Repository: makeplane/plane
Length of output: 429
🏁 Script executed:
Repository: makeplane/plane
Length of output: 250
🏁 Script executed:
Repository: makeplane/plane
Length of output: 281
🏁 Script executed:
Repository: makeplane/plane
Length of output: 229
🏁 Script executed:
Repository: makeplane/plane
Length of output: 41
🏁 Script executed:
Repository: makeplane/plane
Length of output: 229
🌐 Web query:
urllib3 requests library DNS resolution caching behavior💡 Result:
urllib3(and thereforerequests) does not implement its own DNS cache. When it needs a new TCP connection, it resolves hostnames via the OS resolver by callingsocket.getaddrinfo()(i.e., whatever your system/NSS/DNS configuration does). [1]urllib3andrequests.Session()keep connection pools keyed by host/port and will reuse an existing keep-alive connection when possible—so no new DNS lookup happens if no new connection is created. [2]requests.Session()/ adapter, or disable reuse), or implement DNS caching/overrides outside ofrequests/urllib3(OS-level caching or custom resolver approaches). It’s commonly noted that requests/urllib3 themselves don’t cache DNS. [3][4]Sources:
[1]
urllib3.util.connection.create_connectionusessocket.getaddrinfo()(no library DNS cache) — https://dokk.org/documentation/urllib3/1.6/_modules/urllib3/util/connection/ (dokk.org)[2] Requests docs: keep-alive is automatic within a
Session(connection pooling/reuse) — https://docs.python-requests.org/_/downloads/en/v2.7.0/pdf/ (docs.python-requests.org)[3] AWS re:Invent slide explicitly stating requests/urllib3 don’t cache DNS — https://reinvent.awsevents.com/content/dam/reinvent/2024/slides/svs/SVS218-NEW_Accelerate-Python-and-NET-Lambda-functions-with-SnapStart.pdf (reinvent.awsevents.com)
[4] Discussion noting
requests(viaurllib3) callsgetaddrinfofor hostname resolution and suggests OS/app-level DNS caching if needed — https://stackoverflow.com/questions/45479393/connection-error-while-using-requests-to-get-response-from-google-distance-matri (stackoverflow.com)Citations:
The SSRF guard is bypassable via DNS rebinding—a real TOCTOU window exists in the validation approach.
validate_url_ip()resolves the hostname viasocket.getaddrinfo()to check the IP, butrequests.get()must resolve the same hostname again when opening the socket. Becauseurllib3(underlyingrequests) performs no DNS caching and each socket creation triggers an independent resolver call, an attacker-controlled DNS server can return a public IP during validation, then serve a private IP on the next lookup—allowing the actual request to hit internal addresses. This same TOCTOU gap applies to each redirect hop (line 106 validates, line 108-110 requests). A proper fix requires either binding the socket connect to the pre-validated IP address or enforcing egress controls outside the application layer.🤖 Prompt for AI Agents