Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b367ef2
Add WIP fix for handling urls with unicode characters.
Kami Mar 13, 2021
4e0bb04
Add WIP fix for the client side and make sure urls with unicode
Kami Mar 13, 2021
20e9b81
Revert "Add WIP fix for the client side and make sure urls with unicode"
Kami Mar 13, 2021
cab8b52
Add a fix for CLI which would result in exceptions in various scenarios
Kami Mar 13, 2021
e08f9f1
Move functionality for surrogate re-encoding to a uility function for
Kami Mar 13, 2021
2f06857
Add additional unit tests for the utf-8 bug.
Kami Mar 13, 2021
3ef58c9
Add changelog entry.
Kami Mar 13, 2021
db8e85e
Add an example rule fixture for rule with unicode name which can be used
Kami Mar 13, 2021
f3a92b1
Use a different more unique name to avoid breaking other test with a
Kami Mar 13, 2021
04e58b6
Update API router code to throw a more user-friendly exception in case
Kami Mar 13, 2021
15274ea
Add a test case for it.
Kami Mar 13, 2021
ae42830
fix subprocess handling when killed on timeout
engineer-roman Apr 3, 2021
fde03cc
Merge branch 'master' into url_path_unicode_fix
Kami Apr 5, 2021
c0ed21b
For now, remove test fixture which is causing failure in end to end
Kami Apr 5, 2021
b475c64
Merge branch 'master' into fix_zombie_spawning_green_shell
Kami Apr 5, 2021
c687259
Merge pull request #5189 from StackStorm/url_path_unicode_fix
Kami Apr 5, 2021
d1912e1
Merge branch 'fix_zombie_spawning_green_shell' of https://github.com/…
Kami Apr 6, 2021
a4e3c98
Instead of overriding process.returncode and utilizing that attribute to
Kami Apr 6, 2021
708fa44
Update docstring.
Kami Apr 6, 2021
39e95b4
Add integration tests for st2common.util.green.shell.run_command()
Kami Apr 6, 2021
927fe07
Add TODO comment.
Kami Apr 6, 2021
0770969
Add changelog entry.
Kami Apr 6, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,20 @@ Added
* Added the command line utility `st2-validate-pack` that can be used by pack developers to
validate pack contents. (improvement)

* Fix a bug in the API and CLI code which would prevent users from being able to retrieve resources
which contain non-ascii (utf-8) characters in the names / references. (bug fix) #5189

Contributed by @Kami.

* Fix a bug in the API router code and make sure we return correct and user-friendly error to the
user in case we fail to parse the request URL / path because it contains invalid or incorrectly
URL encoded data.

Previously such errors weren't handled correctly which meant original exception with a stack
trace got propagated to the user. (bug fix) #5189

Contributed by @Kami.

Changed
~~~~~~~

Expand Down Expand Up @@ -257,6 +271,11 @@ Fixed

Contributed by @Kami.

* Make sure ``st2common.util.green.shell.run_command()`` doesn't leave stray / zombie processes
laying around in some command timeout scenarios. #5220

Contributed by @r0m4n-z.

3.4.1 - March 14, 2021
----------------------

Expand Down
59 changes: 59 additions & 0 deletions st2api/tests/unit/controllers/v1/test_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import os
import os.path
import copy
import urllib

try:
import simplejson as json
Expand Down Expand Up @@ -277,6 +278,22 @@
}


ACTION_WITH_UNICODE_NAME = {
"name": "st2.dummy.action_unicode_我爱狗",
"description": "test description",
"enabled": True,
"pack": "dummy_pack_1",
"entry_point": "/tmp/test/action1.sh",
"runner_type": "local-shell-script",
"parameters": {
"a": {"type": "string", "default": "A1"},
"b": {"type": "string", "default": "B1"},
"sudo": {"default": True, "immutable": True},
},
"notify": {"on-complete": {"message": "Woohoo! I completed!!!"}},
}


class ActionsControllerTestCase(
FunctionalTest, APIControllerWithIncludeAndExcludeFilterTestCase, CleanFilesTestCase
):
Expand Down Expand Up @@ -640,6 +657,48 @@ def test_action_with_notify_update(self):
self.assertEqual(get_resp.json["notify"], {})
self.__do_delete(action_id)

@mock.patch.object(
action_validator, "validate_action", mock.MagicMock(return_value=True)
)
def test_action_with_unicode_name_create(self):
post_resp = self.__do_post(ACTION_WITH_UNICODE_NAME)
action_id = self.__get_action_id(post_resp)
get_resp = self.__do_get_one(action_id)
self.assertEqual(get_resp.status_int, 200)
self.assertEqual(self.__get_action_id(get_resp), action_id)
self.assertEqual(get_resp.json["name"], "st2.dummy.action_unicode_我爱狗")
self.assertEqual(
get_resp.json["ref"], "dummy_pack_1.st2.dummy.action_unicode_我爱狗"
)
self.assertEqual(
get_resp.json["uid"], "action:dummy_pack_1:st2.dummy.action_unicode_我爱狗"
)

get_resp = self.__do_get_one("dummy_pack_1.st2.dummy.action_unicode_我爱狗")
self.assertEqual(get_resp.json["name"], "st2.dummy.action_unicode_我爱狗")
self.assertEqual(
get_resp.json["ref"], "dummy_pack_1.st2.dummy.action_unicode_我爱狗"
)
self.assertEqual(
get_resp.json["uid"], "action:dummy_pack_1:st2.dummy.action_unicode_我爱狗"
)

# Now retrieve the action using the ref and ensure it works correctly
# NOTE: We need to use urlquoted value when retrieving the item since that's how all the
# http clients work - non ascii characters get escaped / quoted. Passing in unquoted
# value will result in exception (as expected).
ref_quoted = urllib.parse.quote("dummy_pack_1.st2.dummy.action_unicode_我爱狗")
get_resp = self.__do_get_one(ref_quoted)
self.assertEqual(get_resp.json["name"], "st2.dummy.action_unicode_我爱狗")
self.assertEqual(
get_resp.json["ref"], "dummy_pack_1.st2.dummy.action_unicode_我爱狗"
)
self.assertEqual(
get_resp.json["uid"], "action:dummy_pack_1:st2.dummy.action_unicode_我爱狗"
)

self.__do_delete(action_id)

@mock.patch.object(
action_validator, "validate_action", mock.MagicMock(return_value=True)
)
Expand Down
18 changes: 18 additions & 0 deletions st2api/tests/unit/controllers/v1/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@
# See the License for the specific language governing permissions and
# limitations under the License.

import webob
from oslo_config import cfg
from webob.request import Request

from st2common.router import Router

from st2tests.api import FunctionalTest


Expand Down Expand Up @@ -95,3 +100,16 @@ def test_valid_status_code_is_returned_on_invalid_path(self):
"/v1/executions/577f775b0640fd1451f2030b/re_run/a/b", expect_errors=True
)
self.assertEqual(resp.status_int, 404)

def test_router_invalid_url_path_friendly_error(self):
# NOTE: We intentionally don't use sp.app.get here since that goes through the webtest
# layer which manipulates the path which means we won't be testing what we actually want
# to test (an edge case). To test the edge case correctly, we need to manually call router
# with specifically crafted data.
router = Router()
request = Request(environ={"PATH_INFO": "/v1/rules/好的".encode("utf-8")})

expected_msg = "URL likely contains invalid or incorrectly URL encoded values"
self.assertRaisesRegexp(
webob.exc.HTTPBadRequest, expected_msg, router.match, request
)
37 changes: 37 additions & 0 deletions st2client/st2client/shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
from st2client.config import set_config
from st2client.exceptions.operations import OperationFailureException
from st2client.utils.logging import LogLevelFilter, set_log_level_for_all_loggers
from st2client.utils.misc import reencode_list_with_surrogate_escape_sequences
from st2client.commands.auth import TokenCreateCommand
from st2client.commands.auth import LoginCommand

Expand Down Expand Up @@ -98,6 +99,42 @@

PACKAGE_METADATA_FILE_PATH = "/opt/stackstorm/st2/package.meta"

"""
Here we sanitize the provided args and ensure they contain valid unicode values.

By default, sys.argv will contain a unicode string where the actual item values which contain
unicode sequences are escaped using unicode surrogates.

For example, if "examples.test_rule_utf8_náme" value is specified as a CLI argument, sys.argv
and as such also url, would contain "examples.test_rule_utf8_n%ED%B3%83%ED%B2%A1me" which is not
what we want.

Complete sys.argv example:

1. Default - ['shell.py', '--debug', 'rule', 'get', 'examples.test_rule_utf8_n\udcc3\udca1me']
2. What we want - ['shell.py', '--debug', 'rule', 'get', 'examples.test_rule_utf8_náme']

This won't work correctly when sending requests to the API. As such, we correctly escape the
value to the unicode string here and then let the http layer (requests) correctly url encode
this value.

Technically, we could also just try to re-encode it in the HTTPClient and I tried that first, but
it turns out more code in the client results in exceptions if it's not re-encoded as early as
possible.
"""

REENCODE_ARGV = os.environ.get("ST2_CLI_RENCODE_ARGV", "true").lower() in [
"true",
"1",
"yes",
]

if REENCODE_ARGV:
try:
sys.argv = reencode_list_with_surrogate_escape_sequences(sys.argv)
except Exception as e:
print("Failed to re-encode sys.argv: %s" % (str(e)))


def get_stackstorm_version():
"""
Expand Down
24 changes: 23 additions & 1 deletion st2client/st2client/utils/misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@
# limitations under the License.

from __future__ import absolute_import

from typing import List

import copy

import six

__all__ = ["merge_dicts"]
__all__ = ["merge_dicts", "reencode_list_with_surrogate_escape_sequences"]


def merge_dicts(d1, d2):
Expand All @@ -39,3 +42,22 @@ def merge_dicts(d1, d2):
result[key] = value

return result


def reencode_list_with_surrogate_escape_sequences(value: List[str]) -> List[str]:
"""
Function which reencodes each item in the provided list replacing unicode surrogate escape
sequences using actual unicode values.
"""
result = []

for item in value:
try:
item = item.encode("ascii", "surrogateescape").decode("utf-8")
except UnicodeEncodeError:
# Already a unicode string, nothing to do
pass

result.append(item)

return result
15 changes: 15 additions & 0 deletions st2client/tests/unit/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,21 @@ def test_command_delete_failed(self):
args = self.parser.parse_args(["fakeresource", "delete", "cba"])
self.assertRaises(Exception, self.branch.commands["delete"].run, args)

@mock.patch.object(
models.ResourceManager,
"get_by_id",
mock.MagicMock(return_value=base.FakeResource(**base.RESOURCES[0])),
)
def test_command_get_unicode_primary_key(self):
args = self.parser.parse_args(
["fakeresource", "get", "examples.test_rule_utf8_náme"]
)
self.assertEqual(args.func, self.branch.commands["get"].run_and_print)
instance = self.branch.commands["get"].run(args)
actual = instance.serialize()
expected = json.loads(json.dumps(base.RESOURCES[0]))
self.assertEqual(actual, expected)


class ResourceViewCommandTestCase(unittest2.TestCase):
def setUp(self):
Expand Down
33 changes: 33 additions & 0 deletions st2client/tests/unit/test_shell.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# Copyright 2020 The StackStorm Authors.
# Copyright 2019 Extreme Networks, Inc.
#
Expand Down Expand Up @@ -32,6 +33,7 @@
from st2client.shell import Shell
from st2client.client import Client
from st2client.utils import httpclient
from st2client.utils.misc import reencode_list_with_surrogate_escape_sequences
from st2common.models.db.auth import TokenDB
from tests import base

Expand Down Expand Up @@ -828,3 +830,34 @@ def test_automatic_auth_skipped_if_token_provided_as_cli_argument(self):
args = shell.parser.parse_args(args=argv)
shell.get_client(args=args)
self.assertEqual(shell._get_auth_token.call_count, 0)

def test_get_one_unicode_character_in_name(self):
self._write_mock_config()

shell = Shell()
shell._get_auth_token = mock.Mock()

os.environ["ST2_AUTH_TOKEN"] = "fooo"
argv = ["action", "get", "examples.test_rule_utf8_náme"]
args = shell.parser.parse_args(args=argv)
shell.get_client(args=args)
self.assertEqual(args.ref_or_id, "examples.test_rule_utf8_náme")

def test_reencode_list_replace_surrogate_escape(self):
value = ["a", "b", "c", "d"]
expected = value
result = reencode_list_with_surrogate_escape_sequences(value)

self.assertEqual(result, expected)

value = ["a", "b", "c", "n\udcc3\udca1me"]
expected = ["a", "b", "c", "náme"]
result = reencode_list_with_surrogate_escape_sequences(value)

self.assertEqual(result, expected)

value = ["a", "b", "c", "你好"]
expected = value
result = reencode_list_with_surrogate_escape_sequences(value)

self.assertEqual(result, expected)
12 changes: 12 additions & 0 deletions st2common/st2common/middleware/instrumentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
from st2common.metrics.base import get_driver
from st2common.util.date import get_datetime_utc_now
from st2common.router import NotFoundException
from st2common.router import Response
from st2common.util.jsonify import json_encode

__all__ = ["RequestInstrumentationMiddleware", "ResponseInstrumentationMiddleware"]

Expand Down Expand Up @@ -47,6 +49,16 @@ def __call__(self, environ, start_response):
endpoint, _ = self.router.match(request)
except NotFoundException:
endpoint = {}
except Exception as e:
# Special case to make sure we return friendly error to the user.
# If we don't do that and router.match() throws an exception, we will return stack trace
# to the end user which is not good.
status_code = getattr(e, "status_code", 500)
headers = {"Content-Type": "application/json"}
body = {"faultstring": getattr(e, "detail", str(e))}
response_body = json_encode(body)
resp = Response(response_body, status=status_code, headers=headers)
return resp(environ, start_response)

# NOTE: We don't track per request and response metrics for /v1/executions/<id> and some
# other endpoints because this would result in a lot of unique metrics which is an
Expand Down
28 changes: 26 additions & 2 deletions st2common/st2common/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
from six.moves.urllib import parse as urlparse # pylint: disable=import-error
import webob
from webob import cookies, exc
from webob.compat import url_unquote
from six.moves import urllib

from st2common.exceptions import rbac as rbac_exc
from st2common.exceptions import auth as auth_exc
Expand Down Expand Up @@ -264,7 +264,31 @@ def add_spec(self, spec, transforms):
)

def match(self, req):
path = url_unquote(req.path)
# NOTE: webob.url_unquote doesn't work correctly under Python 3 when paths contain non-ascii
# characters. That method supposed to handle Python 2 and Python 3 compatibility, but it
# doesn't work correctly under Python 3.
try:
path = urllib.parse.unquote(req.path)
except Exception as e:
# This exception being thrown indicates that the URL / path contains bad or incorrectly
# URL escaped characters. Instead of returning this stack track + 500 error to the
# user we return a friendly and more correct exception
# NOTE: We should not access or log req.path here since it's a property which results
# in exception and if we try to log it, it will fail.
try:
path = req.environ["PATH_INFO"]
except Exception:
path = "unknown"

LOG.error('Failed to parse request URL / path "%s": %s' % (path, str(e)))

abort(
400,
'Failed to parse request path "%s". URL likely contains invalid or incorrectly '
"URL encoded values." % (path),
)
return

LOG.debug("Match path: %s", path)

if len(path) > 1 and path.endswith("/"):
Expand Down
Loading