Skip to content

Commit 9b5670c

Browse files
authored
Merge pull request #4757 from StackStorm/http_runner_netloc_blacklist
Add support for blacklisting hosts to the HTTP runner
2 parents f485451 + 25b43cb commit 9b5670c

File tree

4 files changed

+271
-3
lines changed

4 files changed

+271
-3
lines changed

CHANGELOG.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ Changelog
44
in development
55
--------------
66

7+
Added
8+
~~~~~
9+
10+
* Add support for blacklisting / whitelisting hosts to the HTTP runner by adding new
11+
``url_hosts_blacklist`` and ``url_hosts_whitelist`` runner attribute. (new feature)
12+
#4757
13+
714
Changed
815
~~~~~~~
916

contrib/runners/http_runner/http_runner/http_runner.py

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# limitations under the License.
1414

1515
from __future__ import absolute_import
16+
1617
import ast
1718
import copy
1819
import json
@@ -21,6 +22,7 @@
2122
import requests
2223
from requests.auth import HTTPBasicAuth
2324
from oslo_config import cfg
25+
from six.moves.urllib import parse as urlparse # pylint: disable=import-error
2426

2527
from st2common.runners.base import ActionRunner
2628
from st2common.runners.base import get_metadata as get_runner_metadata
@@ -55,6 +57,8 @@
5557
RUNNER_VERIFY_SSL_CERT = 'verify_ssl_cert'
5658
RUNNER_USERNAME = 'username'
5759
RUNNER_PASSWORD = 'password'
60+
RUNNER_URL_HOSTS_BLACKLIST = 'url_hosts_blacklist'
61+
RUNNER_URL_HOSTS_WHITELIST = 'url_hosts_whitelist'
5862

5963
# Lookup constants for action params
6064
ACTION_AUTH = 'auth'
@@ -93,10 +97,17 @@ def pre_run(self):
9397
self._http_proxy = self.runner_parameters.get(RUNNER_HTTP_PROXY, None)
9498
self._https_proxy = self.runner_parameters.get(RUNNER_HTTPS_PROXY, None)
9599
self._verify_ssl_cert = self.runner_parameters.get(RUNNER_VERIFY_SSL_CERT, None)
100+
self._url_hosts_blacklist = self.runner_parameters.get(RUNNER_URL_HOSTS_BLACKLIST, [])
101+
self._url_hosts_whitelist = self.runner_parameters.get(RUNNER_URL_HOSTS_WHITELIST, [])
96102

97103
def run(self, action_parameters):
98104
client = self._get_http_client(action_parameters)
99105

106+
if self._url_hosts_blacklist and self._url_hosts_whitelist:
107+
msg = ('"url_hosts_blacklist" and "url_hosts_whitelist" parameters are mutually '
108+
'exclusive. Only one should be provided.')
109+
raise ValueError(msg)
110+
100111
try:
101112
result = client.run()
102113
except requests.exceptions.Timeout as e:
@@ -147,7 +158,9 @@ def _get_http_client(self, action_parameters):
147158
headers=headers, cookies=self._cookies, auth=auth,
148159
timeout=timeout, allow_redirects=self._allow_redirects,
149160
proxies=proxies, files=files, verify=self._verify_ssl_cert,
150-
username=self._username, password=self._password)
161+
username=self._username, password=self._password,
162+
url_hosts_blacklist=self._url_hosts_blacklist,
163+
url_hosts_whitelist=self._url_hosts_whitelist)
151164

152165
@staticmethod
153166
def _get_result_status(status_code):
@@ -158,7 +171,8 @@ def _get_result_status(status_code):
158171
class HTTPClient(object):
159172
def __init__(self, url=None, method=None, body='', params=None, headers=None, cookies=None,
160173
auth=None, timeout=60, allow_redirects=False, proxies=None,
161-
files=None, verify=False, username=None, password=None):
174+
files=None, verify=False, username=None, password=None,
175+
url_hosts_blacklist=None, url_hosts_whitelist=None):
162176
if url is None:
163177
raise Exception('URL must be specified.')
164178

@@ -188,12 +202,30 @@ def __init__(self, url=None, method=None, body='', params=None, headers=None, co
188202
self.verify = verify
189203
self.username = username
190204
self.password = password
205+
self.url_hosts_blacklist = url_hosts_blacklist or []
206+
self.url_hosts_whitelist = url_hosts_whitelist or []
207+
208+
if self.url_hosts_blacklist and self.url_hosts_whitelist:
209+
msg = ('"url_hosts_blacklist" and "url_hosts_whitelist" parameters are mutually '
210+
'exclusive. Only one should be provided.')
211+
raise ValueError(msg)
191212

192213
def run(self):
193214
results = {}
194215
resp = None
195216
json_content = self._is_json_content()
196217

218+
# Check if the provided URL is blacklisted
219+
is_url_blacklisted = self._is_url_blacklisted(url=self.url)
220+
221+
if is_url_blacklisted:
222+
raise ValueError('URL "%s" is blacklisted' % (self.url))
223+
224+
is_url_whitelisted = self._is_url_whitelisted(url=self.url)
225+
226+
if not is_url_whitelisted:
227+
raise ValueError('URL "%s" is not whitelisted' % (self.url))
228+
197229
try:
198230
if json_content:
199231
# cast params (body) to dict
@@ -301,6 +333,49 @@ def _cast_object(self, value):
301333
else:
302334
return value
303335

336+
def _is_url_blacklisted(self, url):
337+
"""
338+
Verify if the provided URL is blacklisted via url_hosts_blacklist runner parameter.
339+
"""
340+
if not self.url_hosts_blacklist:
341+
# Blacklist is empty
342+
return False
343+
344+
host = self._get_host_from_url(url=url)
345+
346+
if host in self.url_hosts_blacklist:
347+
return True
348+
349+
return False
350+
351+
def _is_url_whitelisted(self, url):
352+
"""
353+
Verify if the provided URL is whitelisted via url_hosts_whitelist runner parameter.
354+
"""
355+
if not self.url_hosts_whitelist:
356+
return True
357+
358+
host = self._get_host_from_url(url=url)
359+
360+
if host in self.url_hosts_whitelist:
361+
return True
362+
363+
return False
364+
365+
def _get_host_from_url(self, url):
366+
"""
367+
Return sanitized host (netloc) value from the provided url.
368+
"""
369+
parsed = urlparse.urlparse(url)
370+
371+
# Remove port and []
372+
host = parsed.netloc.replace('[', '').replace(']', '')
373+
374+
if parsed.port is not None:
375+
host = host.replace(':%s' % (parsed.port), '')
376+
377+
return host
378+
304379

305380
def get_runner():
306381
return HttpRunner(str(uuid.uuid4()))

contrib/runners/http_runner/http_runner/runner.yaml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,22 @@
3636
CA bundle which comes from Mozilla. Verification using a custom CA bundle
3737
is not yet supported. Set to False to skip verification.
3838
type: boolean
39+
url_hosts_blacklist:
40+
description: Optional list of hosts (network locations) to blacklist (e.g. example.com,
41+
127.0.0.1, ::1, etc.). If action will try to access that endpoint, an exception will be
42+
thrown and action will be marked as failed.
43+
required: false
44+
type: array
45+
items:
46+
type: string
47+
url_hosts_whitelist:
48+
description: Optional list of hosts (network locations) to whitelist (e.g. example.com,
49+
127.0.0.1, ::1, etc.). If specified, actions will only be able to hit hosts on this
50+
whitelist.
51+
required: false
52+
type: array
53+
items:
54+
type: string
3955
output_key: body
4056
output_schema:
4157
status_code:

contrib/runners/http_runner/tests/unit/test_http_runner.py

Lines changed: 171 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,23 @@
1515

1616
from __future__ import absolute_import
1717

18+
import re
19+
1820
import six
1921
import mock
2022
import unittest2
2123

24+
from st2common.constants.action import LIVEACTION_STATUS_SUCCEEDED
2225
from http_runner.http_runner import HTTPClient
26+
from http_runner.http_runner import HttpRunner
27+
2328
import st2tests.config as tests_config
2429

30+
__all__ = [
31+
'HTTPClientTestCase',
32+
'HTTPRunnerTestCase'
33+
]
34+
2535

2636
if six.PY2:
2737
EXPECTED_DATA = ''
@@ -33,7 +43,7 @@ class MockResult(object):
3343
close = mock.Mock()
3444

3545

36-
class HTTPRunnerTestCase(unittest2.TestCase):
46+
class HTTPClientTestCase(unittest2.TestCase):
3747
@classmethod
3848
def setUpClass(cls):
3949
tests_config.parse_args()
@@ -212,3 +222,163 @@ def test_http_unicode_body_data(self, mock_requests):
212222
expected_data = body
213223

214224
self.assertEqual(call_kwargs['data'], expected_data)
225+
226+
@mock.patch('http_runner.http_runner.requests')
227+
def test_blacklisted_url_url_hosts_blacklist_runner_parameter(self, mock_requests):
228+
# Black list is empty
229+
self.assertEqual(mock_requests.request.call_count, 0)
230+
231+
url = 'http://www.example.com'
232+
client = HTTPClient(url=url, method='GET')
233+
client.run()
234+
235+
self.assertEqual(mock_requests.request.call_count, 1)
236+
237+
# Blacklist is set
238+
url_hosts_blacklist = [
239+
'example.com',
240+
'127.0.0.1',
241+
'::1',
242+
'2001:0db8:85a3:0000:0000:8a2e:0370:7334'
243+
]
244+
245+
# Blacklisted urls
246+
urls = [
247+
'https://example.com',
248+
'http://example.com',
249+
'http://example.com:81',
250+
'http://example.com:80',
251+
'http://example.com:9000',
252+
'http://[::1]:80/',
253+
'http://[::1]',
254+
'http://[::1]:9000',
255+
'http://[2001:0db8:85a3:0000:0000:8a2e:0370:7334]',
256+
'https://[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:8000'
257+
]
258+
259+
for url in urls:
260+
expected_msg = r'URL "%s" is blacklisted' % (re.escape(url))
261+
client = HTTPClient(url=url, method='GET', url_hosts_blacklist=url_hosts_blacklist)
262+
self.assertRaisesRegexp(ValueError, expected_msg, client.run)
263+
264+
# Non blacklisted URLs
265+
urls = [
266+
'https://example2.com',
267+
'http://example3.com',
268+
'http://example4.com:81'
269+
]
270+
271+
for url in urls:
272+
mock_requests.request.reset_mock()
273+
274+
self.assertEqual(mock_requests.request.call_count, 0)
275+
276+
client = HTTPClient(url=url, method='GET', url_hosts_blacklist=url_hosts_blacklist)
277+
client.run()
278+
279+
self.assertEqual(mock_requests.request.call_count, 1)
280+
281+
@mock.patch('http_runner.http_runner.requests')
282+
def test_whitelisted_url_url_hosts_whitelist_runner_parameter(self, mock_requests):
283+
# Whitelist is empty
284+
self.assertEqual(mock_requests.request.call_count, 0)
285+
286+
url = 'http://www.example.com'
287+
client = HTTPClient(url=url, method='GET')
288+
client.run()
289+
290+
self.assertEqual(mock_requests.request.call_count, 1)
291+
292+
# Whitelist is set
293+
url_hosts_whitelist = [
294+
'example.com',
295+
'127.0.0.1',
296+
'::1',
297+
'2001:0db8:85a3:0000:0000:8a2e:0370:7334'
298+
]
299+
300+
# Non whitelisted urls
301+
urls = [
302+
'https://www.google.com',
303+
'https://www.example2.com',
304+
'http://127.0.0.2'
305+
]
306+
307+
for url in urls:
308+
expected_msg = r'URL "%s" is not whitelisted' % (re.escape(url))
309+
client = HTTPClient(url=url, method='GET', url_hosts_whitelist=url_hosts_whitelist)
310+
self.assertRaisesRegexp(ValueError, expected_msg, client.run)
311+
312+
# Whitelisted URLS
313+
urls = [
314+
'https://example.com',
315+
'http://example.com',
316+
'http://example.com:81',
317+
'http://example.com:80',
318+
'http://example.com:9000',
319+
'http://[::1]:80/',
320+
'http://[::1]',
321+
'http://[::1]:9000',
322+
'http://[2001:0db8:85a3:0000:0000:8a2e:0370:7334]',
323+
'https://[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:8000'
324+
]
325+
326+
for url in urls:
327+
mock_requests.request.reset_mock()
328+
329+
self.assertEqual(mock_requests.request.call_count, 0)
330+
331+
client = HTTPClient(url=url, method='GET', url_hosts_whitelist=url_hosts_whitelist)
332+
client.run()
333+
334+
self.assertEqual(mock_requests.request.call_count, 1)
335+
336+
def test_url_host_blacklist_and_url_host_blacklist_params_are_mutually_exclusive(self):
337+
url = 'http://www.example.com'
338+
339+
expected_msg = (r'"url_hosts_blacklist" and "url_hosts_whitelist" parameters are mutually '
340+
'exclusive.')
341+
self.assertRaisesRegexp(ValueError, expected_msg, HTTPClient, url=url, method='GET',
342+
url_hosts_blacklist=[url], url_hosts_whitelist=[url])
343+
344+
345+
class HTTPRunnerTestCase(unittest2.TestCase):
346+
@mock.patch('http_runner.http_runner.requests')
347+
def test_get_success(self, mock_requests):
348+
mock_result = MockResult()
349+
350+
# Unknown content type, body should be returned raw
351+
mock_result.text = 'foo bar ponies'
352+
mock_result.headers = {'Content-Type': 'text/html'}
353+
mock_result.status_code = 200
354+
355+
mock_requests.request.return_value = mock_result
356+
357+
runner_parameters = {
358+
'url': 'http://www.example.com',
359+
'method': 'GET'
360+
}
361+
runner = HttpRunner('id')
362+
runner.runner_parameters = runner_parameters
363+
runner.pre_run()
364+
365+
status, result, _ = runner.run({})
366+
self.assertEqual(status, LIVEACTION_STATUS_SUCCEEDED)
367+
self.assertEqual(result['body'], 'foo bar ponies')
368+
self.assertEqual(result['status_code'], 200)
369+
self.assertEqual(result['parsed'], False)
370+
371+
def test_url_host_blacklist_and_url_host_blacklist_params_are_mutually_exclusive(self):
372+
runner_parameters = {
373+
'url': 'http://www.example.com',
374+
'method': 'GET',
375+
'url_hosts_blacklist': ['http://127.0.0.1'],
376+
'url_hosts_whitelist': ['http://127.0.0.1'],
377+
}
378+
runner = HttpRunner('id')
379+
runner.runner_parameters = runner_parameters
380+
runner.pre_run()
381+
382+
expected_msg = (r'"url_hosts_blacklist" and "url_hosts_whitelist" parameters are mutually '
383+
'exclusive.')
384+
self.assertRaisesRegexp(ValueError, expected_msg, runner.run, {})

0 commit comments

Comments
 (0)