Skip to content

Commit dd22407

Browse files
committed
fix: enhance URL validation to prevent access to blocked internal addresses
1 parent 6e44ee2 commit dd22407

1 file changed

Lines changed: 134 additions & 57 deletions

File tree

apps/common/utils/fork.py

Lines changed: 134 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import copy
2+
import ipaddress
23
import re
4+
import socket
35
import traceback
46
from functools import reduce
57
from typing import List, Set
@@ -13,6 +15,44 @@
1315

1416
requests.packages.urllib3.disable_warnings()
1517

18+
_BLOCKED_NETWORKS = [
19+
ipaddress.ip_network("0.0.0.0/8"),
20+
ipaddress.ip_network("10.0.0.0/8"),
21+
ipaddress.ip_network("100.64.0.0/10"),
22+
ipaddress.ip_network("127.0.0.0/8"),
23+
ipaddress.ip_network("169.254.0.0/16"),
24+
ipaddress.ip_network("172.16.0.0/12"),
25+
ipaddress.ip_network("192.168.0.0/16"),
26+
ipaddress.ip_network("198.18.0.0/15"),
27+
ipaddress.ip_network("198.51.100.0/24"),
28+
ipaddress.ip_network("203.0.113.0/24"),
29+
ipaddress.ip_network("240.0.0.0/4"),
30+
ipaddress.ip_network("::1/128"),
31+
ipaddress.ip_network("fc00::/7"),
32+
ipaddress.ip_network("fe80::/10"),
33+
]
34+
35+
36+
def _validate_url(url: str) -> None:
37+
parsed = urlparse(url)
38+
if parsed.scheme not in ("http", "https"):
39+
raise ValueError(f"Disallowed URL scheme: {parsed.scheme!r}")
40+
hostname = parsed.hostname
41+
if not hostname:
42+
raise ValueError("Missing hostname in URL")
43+
try:
44+
addr_infos = socket.getaddrinfo(hostname, None)
45+
except socket.gaierror as exc:
46+
raise ValueError(f"Cannot resolve host {hostname!r}: {exc}") from exc
47+
for addr_info in addr_infos:
48+
ip_str = addr_info[4][0]
49+
try:
50+
ip = ipaddress.ip_address(ip_str)
51+
except ValueError:
52+
continue
53+
if any(ip in net for net in _BLOCKED_NETWORKS):
54+
raise ValueError(f"URL resolves to a blocked internal address: {ip}")
55+
1656

1757
class ChildLink:
1858
def __init__(self, url, tag):
@@ -29,27 +69,34 @@ def fork(self, level: int, exclude_link_url: Set[str], fork_handler):
2969
self.fork_child(ChildLink(self.base_url, None), self.selector_list, level, exclude_link_url, fork_handler)
3070

3171
@staticmethod
32-
def fork_child(child_link: ChildLink, selector_list: List[str], level: int, exclude_link_url: Set[str],
33-
fork_handler):
72+
def fork_child(
73+
child_link: ChildLink, selector_list: List[str], level: int, exclude_link_url: Set[str], fork_handler
74+
):
3475
if level < 0:
3576
return
3677
else:
3778
child_link.url = remove_fragment(child_link.url)
38-
child_url = child_link.url[:-1] if child_link.url.endswith('/') else child_link.url
79+
child_url = child_link.url[:-1] if child_link.url.endswith("/") else child_link.url
3980
if not exclude_link_url.__contains__(child_url):
4081
exclude_link_url.add(child_url)
4182
response = Fork(child_link.url, selector_list).fork()
4283
fork_handler(child_link, response)
4384
for child_link in response.child_link_list:
44-
child_url = child_link.url[:-1] if child_link.url.endswith('/') else child_link.url
85+
child_url = child_link.url[:-1] if child_link.url.endswith("/") else child_link.url
4586
if not exclude_link_url.__contains__(child_url):
4687
ForkManage.fork_child(child_link, selector_list, level - 1, exclude_link_url, fork_handler)
4788

4889

4990
def remove_fragment(url: str) -> str:
5091
parsed_url = urlparse(url)
51-
modified_url = ParseResult(scheme=parsed_url.scheme, netloc=parsed_url.netloc, path=parsed_url.path,
52-
params=parsed_url.params, query=parsed_url.query, fragment=None)
92+
modified_url = ParseResult(
93+
scheme=parsed_url.scheme,
94+
netloc=parsed_url.netloc,
95+
path=parsed_url.path,
96+
params=parsed_url.params,
97+
query=parsed_url.query,
98+
fragment=None,
99+
)
53100
return urlunparse(modified_url)
54101

55102

@@ -63,54 +110,68 @@ def __init__(self, content: str, child_link_list: List[ChildLink], status, messa
63110

64111
@staticmethod
65112
def success(html_content: str, child_link_list: List[ChildLink]):
66-
return Fork.Response(html_content, child_link_list, 200, '')
113+
return Fork.Response(html_content, child_link_list, 200, "")
67114

68115
@staticmethod
69116
def error(message: str):
70-
return Fork.Response('', [], 500, message)
117+
return Fork.Response("", [], 500, message)
71118

72119
def __init__(self, base_fork_url: str, selector_list: List[str]):
120+
_validate_url(base_fork_url)
73121
base_fork_url = remove_fragment(base_fork_url)
74122
parsed = urlparse(base_fork_url)
75-
path = parsed.path.rstrip('/')
76-
self.base_fork_url = urlunparse((
77-
parsed.scheme,
78-
parsed.netloc,
79-
path,
80-
None,
81-
None,
82-
None # fragment
83-
))
123+
path = parsed.path.rstrip("/")
124+
self.base_fork_url = urlunparse(
125+
(
126+
parsed.scheme,
127+
parsed.netloc,
128+
path,
129+
None,
130+
None,
131+
None, # fragment
132+
)
133+
)
84134
parsed = urlsplit(base_fork_url)
85135
query = parsed.query
86136
if query is not None and len(query) > 0:
87-
self.base_fork_url = self.base_fork_url + '?' + query
137+
self.base_fork_url = self.base_fork_url + "?" + query
88138
self.selector_list = [selector for selector in selector_list if selector is not None and len(selector) > 0]
89139
self.urlparse = urlparse(self.base_fork_url)
90-
self.base_url = ParseResult(scheme=self.urlparse.scheme, netloc=self.urlparse.netloc, path='', params='',
91-
query='',
92-
fragment='').geturl()
140+
self.base_url = ParseResult(
141+
scheme=self.urlparse.scheme, netloc=self.urlparse.netloc, path="", params="", query="", fragment=""
142+
).geturl()
93143

94144
def get_child_link_list(self, bf: BeautifulSoup):
95145
# Compute the crawl prefix: parent directory when base_fork_url is an HTML file
96146
crawl_prefix = self.base_fork_url
97-
if crawl_prefix.endswith(('.html', '.htm')):
98-
crawl_prefix = crawl_prefix.rsplit('/', 1)[0]
147+
if crawl_prefix.endswith((".html", ".htm")):
148+
crawl_prefix = crawl_prefix.rsplit("/", 1)[0]
99149
pattern = "^((?!(http:|https:|tel:/|#|mailto:|javascript:))|" + crawl_prefix + "|/).*"
100-
link_list = bf.find_all(name='a', href=re.compile(pattern))
101-
result = [ChildLink(link.get('href'), link) if link.get('href').startswith(self.base_url) else ChildLink(
102-
self.base_url + link.get('href'), link) for link in link_list]
150+
link_list = bf.find_all(name="a", href=re.compile(pattern))
151+
result = [
152+
ChildLink(link.get("href"), link)
153+
if link.get("href").startswith(self.base_url)
154+
else ChildLink(self.base_url + link.get("href"), link)
155+
for link in link_list
156+
]
103157
result = [row for row in result if row.url.startswith(crawl_prefix)]
104158
return result
105159

106160
def get_content_html(self, bf: BeautifulSoup):
107161
if self.selector_list is None or len(self.selector_list) == 0:
108162
return str(bf)
109-
params = reduce(lambda x, y: {**x, **y},
110-
[{'class_': selector.replace('.', '')} if selector.startswith('.') else
111-
{'id': selector.replace("#", "")} if selector.startswith("#") else {'name': selector} for
112-
selector in
113-
self.selector_list], {})
163+
params = reduce(
164+
lambda x, y: {**x, **y},
165+
[
166+
{"class_": selector.replace(".", "")}
167+
if selector.startswith(".")
168+
else {"id": selector.replace("#", "")}
169+
if selector.startswith("#")
170+
else {"name": selector}
171+
for selector in self.selector_list
172+
],
173+
{},
174+
)
114175
f = bf.find_all(**params)
115176
return "\n".join([str(row) for row in f])
116177

@@ -119,53 +180,58 @@ def reset_url(tag, field, base_fork_url):
119180
field_value: str = tag[field]
120181
if field_value.startswith("/"):
121182
result = urlparse(base_fork_url)
122-
result_url = ParseResult(scheme=result.scheme, netloc=result.netloc, path=field_value, params='', query='',
123-
fragment='').geturl()
183+
result_url = ParseResult(
184+
scheme=result.scheme, netloc=result.netloc, path=field_value, params="", query="", fragment=""
185+
).geturl()
124186
else:
125187
# When base_fork_url is an HTML file (not a directory), resolve relative
126188
# links against its parent directory to avoid broken paths like
127189
# /en/index.html/about_dolphindb.html
128-
if base_fork_url.endswith(('.html', '.htm')):
129-
base = base_fork_url.rsplit('/', 1)[0] + '/'
190+
if base_fork_url.endswith((".html", ".htm")):
191+
base = base_fork_url.rsplit("/", 1)[0] + "/"
130192
else:
131-
base = base_fork_url + '/'
193+
base = base_fork_url + "/"
132194
result_url = urljoin(base, field_value)
133-
result_url = result_url[:-1] if result_url.endswith('/') else result_url
195+
result_url = result_url[:-1] if result_url.endswith("/") else result_url
134196
tag[field] = result_url
135197

136198
def reset_beautiful_soup(self, bf: BeautifulSoup):
137199
reset_config_list = [
138200
{
139-
'field': 'href',
201+
"field": "href",
140202
},
141203
{
142-
'field': 'src',
143-
}
204+
"field": "src",
205+
},
144206
]
145207
for reset_config in reset_config_list:
146-
field = reset_config.get('field')
147-
tag_list = bf.find_all(**{field: re.compile('^(?!(http:|https:|tel:/|#|mailto:|javascript:)).*')})
208+
field = reset_config.get("field")
209+
tag_list = bf.find_all(**{field: re.compile("^(?!(http:|https:|tel:/|#|mailto:|javascript:)).*")})
148210
for tag in tag_list:
149211
self.reset_url(tag, field, self.base_fork_url)
150212
# 去掉 href 以 # 开头的锚点链接,保留文字
151-
for a in bf.find_all('a', href=re.compile('^#')):
213+
for a in bf.find_all("a", href=re.compile("^#")):
152214
a.unwrap()
153215
return bf
154216

155217
@staticmethod
156218
def get_beautiful_soup(response):
157-
encoding = response.encoding if response.encoding is not None and response.encoding != 'ISO-8859-1' else response.apparent_encoding
219+
encoding = (
220+
response.encoding
221+
if response.encoding is not None and response.encoding != "ISO-8859-1"
222+
else response.apparent_encoding
223+
)
158224
html_content = response.content.decode(encoding)
159225
beautiful_soup = BeautifulSoup(html_content, "html.parser")
160-
meta_list = beautiful_soup.find_all('meta')
226+
meta_list = beautiful_soup.find_all("meta")
161227
charset_list = Fork.get_charset_list(meta_list)
162228
if len(charset_list) > 0:
163229
charset = charset_list[0]
164230
if charset != encoding:
165231
try:
166-
html_content = response.content.decode(charset, errors='replace')
232+
html_content = response.content.decode(charset, errors="replace")
167233
except Exception as e:
168-
maxkb_logger.error(f'{e}: {traceback.format_exc()}')
234+
maxkb_logger.error(f"{e}: {traceback.format_exc()}")
169235
return BeautifulSoup(html_content, "html.parser")
170236
return beautiful_soup
171237

@@ -174,39 +240,50 @@ def get_charset_list(meta_list):
174240
charset_list = []
175241
for meta in meta_list:
176242
if meta.attrs is not None:
177-
if 'charset' in meta.attrs:
178-
charset_list.append(meta.attrs.get('charset'))
179-
elif meta.attrs.get('http-equiv', '').lower() == 'content-type' and 'content' in meta.attrs:
180-
match = re.search(r'charset=([^\s;]+)', meta.attrs['content'], re.I)
243+
if "charset" in meta.attrs:
244+
charset_list.append(meta.attrs.get("charset"))
245+
elif meta.attrs.get("http-equiv", "").lower() == "content-type" and "content" in meta.attrs:
246+
match = re.search(r"charset=([^\s;]+)", meta.attrs["content"], re.I)
181247
if match:
182248
charset_list.append(match.group(1))
183249
return charset_list
184250

185251
def fork(self):
186252
try:
187-
188253
headers = {
189-
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36'
254+
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36"
190255
}
191256

192-
maxkb_logger.info(f'fork:{self.base_fork_url}')
193-
response = requests.get(self.base_fork_url, verify=False, headers=headers)
257+
maxkb_logger.info(f"fork:{self.base_fork_url}")
258+
url = self.base_fork_url
259+
for _ in range(10):
260+
response = requests.get(url, verify=True, headers=headers, allow_redirects=False, timeout=(10, 30))
261+
if response.status_code in (301, 302, 303, 307, 308):
262+
location = response.headers.get("Location")
263+
if not location:
264+
break
265+
location = urljoin(url, location)
266+
_validate_url(location)
267+
url = location
268+
continue
269+
break
194270
if response.status_code != 200:
195271
maxkb_logger.error(f"url: {self.base_fork_url} code:{response.status_code}")
196272
return Fork.Response.error(f"url: {self.base_fork_url} code:{response.status_code}")
197273
bf = self.get_beautiful_soup(response)
198274
except Exception as e:
199-
maxkb_logger.error(f'{str(e)}:{traceback.format_exc()}')
275+
maxkb_logger.error(f"{str(e)}:{traceback.format_exc()}")
200276
return Fork.Response.error(str(e))
201277
bf = self.reset_beautiful_soup(bf)
202278
link_list = self.get_child_link_list(bf)
203279
content = self.get_content_html(bf)
204280

205-
r = markdownify(content, heading_style='ATX')
281+
r = markdownify(content, heading_style="ATX")
206282
return Fork.Response.success(r, link_list)
207283

208284

209285
def handler(base_url, response: Fork.Response):
210286
maxkb_logger.info(base_url.url, base_url.tag.text if base_url.tag else None, response.content)
211287

288+
212289
# ForkManage('https://bbs.fit2cloud.com/c/de/6', ['.md-content']).fork(3, set(), handler)

0 commit comments

Comments
 (0)