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 = '?' + '&amp;'.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">&nbsp;</td>\n' \
                     '    <td class="coldate">&nbsp;</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">&nbsp;</td>\n' \
                         '    <td class="coldate">&nbsp;</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&amp;temp_url_expires=%s&amp;'
+            '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/&amp;'
+                      b'temp_url_expires=2024-12-31T00%3A00%3A00&amp;'
+                      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/&amp;'
+                      b'temp_url_expires=2024-12-31T00%3A00%3A00&amp;'
+                      b'temp_url_sig=the-sig&amp;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/&amp;'
+                      b'temp_url_expires=2024-12-31T00%3A00%3A00&amp;'
+                      b'temp_url_sig=the-sig&amp;temp_url_ip_range='
+                      b'127.0.0.1&amp;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(