From 8c4e65a6b5cf14dc0335674cfe8018c1825987e1 Mon Sep 17 00:00:00 2001 From: Tim Burke <tim.burke@gmail.com> Date: Thu, 23 Sep 2021 10:31:42 -0700 Subject: [PATCH] staticweb: Work with prefix-based tempurls Note that there's a bit of a privilege escalation as prefix-based tempurls can now be used to perform listings -- but only on containers with staticweb enabled. Since having staticweb enabled was previously pretty useless unless the container was both public and publicly-listable, I think it's probably fine. This also allows tempurls to be used at the container level, but only for staticweb responses. Change-Id: I7949185fdd3b64b882df01d54a8bc158ce2d7032 --- swift/common/middleware/staticweb.py | 53 ++++- swift/common/middleware/tempurl.py | 219 +++++++++++------- test/functional/test_staticweb.py | 214 +++++++++++++++++ test/functional/test_tempurl.py | 41 ++-- test/unit/common/middleware/test_staticweb.py | 66 ++++++ test/unit/common/middleware/test_tempurl.py | 103 +++++--- 6 files changed, 550 insertions(+), 146 deletions(-) diff --git a/swift/common/middleware/staticweb.py b/swift/common/middleware/staticweb.py index df52bb0ebd..7770af208f 100644 --- a/swift/common/middleware/staticweb.py +++ b/swift/common/middleware/staticweb.py @@ -59,7 +59,8 @@ requests for paths not found. For pseudo paths that have no <index.name>, this middleware can serve HTML file listings if you set the ``X-Container-Meta-Web-Listings: true`` metadata item -on the container. +on the container. Note that the listing must be authorized; you may want a +container ACL like ``X-Container-Read: .r:*,.rlistings``. If listings are enabled, the listings can have a custom style sheet by setting the X-Container-Meta-Web-Listings-CSS header. For instance, setting @@ -68,6 +69,17 @@ the .../listing.css style sheet. If you "view source" in your browser on a listing page, you will see the well defined document structure that can be styled. +Additionally, prefix-based :ref:`tempurl` parameters may be used to authorize +requests instead of making the whole container publicly readable. This gives +clients dynamic discoverability of the objects available within that prefix. + +.. note:: + + ``temp_url_prefix`` values should typically end with a slash (``/``) when + used with StaticWeb. StaticWeb's redirects will not carry over any TempURL + parameters, as they likely indicate that the user created an overly-broad + TempURL. + By default, the listings will be rendered with a label of "Listing of /v1/account/container/path". This can be altered by setting a ``X-Container-Meta-Web-Listings-Label: <label>``. For example, @@ -137,6 +149,7 @@ from swift.common.wsgi import make_env, WSGIContext from swift.common.http import is_success, is_redirection, HTTP_NOT_FOUND from swift.common.swob import Response, HTTPMovedPermanently, HTTPNotFound, \ Request, wsgi_quote, wsgi_to_str, str_to_wsgi +from swift.common.middleware.tempurl import get_temp_url_info from swift.proxy.controllers.base import get_container_info @@ -225,7 +238,7 @@ class _StaticWebContext(WSGIContext): self._dir_type = meta.get('web-directory-type', '').strip() return container_info - def _listing(self, env, start_response, prefix=None): + def _listing(self, env, start_response, prefix=''): """ Sends an HTML object listing to the remote client. @@ -284,7 +297,27 @@ class _StaticWebContext(WSGIContext): if prefix and not listing: resp = HTTPNotFound()(env, self._start_response) return self._error_response(resp, env, start_response) - headers = {'Content-Type': 'text/html; charset=UTF-8'} + + tempurl_qs = tempurl_prefix = '' + if env.get('REMOTE_USER') == '.wsgi.tempurl': + sig, expires, tempurl_prefix, _filename, inline, ip_range = \ + get_temp_url_info(env) + if tempurl_prefix is None: + tempurl_prefix = '' + else: + parts = [ + 'temp_url_prefix=%s' % quote(tempurl_prefix), + 'temp_url_expires=%s' % quote(str(expires)), + 'temp_url_sig=%s' % sig, + ] + if ip_range: + parts.append('temp_url_ip_range=%s' % quote(ip_range)) + if inline: + parts.append('inline') + tempurl_qs = '?' + '&'.join(parts) + + headers = {'Content-Type': 'text/html; charset=UTF-8', + 'X-Backend-Content-Generator': 'staticweb'} body = '<!DOCTYPE html>\n' \ '<html>\n' \ ' <head>\n' \ @@ -309,12 +342,12 @@ class _StaticWebContext(WSGIContext): ' <th class="colsize">Size</th>\n' \ ' <th class="coldate">Date</th>\n' \ ' </tr>\n' % html_escape(label) - if prefix: + if len(prefix) > len(tempurl_prefix): body += ' <tr id="parent" class="item">\n' \ - ' <td class="colname"><a href="../">../</a></td>\n' \ + ' <td class="colname"><a href="../%s">../</a></td>\n' \ ' <td class="colsize"> </td>\n' \ ' <td class="coldate"> </td>\n' \ - ' </tr>\n' + ' </tr>\n' % tempurl_qs for item in listing: if 'subdir' in item: subdir = item['subdir'] if six.PY3 else \ @@ -326,7 +359,7 @@ class _StaticWebContext(WSGIContext): ' <td class="colsize"> </td>\n' \ ' <td class="coldate"> </td>\n' \ ' </tr>\n' % \ - (quote(subdir), html_escape(subdir)) + (quote(subdir) + tempurl_qs, html_escape(subdir)) for item in listing: if 'name' in item: name = item['name'] if six.PY3 else \ @@ -347,7 +380,7 @@ class _StaticWebContext(WSGIContext): ' </tr>\n' % \ (' '.join('type-' + html_escape(t.lower()) for t in content_type.split('/')), - quote(name), html_escape(name), + quote(name) + tempurl_qs, html_escape(name), bytes, last_modified) body += ' </table>\n' \ ' </body>\n' \ @@ -540,8 +573,8 @@ class StaticWeb(object): return self.app(env, start_response) if env['REQUEST_METHOD'] not in ('HEAD', 'GET'): return self.app(env, start_response) - if env.get('REMOTE_USER') and \ - not config_true_value(env.get('HTTP_X_WEB_MODE', 'f')): + if env.get('REMOTE_USER') and env['REMOTE_USER'] != '.wsgi.tempurl' \ + and not config_true_value(env.get('HTTP_X_WEB_MODE', 'f')): return self.app(env, start_response) if not container: return self.app(env, start_response) diff --git a/swift/common/middleware/tempurl.py b/swift/common/middleware/tempurl.py index ffb900d78c..1f575066db 100644 --- a/swift/common/middleware/tempurl.py +++ b/swift/common/middleware/tempurl.py @@ -309,13 +309,15 @@ from six.moves.urllib.parse import urlencode from swift.proxy.controllers.base import get_account_info, get_container_info from swift.common.header_key_dict import HeaderKeyDict +from swift.common.http import is_success from swift.common.digest import get_allowed_digests, \ extract_digest_and_algorithm, DEFAULT_ALLOWED_DIGESTS, get_hmac from swift.common.swob import header_to_environ_key, HTTPUnauthorized, \ HTTPBadRequest, wsgi_to_str from swift.common.utils import split_path, get_valid_utf8_str, \ - streq_const_time, quote, get_logger + streq_const_time, quote, get_logger, close_if_possible from swift.common.registry import register_swift_info, register_sensitive_param +from swift.common.wsgi import WSGIContext DISALLOWED_INCOMING_HEADERS = 'x-object-manifest x-symlink-target' @@ -364,6 +366,55 @@ def get_tempurl_keys_from_metadata(meta): if key.lower() in ('temp-url-key', 'temp-url-key-2')] +def normalize_temp_url_expires(value): + """ + Returns the normalized expiration value as an int + + If not None, the value is converted to an int if possible or 0 + if not, and checked for expiration (returns 0 if expired). + """ + if value is None: + return value + try: + temp_url_expires = int(value) + except ValueError: + try: + temp_url_expires = timegm(strptime( + value, EXPIRES_ISO8601_FORMAT)) + except ValueError: + temp_url_expires = 0 + if temp_url_expires < time(): + temp_url_expires = 0 + return temp_url_expires + + +def get_temp_url_info(env): + """ + Returns the provided temporary URL parameters (sig, expires, prefix, + temp_url_ip_range), if given and syntactically valid. + Either sig, expires or prefix could be None if not provided. + + :param env: The WSGI environment for the request. + :returns: (sig, expires, prefix, filename, inline, + temp_url_ip_range) as described above. + """ + sig = expires = prefix = ip_range = filename = inline = None + qs = parse_qs(env.get('QUERY_STRING', ''), keep_blank_values=True) + if 'temp_url_ip_range' in qs: + ip_range = qs['temp_url_ip_range'][0] + if 'temp_url_sig' in qs: + sig = qs['temp_url_sig'][0] + if 'temp_url_expires' in qs: + expires = qs['temp_url_expires'][0] + if 'temp_url_prefix' in qs: + prefix = qs['temp_url_prefix'][0] + if 'filename' in qs: + filename = qs['filename'][0] + if 'inline' in qs: + inline = True + return (sig, expires, prefix, filename, inline, ip_range) + + def disposition_format(disposition_type, filename): # Content-Disposition in HTTP is defined in # https://tools.ietf.org/html/rfc6266 and references @@ -495,9 +546,10 @@ class TempURL(object): """ if env['REQUEST_METHOD'] == 'OPTIONS': return self.app(env, start_response) - info = self._get_temp_url_info(env) - temp_url_sig, temp_url_expires, temp_url_prefix, filename, \ + info = get_temp_url_info(env) + temp_url_sig, client_temp_url_expires, temp_url_prefix, filename, \ inline_disposition, temp_url_ip_range = info + temp_url_expires = normalize_temp_url_expires(client_temp_url_expires) if temp_url_sig is None and temp_url_expires is None: return self.app(env, start_response) if not temp_url_sig or not temp_url_expires: @@ -511,7 +563,10 @@ class TempURL(object): if hash_algorithm not in self.allowed_digests: return self._invalid(env, start_response) - account, container, obj = self._get_path_parts(env) + account, container, obj = self._get_path_parts( + env, allow_container_root=( + env['REQUEST_METHOD'] in ('GET', 'HEAD') and + temp_url_prefix == "")) if not account: return self._invalid(env, start_response) @@ -577,116 +632,102 @@ class TempURL(object): env['swift.authorize_override'] = True env['REMOTE_USER'] = '.wsgi.tempurl' qs = {'temp_url_sig': temp_url_sig, - 'temp_url_expires': temp_url_expires} + 'temp_url_expires': client_temp_url_expires} if temp_url_prefix is not None: qs['temp_url_prefix'] = temp_url_prefix if filename: qs['filename'] = filename env['QUERY_STRING'] = urlencode(qs) - def _start_response(status, headers, exc_info=None): - headers = self._clean_outgoing_headers(headers) - if env['REQUEST_METHOD'] in ('GET', 'HEAD') and status[0] == '2': - # figure out the right value for content-disposition - # 1) use the value from the query string - # 2) use the value from the object metadata - # 3) use the object name (default) - out_headers = [] - existing_disposition = None - for h, v in headers: - if h.lower() != 'content-disposition': - out_headers.append((h, v)) - else: - existing_disposition = v - if inline_disposition: - if filename: - disposition_value = disposition_format('inline', - filename) - else: - disposition_value = 'inline' - elif filename: - disposition_value = disposition_format('attachment', - filename) - elif existing_disposition: - disposition_value = existing_disposition + ctx = WSGIContext(self.app) + app_iter = ctx._app_call(env) + ctx._response_headers = self._clean_outgoing_headers( + ctx._response_headers) + if env['REQUEST_METHOD'] in ('GET', 'HEAD') and \ + is_success(ctx._get_status_int()): + # figure out the right value for content-disposition + # 1) use the value from the query string + # 2) use the value from the object metadata + # 3) use the object name (default) + out_headers = [] + existing_disposition = None + content_generator = None + for h, v in ctx._response_headers: + if h.lower() == 'x-backend-content-generator': + content_generator = v + + if h.lower() != 'content-disposition': + out_headers.append((h, v)) else: - name = basename(wsgi_to_str(env['PATH_INFO']).rstrip('/')) - disposition_value = disposition_format('attachment', - name) - # this is probably just paranoia, I couldn't actually get a - # newline into existing_disposition - value = disposition_value.replace('\n', '%0A') - out_headers.append(('Content-Disposition', value)) + existing_disposition = v + if content_generator == 'staticweb': + inline_disposition = True + elif obj == "": + # Generally, tempurl requires an object. We carved out an + # exception to allow GETs at the container root for the sake + # of staticweb, but we can't tell whether we'll have a + # staticweb response or not until after we call the app + close_if_possible(app_iter) + return self._invalid(env, start_response) - # include Expires header for better cache-control - out_headers.append(('Expires', strftime( - "%a, %d %b %Y %H:%M:%S GMT", - gmtime(temp_url_expires)))) - headers = out_headers - return start_response(status, headers, exc_info) + if inline_disposition: + if filename: + disposition_value = disposition_format('inline', + filename) + else: + disposition_value = 'inline' + elif filename: + disposition_value = disposition_format('attachment', + filename) + elif existing_disposition: + disposition_value = existing_disposition + else: + name = basename(wsgi_to_str(env['PATH_INFO']).rstrip('/')) + disposition_value = disposition_format('attachment', + name) + # this is probably just paranoia, I couldn't actually get a + # newline into existing_disposition + value = disposition_value.replace('\n', '%0A') + out_headers.append(('Content-Disposition', value)) - return self.app(env, _start_response) + # include Expires header for better cache-control + out_headers.append(('Expires', strftime( + "%a, %d %b %Y %H:%M:%S GMT", + gmtime(temp_url_expires)))) + ctx._response_headers = out_headers + start_response( + ctx._response_status, + ctx._response_headers, + ctx._response_exc_info) + return app_iter - def _get_path_parts(self, env): + def _get_path_parts(self, env, allow_container_root=False): """ Return the account, container and object name for the request, if it's an object request and one of the configured methods; otherwise, None is returned. + If it's a container request and allow_root_container is true, + the object name returned will be the empty string. + :param env: The WSGI environment for the request. + :param allow_container_root: Whether requests to the root of a + container should be allowed. :returns: (Account str, container str, object str) or (None, None, None). """ if env['REQUEST_METHOD'] in self.conf['methods']: try: - ver, acc, cont, obj = split_path(env['PATH_INFO'], 4, 4, True) + ver, acc, cont, obj = split_path( + env['PATH_INFO'], 3 if allow_container_root else 4, + 4, True) except ValueError: return (None, None, None) - if ver == 'v1' and obj.strip('/'): - return (wsgi_to_str(acc), wsgi_to_str(cont), wsgi_to_str(obj)) + if ver == 'v1' and (allow_container_root or obj.strip('/')): + return (wsgi_to_str(acc), wsgi_to_str(cont), + wsgi_to_str(obj) if obj else '') return (None, None, None) - def _get_temp_url_info(self, env): - """ - Returns the provided temporary URL parameters (sig, expires, prefix, - temp_url_ip_range), if given and syntactically valid. - Either sig, expires or prefix could be None if not provided. - If provided, expires is also converted to an int if possible or 0 - if not, and checked for expiration (returns 0 if expired). - - :param env: The WSGI environment for the request. - :returns: (sig, expires, prefix, filename, inline, - temp_url_ip_range) as described above. - """ - temp_url_sig = temp_url_expires = temp_url_prefix = filename =\ - inline = None - temp_url_ip_range = None - qs = parse_qs(env.get('QUERY_STRING', ''), keep_blank_values=True) - if 'temp_url_ip_range' in qs: - temp_url_ip_range = qs['temp_url_ip_range'][0] - if 'temp_url_sig' in qs: - temp_url_sig = qs['temp_url_sig'][0] - if 'temp_url_expires' in qs: - try: - temp_url_expires = int(qs['temp_url_expires'][0]) - except ValueError: - try: - temp_url_expires = timegm(strptime( - qs['temp_url_expires'][0], - EXPIRES_ISO8601_FORMAT)) - except ValueError: - temp_url_expires = 0 - if temp_url_expires < time(): - temp_url_expires = 0 - if 'temp_url_prefix' in qs: - temp_url_prefix = qs['temp_url_prefix'][0] - if 'filename' in qs: - filename = qs['filename'][0] - if 'inline' in qs: - inline = True - return (temp_url_sig, temp_url_expires, temp_url_prefix, filename, - inline, temp_url_ip_range) - def _get_keys(self, env): """ Returns the X-[Account|Container]-Meta-Temp-URL-Key[-2] header values diff --git a/test/functional/test_staticweb.py b/test/functional/test_staticweb.py index 9347156370..b26f98f37f 100644 --- a/test/functional/test_staticweb.py +++ b/test/functional/test_staticweb.py @@ -15,13 +15,17 @@ # limitations under the License. import functools +import hashlib import six +import time from unittest import SkipTest from six.moves.urllib.parse import unquote +from swift.common.middleware import tempurl from swift.common.utils import quote from swift.common.swob import str_to_wsgi import test.functional as tf from test.functional.tests import Utils, Base, Base2, BaseEnv +from test.functional.test_tempurl import tempurl_parms from test.functional.swift_test_client import Account, Connection, \ ResponseError @@ -424,3 +428,213 @@ class TestStaticWebUTF8(Base2, TestStaticWeb): def test_redirect_slash_anon_remap_cont(self): self.skipTest("Can't remap UTF8 containers") + + +class TestStaticWebTempurlEnv(BaseEnv): + static_web_enabled = None # tri-state: None initially, then True/False + tempurl_enabled = None # tri-state: None initially, then True/False + + @classmethod + def setUp(cls): + cls.conn = Connection(tf.config) + cls.conn.authenticate() + + if cls.static_web_enabled is None: + cls.static_web_enabled = 'staticweb' in tf.cluster_info + if not cls.static_web_enabled: + return + + if cls.tempurl_enabled is None: + cls.tempurl_enabled = 'tempurl' in tf.cluster_info + if not cls.tempurl_enabled: + return + + cls.account = Account( + cls.conn, tf.config.get('account', tf.config['username'])) + cls.account.delete_containers() + + cls.container = cls.account.container(Utils.create_name()) + cls.tempurl_key = Utils.create_name() + if not cls.container.create( + hdrs={'X-Container-Meta-Web-Listings': 'true', + 'X-Container-Meta-Temp-URL-Key': cls.tempurl_key}): + raise ResponseError(cls.conn.response) + + objects = ['index', + 'error', + 'listings_css', + 'dir/', + 'dir/obj', + 'dir/subdir/', + 'dir/subdir/obj'] + + cls.objects = {} + for item in sorted(objects): + if '/' in item.rstrip('/'): + parent, _ = item.rstrip('/').rsplit('/', 1) + path = '%s/%s' % (cls.objects[parent + '/'].name, + Utils.create_name()) + else: + path = Utils.create_name() + + if item[-1] == '/': + cls.objects[item] = cls.container.file(path) + cls.objects[item].write(hdrs={ + 'Content-Type': 'application/directory'}) + else: + cls.objects[item] = cls.container.file(path) + cls.objects[item].write(('%s contents' % item).encode('utf8')) + + +class TestStaticWebTempurl(Base): + env = TestStaticWebTempurlEnv + set_up = False + + def setUp(self): + super(TestStaticWebTempurl, self).setUp() + if self.env.static_web_enabled is False: + raise SkipTest("Static Web not enabled") + elif self.env.static_web_enabled is not True: + # just some sanity checking + raise Exception( + "Expected static_web_enabled to be True/False, got %r" % + (self.env.static_web_enabled,)) + + if self.env.tempurl_enabled is False: + raise SkipTest("Temp URL not enabled") + elif self.env.tempurl_enabled is not True: + # just some sanity checking + raise Exception( + "Expected tempurl_enabled to be True/False, got %r" % + (self.env.tempurl_enabled,)) + + self.whole_container_parms = dict(tempurl_parms( + 'GET', int(time.time() + 60), + 'prefix:%s' % self.env.conn.make_path( + self.env.container.path + ['']), + self.env.tempurl_key, hashlib.sha256, + ), temp_url_prefix='') + + def link(self, virtual_name, parms=None): + name = self.env.objects[virtual_name].name.rsplit('/', 1)[-1] + if parms is None: + parms = self.whole_container_parms + return ( + '<a href="%s?temp_url_prefix=%s&temp_url_expires=%s&' + 'temp_url_sig=%s">%s</a>' % ( + name, + parms['temp_url_prefix'], + quote(str(parms['temp_url_expires'])), + parms['temp_url_sig'], + name)) + + def test_unauthed(self): + status = self.env.conn.make_request( + 'GET', self.env.container.path, cfg={'no_auth_token': True}) + self.assertEqual(status, 401) + + def test_staticweb_off(self): + self.env.container.update_metadata( + {'X-Remove-Container-Meta-Web-Listings': 'true'}) + status = self.env.conn.make_request( + 'GET', self.env.container.path, parms=self.whole_container_parms, + cfg={'no_auth_token': True}) + self.assertEqual(status, 401, self.env.conn.response.read()) + + status = self.env.conn.make_request( + 'GET', self.env.container.path + [''], + parms=self.whole_container_parms, + cfg={'no_auth_token': True}) + self.assertEqual(status, 401) + + status = self.env.conn.make_request( + 'GET', + self.env.container.path + [self.env.objects['dir/'].name, ''], + parms=self.whole_container_parms, + cfg={'no_auth_token': True}) + self.assertEqual(status, 404) + + def test_get_root(self): + status = self.env.conn.make_request( + 'GET', self.env.container.path, parms=self.whole_container_parms, + cfg={'no_auth_token': True}) + self.assertEqual(status, 301) + + status = self.env.conn.make_request( + 'GET', self.env.container.path + [''], + parms=self.whole_container_parms, + cfg={'no_auth_token': True}) + self.assertEqual(status, 200) + body = self.env.conn.response.read() + if not six.PY2: + body = body.decode('utf-8') + self.assertIn('Listing of /v1/', body) + self.assertNotIn('href="..', body) + self.assertIn(self.link('dir/'), body) + + def test_get_dir(self): + status = self.env.conn.make_request( + 'GET', + self.env.container.path + [self.env.objects['dir/'].name, ''], + parms=self.whole_container_parms, + cfg={'no_auth_token': True}) + self.assertEqual(status, 200) + body = self.env.conn.response.read() + if not six.PY2: + body = body.decode('utf-8') + self.assertIn('Listing of /v1/', body) + self.assertIn('href="..', body) + self.assertIn(self.link('dir/obj'), body) + self.assertIn(self.link('dir/subdir/'), body) + + def test_get_dir_with_iso_expiry(self): + iso_expiry = time.strftime( + tempurl.EXPIRES_ISO8601_FORMAT, + time.gmtime(int(self.whole_container_parms['temp_url_expires']))) + iso_parms = dict(self.whole_container_parms, + temp_url_expires=iso_expiry) + status = self.env.conn.make_request( + 'GET', + self.env.container.path + [self.env.objects['dir/'].name, ''], + parms=iso_parms, + cfg={'no_auth_token': True}) + self.assertEqual(status, 200) + body = self.env.conn.response.read() + if not six.PY2: + body = body.decode('utf-8') + self.assertIn('Listing of /v1/', body) + self.assertIn('href="..', body) + self.assertIn(self.link('dir/obj', iso_parms), body) + self.assertIn(self.link('dir/subdir/', iso_parms), body) + + def test_get_limited_dir(self): + parms = dict(tempurl_parms( + 'GET', int(time.time() + 60), + 'prefix:%s' % self.env.conn.make_path( + self.env.container.path + [self.env.objects['dir/'].name, '']), + self.env.tempurl_key, hashlib.sha256, + ), temp_url_prefix=self.env.objects['dir/'].name + '/') + status = self.env.conn.make_request( + 'GET', + self.env.container.path + [self.env.objects['dir/'].name, ''], + parms=parms, cfg={'no_auth_token': True}) + self.assertEqual(status, 200) + body = self.env.conn.response.read() + if not six.PY2: + body = body.decode('utf-8') + self.assertIn('Listing of /v1/', body) + self.assertNotIn('href="..', body) + self.assertIn(self.link('dir/obj', parms), body) + self.assertIn(self.link('dir/subdir/', parms), body) + + status = self.env.conn.make_request( + 'GET', self.env.container.path + [ + self.env.objects['dir/subdir/'].name, ''], + parms=parms, cfg={'no_auth_token': True}) + self.assertEqual(status, 200) + body = self.env.conn.response.read() + if not six.PY2: + body = body.decode('utf-8') + self.assertIn('Listing of /v1/', body) + self.assertIn('href="..', body) + self.assertIn(self.link('dir/subdir/obj', parms), body) diff --git a/test/functional/test_tempurl.py b/test/functional/test_tempurl.py index a7b4e84539..5ef3ae6b1d 100644 --- a/test/functional/test_tempurl.py +++ b/test/functional/test_tempurl.py @@ -34,6 +34,19 @@ from test.functional.swift_test_client import Account, Connection, \ ResponseError +def tempurl_parms(method, expires, path, key, digest=None): + path = urllib.parse.unquote(path) + if not six.PY2: + method = method.encode('utf8') + path = path.encode('utf8') + key = key.encode('utf8') + sig = hmac.new( + key, + b'%s\n%d\n%s' % (method, expires, path), + digest or hashlib.sha256).hexdigest() + return {'temp_url_sig': sig, 'temp_url_expires': str(expires)} + + def setUpModule(): tf.setup_package() @@ -119,16 +132,7 @@ class TestTempurl(Base): self.env.tempurl_key) def tempurl_parms(self, method, expires, path, key): - path = urllib.parse.unquote(path) - if not six.PY2: - method = method.encode('utf8') - path = path.encode('utf8') - key = key.encode('utf8') - sig = hmac.new( - key, - b'%s\n%d\n%s' % (method, expires, path), - self.digest).hexdigest() - return {'temp_url_sig': sig, 'temp_url_expires': str(expires)} + return tempurl_parms(method, expires, path, key, self.digest) def test_GET(self): for e in (str(self.expires), self.expires_8601): @@ -350,17 +354,12 @@ class TestTempurlPrefix(TestTempurl): else: prefix = path_parts[4][:4] prefix_to_hash = '/'.join(path_parts[0:4]) + '/' + prefix - if not six.PY2: - method = method.encode('utf8') - prefix_to_hash = prefix_to_hash.encode('utf8') - key = key.encode('utf8') - sig = hmac.new( - key, - b'%s\n%d\nprefix:%s' % (method, expires, prefix_to_hash), - self.digest).hexdigest() - return { - 'temp_url_sig': sig, 'temp_url_expires': str(expires), - 'temp_url_prefix': prefix} + parms = tempurl_parms( + method, expires, + 'prefix:' + prefix_to_hash, + key, self.digest) + parms['temp_url_prefix'] = prefix + return parms def test_empty_prefix(self): parms = self.tempurl_parms( diff --git a/test/unit/common/middleware/test_staticweb.py b/test/unit/common/middleware/test_staticweb.py index 02547f4d4f..70fa214f6d 100644 --- a/test/unit/common/middleware/test_staticweb.py +++ b/test/unit/common/middleware/test_staticweb.py @@ -539,6 +539,7 @@ class TestStaticWeb(unittest.TestCase): resp = Request.blank('/v1/a/c3/').get_response(self.test_staticweb) self.assertEqual(resp.status_int, 200) self.assertIn(b'Test main index.html file.', resp.body) + self.assertNotIn('X-Backend-Content-Generator', resp.headers) def test_container3subsubdir(self): resp = Request.blank( @@ -591,21 +592,86 @@ class TestStaticWeb(unittest.TestCase): self.assertEqual(resp.status_int, 200) self.assertIn(b'Listing of /v1/a/c4/', resp.body) self.assertIn(b'href="listing.css"', resp.body) + self.assertIn('X-Backend-Content-Generator', resp.headers) + self.assertEqual(resp.headers['X-Backend-Content-Generator'], + 'staticweb') def test_container4indexhtmlauthed(self): + # anonymous access gets staticweb resp = Request.blank('/v1/a/c4').get_response(self.test_staticweb) self.assertEqual(resp.status_int, 301) + + # authed access doesn't (by default) resp = Request.blank( '/v1/a/c4', environ={'REMOTE_USER': 'authed'}).get_response( self.test_staticweb) self.assertEqual(resp.status_int, 200) + + # it can opt-in, though! resp = Request.blank( '/v1/a/c4', headers={'x-web-mode': 't'}, environ={'REMOTE_USER': 'authed'}).get_response( self.test_staticweb) self.assertEqual(resp.status_int, 301) + # and there's an exclusion for authed-via-tempurl + resp = Request.blank( + '/v1/a/c4', + environ={'REMOTE_USER': '.wsgi.tempurl'}).get_response( + self.test_staticweb) + self.assertEqual(resp.status_int, 301) + + def test_container4tempurl(self): + parts = [ + 'temp_url_prefix=subdir/', + 'temp_url_sig=the-sig', + 'temp_url_expires=2024-12-31T00:00:00' + ] + + resp = Request.blank( + '/v1/a/c4/subdir/?' + '&'.join(parts), + environ={'REMOTE_USER': '.wsgi.tempurl'}, + ).get_response(self.test_staticweb) + self.assertEqual(resp.status_int, 200) + self.assertIn(b'Listing of /v1/a/c4/subdir/', resp.body) + self.assertIn(b'<a href="2.txt?temp_url_prefix=subdir/&' + b'temp_url_expires=2024-12-31T00%3A00%3A00&' + b'temp_url_sig=the-sig">2.txt</a>', resp.body) + + parts.append('temp_url_ip_range=127.0.0.1') + resp = Request.blank( + '/v1/a/c4/subdir/?' + '&'.join(parts), + environ={'REMOTE_USER': '.wsgi.tempurl'}, + ).get_response(self.test_staticweb) + self.assertEqual(resp.status_int, 200) + self.assertIn(b'Listing of /v1/a/c4/subdir/', resp.body) + self.assertIn(b'<a href="2.txt?temp_url_prefix=subdir/&' + b'temp_url_expires=2024-12-31T00%3A00%3A00&' + b'temp_url_sig=the-sig&temp_url_ip_range=' + b'127.0.0.1">2.txt</a>', resp.body) + + parts.append('inline') + resp = Request.blank( + '/v1/a/c4/subdir/?' + '&'.join(parts), + environ={'REMOTE_USER': '.wsgi.tempurl'}, + ).get_response(self.test_staticweb) + self.assertEqual(resp.status_int, 200) + self.assertIn(b'Listing of /v1/a/c4/subdir/', resp.body) + self.assertIn(b'<a href="2.txt?temp_url_prefix=subdir/&' + b'temp_url_expires=2024-12-31T00%3A00%3A00&' + b'temp_url_sig=the-sig&temp_url_ip_range=' + b'127.0.0.1&inline">2.txt</a>', resp.body) + + # no prefix => you get normal links (which will almost certainly 401) + resp = Request.blank( + '/v1/a/c4/subdir/?' + '&'.join(parts[1:]), + environ={'REMOTE_USER': '.wsgi.tempurl'}, + ).get_response(self.test_staticweb) + self.assertEqual(resp.status_int, 200) + self.assertIn(b'Listing of /v1/a/c4/subdir/', resp.body) + self.assertIn(b'<a href="2.txt">2.txt</a>', resp.body) + def test_container4unknown(self): resp = Request.blank( '/v1/a/c4/unknown').get_response(self.test_staticweb) diff --git a/test/unit/common/middleware/test_tempurl.py b/test/unit/common/middleware/test_tempurl.py index 04ea845934..6739a97837 100644 --- a/test/unit/common/middleware/test_tempurl.py +++ b/test/unit/common/middleware/test_tempurl.py @@ -369,6 +369,30 @@ class TestTempURL(unittest.TestCase): sig = hmac.new(key, hmac_body, hashlib.sha256).hexdigest() self.assert_valid_sig(expires, query_path, [key], sig, prefix=prefix) + def test_get_valid_with_prefix_and_staticweb(self): + method = 'GET' + expires = int(time() + 86400) + prefix = 'p1/p2/' + sig_path = 'prefix:/v1/a/c/' + prefix + query_path = '/v1/a/c/' + prefix + 'o' + key = b'abc' + hmac_body = ('%s\n%i\n%s' % + (method, expires, sig_path)).encode('utf-8') + sig = hmac.new(key, hmac_body, hashlib.sha512).hexdigest() + req = self._make_request(query_path, keys=[key], environ={ + 'QUERY_STRING': 'temp_url_sig=%s&temp_url_expires=%s&' + 'temp_url_prefix=%s' % (sig, expires, prefix)}) + self.tempurl.app = FakeApp(iter([('200 Ok', { + 'X-Backend-Content-Generator': 'staticweb'}, b'123')])) + resp = req.get_response(self.tempurl) + self.assertEqual(resp.status_int, 200) + # This is the key thing: if the response came from staticweb, assume + # the client is a browser and doesn't want a download prompt + self.assertEqual(resp.headers['content-disposition'], 'inline') + self.assertIn('expires', resp.headers) + self.assertEqual(req.environ['swift.authorize_override'], True) + self.assertEqual(req.environ['REMOTE_USER'], '.wsgi.tempurl') + def test_get_valid_with_prefix_empty(self): method = 'GET' expires = int(time() + 86400) @@ -1198,6 +1222,16 @@ class TestTempURL(unittest.TestCase): self.assertEqual(self.tempurl._get_path_parts({ 'REQUEST_METHOD': 'GET', 'PATH_INFO': '/v1/a/c/'}), (None, None, None)) + self.assertEqual( + self.tempurl._get_path_parts( + {'REQUEST_METHOD': 'GET', 'PATH_INFO': '/v1/a/c/'}, + allow_container_root=True), + ('a', 'c', '')) + self.assertEqual( + self.tempurl._get_path_parts( + {'REQUEST_METHOD': 'GET', 'PATH_INFO': '/v1/a/c/'}, + allow_container_root=False), + (None, None, None)) self.assertEqual(self.tempurl._get_path_parts({ 'REQUEST_METHOD': 'GET', 'PATH_INFO': '/v1/a/c//////'}), (None, None, None)) @@ -1224,71 +1258,88 @@ class TestTempURL(unittest.TestCase): s = 'f5d5051bddf5df7e27c628818738334f' e_ts = int(time() + 86400) e_8601 = strftime(tempurl.EXPIRES_ISO8601_FORMAT, gmtime(e_ts)) - for e in (e_ts, e_8601): + for e in (str(e_ts), e_8601): self.assertEqual( - self.tempurl._get_temp_url_info( + tempurl.get_temp_url_info( {'QUERY_STRING': 'temp_url_sig=%s&temp_url_expires=%s' % ( s, e)}), - (s, e_ts, None, None, None, None)) + (s, e, None, None, None, None)) self.assertEqual( - self.tempurl._get_temp_url_info( + tempurl.get_temp_url_info( {'QUERY_STRING': 'temp_url_sig=%s&temp_url_expires=%s&temp_url_prefix=%s' % (s, e, 'prefix')}), - (s, e_ts, 'prefix', None, None, None)) + (s, e, 'prefix', None, None, None)) self.assertEqual( - self.tempurl._get_temp_url_info( + tempurl.get_temp_url_info( {'QUERY_STRING': 'temp_url_sig=%s&temp_url_expires=%s&' 'filename=bobisyouruncle' % (s, e)}), - (s, e_ts, None, 'bobisyouruncle', None, None)) + (s, e, None, 'bobisyouruncle', None, None)) self.assertEqual( - self.tempurl._get_temp_url_info({}), + tempurl.get_temp_url_info({}), (None, None, None, None, None, None)) self.assertEqual( - self.tempurl._get_temp_url_info( + tempurl.get_temp_url_info( {'QUERY_STRING': 'temp_url_expires=%s' % e}), - (None, e_ts, None, None, None, None)) + (None, e, None, None, None, None)) self.assertEqual( - self.tempurl._get_temp_url_info( + tempurl.get_temp_url_info( {'QUERY_STRING': 'temp_url_sig=%s' % s}), (s, None, None, None, None, None)) self.assertEqual( - self.tempurl._get_temp_url_info( + tempurl.get_temp_url_info( {'QUERY_STRING': 'temp_url_sig=%s&temp_url_expires=bad' % ( s)}), - (s, 0, None, None, None, None)) + (s, 'bad', None, None, None, None)) self.assertEqual( - self.tempurl._get_temp_url_info( + tempurl.get_temp_url_info( {'QUERY_STRING': 'temp_url_sig=%s&temp_url_expires=%s&' 'inline=' % (s, e)}), - (s, e_ts, None, None, True, None)) + (s, e, None, None, True, None)) self.assertEqual( - self.tempurl._get_temp_url_info( + tempurl.get_temp_url_info( {'QUERY_STRING': 'temp_url_sig=%s&temp_url_expires=%s&' 'filename=bobisyouruncle&inline=' % (s, e)}), - (s, e_ts, None, 'bobisyouruncle', True, None)) + (s, e, None, 'bobisyouruncle', True, None)) self.assertEqual( - self.tempurl._get_temp_url_info( + tempurl.get_temp_url_info( {'QUERY_STRING': 'temp_url_sig=%s&temp_url_expires=%s&' 'filename=bobisyouruncle&inline=' '&temp_url_ip_range=127.0.0.1' % (s, e)}), - (s, e_ts, None, 'bobisyouruncle', True, '127.0.0.1')) + (s, e, None, 'bobisyouruncle', True, '127.0.0.1')) e_ts = int(time() - 1) e_8601 = strftime(tempurl.EXPIRES_ISO8601_FORMAT, gmtime(e_ts)) - for e in (e_ts, e_8601): + for e in (str(e_ts), e_8601): self.assertEqual( - self.tempurl._get_temp_url_info( + tempurl.get_temp_url_info( {'QUERY_STRING': 'temp_url_sig=%s&temp_url_expires=%s' % ( s, e)}), - (s, 0, None, None, None, None)) - # Offsets not supported (yet?). + (s, e, None, None, None, None)) e_8601 = strftime('%Y-%m-%dT%H:%M:%S+0000', gmtime(e_ts)) self.assertEqual( - self.tempurl._get_temp_url_info( + tempurl.get_temp_url_info( {'QUERY_STRING': 'temp_url_sig=%s&temp_url_expires=%s' % ( - s, e_8601)}), - (s, 0, None, None, None, None)) + s, e_8601.replace('+', '%2B'))}), + (s, e_8601, None, None, None, None)) + + def test_normalize_temp_url_expires(self): + e_ts = int(time() + 86400) + self.assertEqual(e_ts, tempurl.normalize_temp_url_expires(e_ts)) + self.assertEqual(e_ts, tempurl.normalize_temp_url_expires(str(e_ts))) + + e_8601 = strftime(tempurl.EXPIRES_ISO8601_FORMAT, gmtime(e_ts)) + self.assertEqual(e_ts, tempurl.normalize_temp_url_expires(e_8601)) + # Offsets not supported (yet?). + e_8601 = strftime('%Y-%m-%dT%H:%M:%S+0000', gmtime(e_ts)) + self.assertEqual(0, tempurl.normalize_temp_url_expires(e_8601)) + + self.assertEqual(None, tempurl.normalize_temp_url_expires(None)) + self.assertEqual(0, tempurl.normalize_temp_url_expires('bad')) + e_ts = int(time() - 1) + self.assertEqual(0, tempurl.normalize_temp_url_expires(e_ts)) + e_8601 = strftime(tempurl.EXPIRES_ISO8601_FORMAT, gmtime(e_ts)) + self.assertEqual(0, tempurl.normalize_temp_url_expires(e_8601)) def test_get_hmacs(self): self.assertEqual(