Skip to content

Commit 63fac3b

Browse files
fix: validate redirects in favicon fetching to prevent SSRF (#8858)
* fix: validate redirects in favicon fetching to prevent SSRF The previous SSRF fix (GHSA-jcc6-f9v6-f7jw) only validated redirects for the main page URL but not for the favicon fetch path. An attacker could craft an HTML page with a favicon link that redirects to a private IP, bypassing the IP validation and leaking internal network data as base64. Extract a reusable `safe_get()` function that validates every redirect hop against private/internal IPs and use it for both page and favicon fetches. Resolves: GHSA-9fr2-pprw-pp9j Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review feedback for SSRF favicon fix - Fix off-by-one in redirect limit: only raise RuntimeError when the response is still a redirect after MAX_REDIRECTS hops, not when the final response is a successful 200 - Return final URL from safe_get() so favicon href resolution uses the correct origin after redirects instead of the original URL - Add unit tests for validate_url_ip and safe_get covering private IP blocking, redirect-following, and redirect limit enforcement Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 587fe76 commit 63fac3b

2 files changed

Lines changed: 178 additions & 24 deletions

File tree

apps/api/plane/bgtasks/work_item_link_task.py

Lines changed: 52 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from urllib.parse import urlparse, urljoin
1414
import base64
1515
import ipaddress
16-
from typing import Dict, Any
16+
from typing import Dict, Any, Tuple
1717
from typing import Optional
1818
from plane.db.models import IssueLink
1919
from plane.utils.exception_logger import log_exception
@@ -66,6 +66,52 @@ def validate_url_ip(url: str) -> None:
6666
MAX_REDIRECTS = 5
6767

6868

69+
def safe_get(
70+
url: str,
71+
headers: Optional[Dict[str, str]] = None,
72+
timeout: int = 1,
73+
) -> Tuple[requests.Response, str]:
74+
"""
75+
Perform a GET request that validates every redirect hop against private IPs.
76+
Prevents SSRF by ensuring no redirect lands on a private/internal address.
77+
78+
Args:
79+
url: The URL to fetch
80+
headers: Optional request headers
81+
timeout: Request timeout in seconds
82+
83+
Returns:
84+
A tuple of (final Response object, final URL after redirects)
85+
86+
Raises:
87+
ValueError: If any URL in the redirect chain points to a private IP
88+
requests.RequestException: On network errors
89+
RuntimeError: If max redirects exceeded
90+
"""
91+
validate_url_ip(url)
92+
93+
current_url = url
94+
response = requests.get(
95+
current_url, headers=headers, timeout=timeout, allow_redirects=False
96+
)
97+
98+
redirect_count = 0
99+
while response.is_redirect:
100+
if redirect_count >= MAX_REDIRECTS:
101+
raise RuntimeError(f"Too many redirects for URL: {url}")
102+
redirect_url = response.headers.get("Location")
103+
if not redirect_url:
104+
break
105+
current_url = urljoin(current_url, redirect_url)
106+
validate_url_ip(current_url)
107+
redirect_count += 1
108+
response = requests.get(
109+
current_url, headers=headers, timeout=timeout, allow_redirects=False
110+
)
111+
112+
return response, current_url
113+
114+
69115
def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
70116
"""
71117
Crawls a URL to extract the title and favicon.
@@ -86,35 +132,19 @@ def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
86132
title = None
87133
final_url = url
88134

89-
validate_url_ip(final_url)
90-
91135
try:
92-
# Manually follow redirects to validate each URL before requesting
93-
redirect_count = 0
94-
response = requests.get(final_url, headers=headers, timeout=1, allow_redirects=False)
95-
96-
while response.is_redirect and redirect_count < MAX_REDIRECTS:
97-
redirect_url = response.headers.get("Location")
98-
if not redirect_url:
99-
break
100-
# Resolve relative redirects against current URL
101-
final_url = urljoin(final_url, redirect_url)
102-
# Validate the redirect target BEFORE making the request
103-
validate_url_ip(final_url)
104-
redirect_count += 1
105-
response = requests.get(final_url, headers=headers, timeout=1, allow_redirects=False)
106-
107-
if redirect_count >= MAX_REDIRECTS:
108-
logger.warning(f"Too many redirects for URL: {url}")
136+
response, final_url = safe_get(url, headers=headers)
109137

110138
soup = BeautifulSoup(response.content, "html.parser")
111139
title_tag = soup.find("title")
112140
title = title_tag.get_text().strip() if title_tag else None
113141

114142
except requests.RequestException as e:
115143
logger.warning(f"Failed to fetch HTML for title: {str(e)}")
144+
except (ValueError, RuntimeError) as e:
145+
logger.warning(f"URL validation failed: {str(e)}")
116146

117-
# Fetch and encode favicon using final URL (after redirects)
147+
# Fetch and encode favicon using final URL (after redirects) for correct relative href resolution
118148
favicon_base64 = fetch_and_encode_favicon(headers, soup, final_url)
119149

120150
# Prepare result
@@ -204,9 +234,7 @@ def fetch_and_encode_favicon(
204234
"favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}",
205235
}
206236

207-
validate_url_ip(favicon_url)
208-
209-
response = requests.get(favicon_url, headers=headers, timeout=1)
237+
response, _ = safe_get(favicon_url, headers=headers)
210238

211239
# Get content type
212240
content_type = response.headers.get("content-type", "image/x-icon")
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# Copyright (c) 2023-present Plane Software, Inc. and contributors
2+
# SPDX-License-Identifier: AGPL-3.0-only
3+
# See the LICENSE file for details.
4+
5+
import pytest
6+
from unittest.mock import patch, MagicMock
7+
from plane.bgtasks.work_item_link_task import safe_get, validate_url_ip
8+
9+
10+
def _make_response(status_code=200, headers=None, is_redirect=False, content=b""):
11+
"""Create a mock requests.Response."""
12+
resp = MagicMock()
13+
resp.status_code = status_code
14+
resp.is_redirect = is_redirect
15+
resp.headers = headers or {}
16+
resp.content = content
17+
return resp
18+
19+
20+
@pytest.mark.unit
21+
class TestValidateUrlIp:
22+
"""Test validate_url_ip blocks private/internal IPs."""
23+
24+
def test_rejects_private_ip(self):
25+
with patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo") as mock_dns:
26+
mock_dns.return_value = [(None, None, None, None, ("192.168.1.1", 0))]
27+
with pytest.raises(ValueError, match="private/internal"):
28+
validate_url_ip("http://example.com")
29+
30+
def test_rejects_loopback(self):
31+
with patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo") as mock_dns:
32+
mock_dns.return_value = [(None, None, None, None, ("127.0.0.1", 0))]
33+
with pytest.raises(ValueError, match="private/internal"):
34+
validate_url_ip("http://example.com")
35+
36+
def test_rejects_non_http_scheme(self):
37+
with pytest.raises(ValueError, match="Only HTTP and HTTPS"):
38+
validate_url_ip("file:///etc/passwd")
39+
40+
def test_allows_public_ip(self):
41+
with patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo") as mock_dns:
42+
mock_dns.return_value = [(None, None, None, None, ("93.184.216.34", 0))]
43+
validate_url_ip("https://example.com") # Should not raise
44+
45+
46+
@pytest.mark.unit
47+
class TestSafeGet:
48+
"""Test safe_get follows redirects safely and blocks SSRF."""
49+
50+
@patch("plane.bgtasks.work_item_link_task.requests.get")
51+
@patch("plane.bgtasks.work_item_link_task.validate_url_ip")
52+
def test_returns_response_for_non_redirect(self, mock_validate, mock_get):
53+
final_resp = _make_response(status_code=200, content=b"OK")
54+
mock_get.return_value = final_resp
55+
56+
response, final_url = safe_get("https://example.com")
57+
58+
assert response is final_resp
59+
assert final_url == "https://example.com"
60+
mock_validate.assert_called_once_with("https://example.com")
61+
62+
@patch("plane.bgtasks.work_item_link_task.requests.get")
63+
@patch("plane.bgtasks.work_item_link_task.validate_url_ip")
64+
def test_follows_redirect_and_validates_each_hop(self, mock_validate, mock_get):
65+
redirect_resp = _make_response(
66+
status_code=301,
67+
is_redirect=True,
68+
headers={"Location": "https://other.com/page"},
69+
)
70+
final_resp = _make_response(status_code=200, content=b"OK")
71+
mock_get.side_effect = [redirect_resp, final_resp]
72+
73+
response, final_url = safe_get("https://example.com")
74+
75+
assert response is final_resp
76+
assert final_url == "https://other.com/page"
77+
# Should validate both the initial URL and the redirect target
78+
assert mock_validate.call_count == 2
79+
mock_validate.assert_any_call("https://example.com")
80+
mock_validate.assert_any_call("https://other.com/page")
81+
82+
@patch("plane.bgtasks.work_item_link_task.requests.get")
83+
@patch("plane.bgtasks.work_item_link_task.validate_url_ip")
84+
def test_blocks_redirect_to_private_ip(self, mock_validate, mock_get):
85+
redirect_resp = _make_response(
86+
status_code=302,
87+
is_redirect=True,
88+
headers={"Location": "http://192.168.1.1:8080"},
89+
)
90+
mock_get.return_value = redirect_resp
91+
# First call (initial URL) succeeds, second call (redirect target) fails
92+
mock_validate.side_effect = [None, ValueError("Access to private/internal networks is not allowed")]
93+
94+
with pytest.raises(ValueError, match="private/internal"):
95+
safe_get("https://evil.com/redirect")
96+
97+
@patch("plane.bgtasks.work_item_link_task.requests.get")
98+
@patch("plane.bgtasks.work_item_link_task.validate_url_ip")
99+
def test_raises_on_too_many_redirects(self, mock_validate, mock_get):
100+
redirect_resp = _make_response(
101+
status_code=302,
102+
is_redirect=True,
103+
headers={"Location": "https://example.com/loop"},
104+
)
105+
mock_get.return_value = redirect_resp
106+
107+
with pytest.raises(RuntimeError, match="Too many redirects"):
108+
safe_get("https://example.com/start")
109+
110+
@patch("plane.bgtasks.work_item_link_task.requests.get")
111+
@patch("plane.bgtasks.work_item_link_task.validate_url_ip")
112+
def test_succeeds_at_exact_max_redirects(self, mock_validate, mock_get):
113+
"""After exactly MAX_REDIRECTS hops, if the final response is 200, it should succeed."""
114+
redirect_resp = _make_response(
115+
status_code=302,
116+
is_redirect=True,
117+
headers={"Location": "https://example.com/next"},
118+
)
119+
final_resp = _make_response(status_code=200, content=b"OK")
120+
# 5 redirects then a 200
121+
mock_get.side_effect = [redirect_resp] * 5 + [final_resp]
122+
123+
response, final_url = safe_get("https://example.com/start")
124+
125+
assert response is final_resp
126+
assert not response.is_redirect

0 commit comments

Comments
 (0)