← Back to team overview

launchpad-reviewers team mailing list archive

lp:~stevenk/launchpad/handle-invalid-unicode-in-query-string-redux into lp:launchpad

 

Steve Kowalik has proposed merging lp:~stevenk/launchpad/handle-invalid-unicode-in-query-string-redux into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #897053 in Launchpad itself: "apport generated urls can trigger UnicodeDecodeError"
  https://bugs.launchpad.net/launchpad/+bug/897053
  Bug #1113932 in Launchpad itself: "Files uploaded through lazr.restful get decoded as Unicode"
  https://bugs.launchpad.net/launchpad/+bug/1113932

For more details, see:
https://code.launchpad.net/~stevenk/launchpad/handle-invalid-unicode-in-query-string-redux/+merge/146773

This branch resurrects the changes from r1646[12] which were reverted due to discovering that lazr.restfulclient does not set filename on file uploads, so Zope will encode the contents, resulting in garbage being stored.

We work around this by grabbing the current request in the two exported methods (IProductRelease.add_file, and IBug.addAttachment) and reaching into the body and pulling out the un-encoded file contents and using that instead.

I have also ripped out the export of get_current_browser_request in lp.services.webapp.publisher, and fixed all imports that were expecting it there to pull from lazr.restful.utils, as well as some whitespace cleanups.
-- 
https://code.launchpad.net/~stevenk/launchpad/handle-invalid-unicode-in-query-string-redux/+merge/146773
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~stevenk/launchpad/handle-invalid-unicode-in-query-string-redux into lp:launchpad.
=== modified file 'lib/lp/app/browser/tales.py'
--- lib/lp/app/browser/tales.py	2013-01-30 05:31:20 +0000
+++ lib/lp/app/browser/tales.py	2013-02-06 04:43:28 +0000
@@ -19,6 +19,7 @@
 import urllib
 
 from lazr.enum import enumerated_type_registry
+from lazr.restful.utils import get_current_browser_request
 from lazr.uri import URI
 import pytz
 from z3c.ptcompat import ViewPageTemplateFile
@@ -94,7 +95,6 @@
     get_facet,
     )
 from lp.services.webapp.publisher import (
-    get_current_browser_request,
     LaunchpadView,
     nearest,
     )

=== modified file 'lib/lp/bugs/interfaces/bug.py'
--- lib/lp/bugs/interfaces/bug.py	2013-01-07 02:40:55 +0000
+++ lib/lp/bugs/interfaces/bug.py	2013-02-06 04:43:28 +0000
@@ -685,14 +685,14 @@
 class IBugEdit(Interface):
     """IBug attributes that require launchpad.Edit permission."""
 
-    @call_with(owner=REQUEST_USER)
+    @call_with(owner=REQUEST_USER, from_api=True)
     @operation_parameters(
         data=Bytes(constraint=attachment_size_constraint),
         comment=Text(), filename=TextLine(), is_patch=Bool(),
         content_type=TextLine(), description=Text())
     @export_factory_operation(IBugAttachment, [])
     def addAttachment(owner, data, comment, filename, is_patch=False,
-                      content_type=None, description=None):
+                      content_type=None, description=None, from_api=False):
         """Attach a file to this bug.
 
         :owner: An IPerson.

=== modified file 'lib/lp/bugs/model/bug.py'
--- lib/lp/bugs/model/bug.py	2012-12-20 14:55:13 +0000
+++ lib/lp/bugs/model/bug.py	2013-02-06 04:43:28 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2012 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Launchpad bug-related database table classes."""
@@ -226,6 +226,7 @@
     )
 from lp.services.webapp.authorization import check_permission
 from lp.services.webapp.interfaces import ILaunchBag
+from lp.services.webapp.publisher import get_raw_form_value_from_current_request
 
 
 def snapshot_bug_params(bug_params):
@@ -1250,8 +1251,10 @@
         bug_watch.destroySelf()
 
     def addAttachment(self, owner, data, comment, filename, is_patch=False,
-                      content_type=None, description=None):
+                      content_type=None, description=None, from_api=False):
         """See `IBug`."""
+        if from_api:
+            data = get_raw_form_value_from_current_request('data')
         if isinstance(data, str):
             filecontent = data
         else:

=== modified file 'lib/lp/registry/interfaces/productrelease.py'
--- lib/lp/registry/interfaces/productrelease.py	2013-01-25 05:25:34 +0000
+++ lib/lp/registry/interfaces/productrelease.py	2013-02-06 04:43:28 +0000
@@ -224,7 +224,7 @@
 class IProductReleaseEditRestricted(Interface):
     """`IProductRelease` properties which require `launchpad.Edit`."""
 
-    @call_with(uploader=REQUEST_USER)
+    @call_with(uploader=REQUEST_USER, from_api=True)
     @operation_parameters(
         filename=TextLine(),
         signature_filename=TextLine(),
@@ -232,16 +232,14 @@
         file_content=Bytes(constraint=productrelease_file_size_constraint),
         signature_content=Bytes(
             constraint=productrelease_signature_size_constraint),
-        file_type=copy_field(IProductReleaseFile['filetype'], required=False)
-        )
-    @export_factory_operation(
-        IProductReleaseFile, ['description'])
+        file_type=copy_field(IProductReleaseFile['filetype'], required=False))
+    @export_factory_operation(IProductReleaseFile, ['description'])
     @export_operation_as('add_file')
     def addReleaseFile(filename, file_content, content_type,
                        uploader, signature_filename=None,
                        signature_content=None,
                        file_type=UpstreamFileType.CODETARBALL,
-                       description=None):
+                       description=None, from_api=False):
         """Add file to the library and link to this `IProductRelease`.
 
         The signature file will also be added if available.

=== modified file 'lib/lp/registry/model/productrelease.py'
--- lib/lp/registry/model/productrelease.py	2013-01-25 05:25:34 +0000
+++ lib/lp/registry/model/productrelease.py	2013-02-06 04:43:28 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2012 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -56,6 +56,7 @@
     )
 from lp.services.librarian.interfaces import ILibraryFileAliasSet
 from lp.services.propertycache import cachedproperty
+from lp.services.webapp.publisher import get_raw_form_value_from_current_request
 
 
 class ProductRelease(SQLBase):
@@ -148,7 +149,7 @@
                        uploader, signature_filename=None,
                        signature_content=None,
                        file_type=UpstreamFileType.CODETARBALL,
-                       description=None):
+                       description=None, from_api=False):
         """See `IProductRelease`."""
         if not self.can_have_release_files:
             raise ProprietaryProduct(
@@ -157,31 +158,26 @@
             raise InvalidFilename
         # Create the alias for the file.
         filename = self.normalizeFilename(filename)
+        if from_api:
+            file_content = StringIO(get_raw_form_value_from_current_request(
+                'file_content'))
         file_obj, file_size = self._getFileObjectAndSize(file_content)
 
         alias = getUtility(ILibraryFileAliasSet).create(
-            name=filename,
-            size=file_size,
-            file=file_obj,
+            name=filename, size=file_size, file=file_obj,
             contentType=content_type)
         if signature_filename is not None and signature_content is not None:
             signature_obj, signature_size = self._getFileObjectAndSize(
                 signature_content)
-            signature_filename = self.normalizeFilename(
-                signature_filename)
+            signature_filename = self.normalizeFilename(signature_filename)
             signature_alias = getUtility(ILibraryFileAliasSet).create(
-                name=signature_filename,
-                size=signature_size,
-                file=signature_obj,
-                contentType='application/pgp-signature')
+                name=signature_filename, size=signature_size,
+                file=signature_obj, contentType='application/pgp-signature')
         else:
             signature_alias = None
-        return ProductReleaseFile(productrelease=self,
-                                  libraryfile=alias,
-                                  signature=signature_alias,
-                                  filetype=file_type,
-                                  description=description,
-                                  uploader=uploader)
+        return ProductReleaseFile(
+            productrelease=self, libraryfile=alias, signature=signature_alias,
+            filetype=file_type, description=description, uploader=uploader)
 
     def getFileAliasByName(self, name):
         """See `IProductRelease`."""

=== modified file 'lib/lp/registry/subscribers.py'
--- lib/lp/registry/subscribers.py	2012-11-26 08:40:20 +0000
+++ lib/lp/registry/subscribers.py	2013-02-06 04:43:28 +0000
@@ -11,6 +11,7 @@
 from datetime import datetime
 import textwrap
 
+from lazr.restful.utils import get_current_browser_request
 import pytz
 from zope.security.proxy import removeSecurityProxy
 
@@ -23,10 +24,7 @@
     simple_sendmail,
     )
 from lp.services.webapp.escaping import structured
-from lp.services.webapp.publisher import (
-    canonical_url,
-    get_current_browser_request,
-    )
+from lp.services.webapp.publisher import canonical_url
 
 
 def product_licenses_modified(product, event):

=== modified file 'lib/lp/registry/tests/test_subscribers.py'
--- lib/lp/registry/tests/test_subscribers.py	2012-12-10 13:43:47 +0000
+++ lib/lp/registry/tests/test_subscribers.py	2013-02-06 04:43:28 +0000
@@ -7,6 +7,7 @@
 
 from datetime import datetime
 
+from lazr.restful.utils import get_current_browser_request
 import pytz
 from zope.component import getUtility
 from zope.security.proxy import removeSecurityProxy
@@ -23,7 +24,6 @@
     product_licenses_modified,
     )
 from lp.services.webapp.escaping import html_escape
-from lp.services.webapp.publisher import get_current_browser_request
 from lp.testing import (
     login_person,
     logout,

=== modified file 'lib/lp/services/webapp/escaping.py'
--- lib/lp/services/webapp/escaping.py	2012-12-10 22:25:44 +0000
+++ lib/lp/services/webapp/escaping.py	2013-02-06 04:43:28 +0000
@@ -8,6 +8,7 @@
     'structured',
     ]
 
+from lazr.restful.utils import get_current_browser_request
 from zope.i18n import (
     Message,
     translate,
@@ -15,7 +16,6 @@
 from zope.interface import implements
 
 from lp.services.webapp.interfaces import IStructuredString
-from lp.services.webapp.publisher import get_current_browser_request
 
 
 HTML_REPLACEMENTS = (

=== modified file 'lib/lp/services/webapp/menu.py'
--- lib/lp/services/webapp/menu.py	2012-12-12 04:59:52 +0000
+++ lib/lp/services/webapp/menu.py	2013-02-06 04:43:28 +0000
@@ -21,6 +21,7 @@
 import types
 
 from lazr.delegates import delegates
+from lazr.restful.utils import get_current_browser_request
 from lazr.uri import (
     InvalidURIError,
     URI,
@@ -45,7 +46,6 @@
     )
 from lp.services.webapp.publisher import (
     canonical_url,
-    get_current_browser_request,
     LaunchpadView,
     UserAttributeCache,
     )

=== modified file 'lib/lp/services/webapp/pgsession.py'
--- lib/lp/services/webapp/pgsession.py	2012-01-01 03:00:09 +0000
+++ lib/lp/services/webapp/pgsession.py	2013-02-06 04:43:28 +0000
@@ -9,6 +9,7 @@
 import time
 from UserDict import DictMixin
 
+from lazr.restful.utils import get_current_browser_request
 from storm.zope.interfaces import IZStorm
 from zope.app.security.interfaces import IUnauthenticatedPrincipal
 from zope.component import getUtility
@@ -21,7 +22,6 @@
     )
 
 from lp.services.helpers import ensure_unicode
-from lp.services.webapp.publisher import get_current_browser_request
 
 
 SECONDS = 1

=== modified file 'lib/lp/services/webapp/publisher.py'
--- lib/lp/services/webapp/publisher.py	2012-11-26 18:44:34 +0000
+++ lib/lp/services/webapp/publisher.py	2013-02-06 04:43:28 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2012 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Publisher of objects as web pages.
@@ -8,13 +8,13 @@
 __metaclass__ = type
 __all__ = [
     'DataDownloadView',
+    'get_raw_form_value_from_current_request',
     'LaunchpadContainer',
     'LaunchpadView',
     'LaunchpadXMLRPCView',
     'canonical_name',
     'canonical_url',
     'canonical_url_iterator',
-    'get_current_browser_request',
     'nearest',
     'Navigation',
     'rootObject',
@@ -26,6 +26,7 @@
     'UserAttributeCache',
     ]
 
+from cgi import FieldStorage
 import httplib
 import re
 
@@ -800,6 +801,21 @@
         return None
 
 
+def get_raw_form_value_from_current_request(field_name):
+    # Circular imports?
+    from lp.services.webapp.servers import WebServiceClientRequest
+    request = get_current_browser_request()
+    assert isinstance(request, WebServiceClientRequest)
+    # Zope wrongly encodes any form element that doesn't look like a file,
+    # so re-fetch the file content if it has been encoded.
+    if request and request.form.has_key(field_name) and isinstance(
+        request.form[field_name], unicode):
+        request._environ['wsgi.input'].seek(0)
+        fs = FieldStorage(fp=request._body_instream, environ=request._environ)
+        # cgi.FieldStorage handily gives us StringIO, so use that.
+        return fs[field_name].file
+
+
 class RootObject:
     implements(ILaunchpadApplication, ILaunchpadRoot)
 

=== modified file 'lib/lp/services/webapp/servers.py'
--- lib/lp/services/webapp/servers.py	2013-02-03 01:49:37 +0000
+++ lib/lp/services/webapp/servers.py	2013-02-06 04:43:28 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2012 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Definition of the internet servers that Launchpad uses."""
@@ -18,6 +18,7 @@
     WebServicePublicationMixin,
     WebServiceRequestTraversal,
     )
+from lazr.restful.utils import get_current_browser_request
 from lazr.uri import URI
 import transaction
 from transaction.interfaces import ISynchronizer
@@ -100,10 +101,7 @@
     )
 from lp.services.webapp.opstats import OpStats
 from lp.services.webapp.publication import LaunchpadBrowserPublication
-from lp.services.webapp.publisher import (
-    get_current_browser_request,
-    RedirectionView,
-    )
+from lp.services.webapp.publisher import RedirectionView
 from lp.services.webapp.vhosts import allvhosts
 from lp.services.webservice.interfaces import IWebServiceApplication
 from lp.testopenid.interfaces.server import ITestOpenIDApplication
@@ -657,6 +655,15 @@
         """As per zope.publisher.browser.BrowserRequest._createResponse"""
         return LaunchpadBrowserResponse()
 
+    def _decode(self, text):
+        text = super(LaunchpadBrowserRequest, self)._decode(text)
+        if isinstance(text, str):
+            # BrowserRequest._decode failed to do so with the user-specified
+            # charsets, so decode as UTF-8 with replacements, since we always
+            # want unicode.
+            text = unicode(text, 'utf-8', 'replace')
+        return text
+
     @cachedproperty
     def form_ng(self):
         """See ILaunchpadBrowserApplicationRequest."""

=== modified file 'lib/lp/services/webapp/tests/test_menu.py'
--- lib/lp/services/webapp/tests/test_menu.py	2012-01-01 02:58:52 +0000
+++ lib/lp/services/webapp/tests/test_menu.py	2013-02-06 04:43:28 +0000
@@ -3,6 +3,7 @@
 
 __metaclass__ = type
 
+from lazr.restful.utils import get_current_browser_request
 from zope.security.management import newInteraction
 
 from lp.services.webapp.menu import (
@@ -10,7 +11,6 @@
     MENU_ANNOTATION_KEY,
     MenuBase,
     )
-from lp.services.webapp.publisher import get_current_browser_request
 from lp.testing import (
     ANONYMOUS,
     login,
@@ -77,7 +77,7 @@
         login(ANONYMOUS)
         context = object()
         menu = TestMenu(context)
-        link = menu._get_link('test_link')
+        menu._get_link('test_link')
         cache = get_current_browser_request().annotations.get(
             MENU_ANNOTATION_KEY)
         self.assertEquals(len(cache.keys()), 1)

=== modified file 'lib/lp/services/webapp/tests/test_servers.py'
--- lib/lp/services/webapp/tests/test_servers.py	2013-02-03 01:49:37 +0000
+++ lib/lp/services/webapp/tests/test_servers.py	2013-02-06 04:43:28 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2012 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -50,6 +50,7 @@
     EventRecorder,
     TestCase,
     )
+from lp.testing.layers import FunctionalLayer
 
 
 class SetInWSGIEnvironmentTestCase(TestCase):
@@ -230,8 +231,7 @@
 
         for method in denied_methods:
             env = self.wsgi_env(self.non_api_path, method)
-            self.assert_(self.factory.canHandle(env),
-                "Sanity check")
+            self.assert_(self.factory.canHandle(env), "Sanity check")
             # Returns a tuple of (request_factory, publication_factory).
             rfactory, pfactory = self.factory.checkRequest(env)
             self.assert_(rfactory is not None,
@@ -352,6 +352,8 @@
 class TestBasicLaunchpadRequest(TestCase):
     """Tests for the base request class"""
 
+    layer = FunctionalLayer
+
     def test_baserequest_response_should_vary(self):
         """Test that our base response has a proper vary header."""
         request = LaunchpadBrowserRequest(StringIO.StringIO(''), {})
@@ -387,6 +389,16 @@
         request = LaunchpadBrowserRequest(StringIO.StringIO(''), env)
         self.assertEquals(u'fnord/trunk\ufffd', request.getHeader('PATH_INFO'))
 
+    def test_request_with_invalid_query_string_recovers(self):
+        # When the query string has invalid utf-8, it is decoded with
+        # replacement.
+        env = {'QUERY_STRING': 'field.title=subproc\xe9s '}
+        request = LaunchpadBrowserRequest(StringIO.StringIO(''), env)
+        # XXX: Python 2.6 and 2.7 handle unicode replacement differently.
+        self.assertIn(
+            request.query_string_params['field.title'],
+            ([u'subproc\ufffd'], [u'subproc\ufffds ']))
+
 
 class TestFeedsBrowserRequest(TestCase):
     """Tests for `FeedsBrowserRequest`."""

=== modified file 'lib/lp/services/webapp/tests/test_user_requested_oops.py'
--- lib/lp/services/webapp/tests/test_user_requested_oops.py	2012-03-06 23:39:08 +0000
+++ lib/lp/services/webapp/tests/test_user_requested_oops.py	2013-02-06 04:43:28 +0000
@@ -1,4 +1,5 @@
-# Copyright 2009 Canonical Ltd.  All rights reserved.
+# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for the user requested oops using ++oops++ traversal."""
 

=== modified file 'lib/lp/services/webapp/tests/test_view_model.py'
--- lib/lp/services/webapp/tests/test_view_model.py	2012-01-01 02:58:52 +0000
+++ lib/lp/services/webapp/tests/test_view_model.py	2013-02-06 04:43:28 +0000
@@ -1,4 +1,5 @@
-# Copyright 2011 Canonical Ltd.  All rights reserved.
+# Copyright 2011 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for the user requested oops using ++oops++ traversal."""
 

=== modified file 'lib/lp/testing/tests/test_publication.py'
--- lib/lp/testing/tests/test_publication.py	2012-04-12 06:06:49 +0000
+++ lib/lp/testing/tests/test_publication.py	2013-02-06 04:43:28 +0000
@@ -6,6 +6,7 @@
 __metaclass__ = type
 
 from lazr.restful import EntryResource
+from lazr.restful.utils import get_current_browser_request
 from zope.app.pagetemplate.simpleviewclass import simple
 from zope.component import (
     getSiteManager,
@@ -24,7 +25,6 @@
     ILaunchpadRoot,
     )
 from lp.services.webapp.publisher import (
-    get_current_browser_request,
     Navigation,
     stepthrough,
     )