← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~sinzui/launchpad/path-info-bad-request into lp:launchpad

 

Curtis Hovey has proposed merging lp:~sinzui/launchpad/path-info-bad-request into lp:launchpad.

Commit message:
Report a 400 Bad Request when the PATH_INFO is not UTF-8 encoded.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #852205 in Launchpad itself: "UnicodeDecodeError in sane_enviromnent closes the connection"
  https://bugs.launchpad.net/launchpad/+bug/852205

For more details, see:
https://code.launchpad.net/~sinzui/launchpad/path-info-bad-request/+merge/140698

Launchpad/Zope fails without raising an error if PATH_INFO is not UTF-8
encoded. The error is deep in Zope and happens before any error handling
is setup

RULES

    Pre-implementation: flacoste, wgrant
    * We want to make the creation of the request object robust to carry
      on for a little bit longer. Something need to indicate that setup
      is incomplete and that an error needs to be reported
    * After error reporting is setup, and before traversal starts, something
      needs to raise a real error. The Error is a generic 400, but it would
      be nice to explain what went wrong since Lp does know for this
      case that the PATH_INFO must be UTF-8 encoded.
    * The New error is exempt from oopses when Lp is not the referrer. Like
      404s and 410s, offsite requests are not an issue with Lp's code.

    Addendum:
    * The very-plain-oops template cannot be used because it claims that
      something when wrong with Lp. In this case though, it was the other
      site, browser, or user.
    * Since the request was not properly setup, the templates in lp.app
      fail when the require a proper request.
    * We need a template that looks like an Lp page, but does not ever
      use the request.


QA

    * Visit https://bugs.qastaging.launchpad.net/mahara/+bug/630891%E4
    * Verify the page:
      * Uses Lp's styles
      * Explains Lp did not understand the request
      * Shows you the traceback when you are logged in as an Lp developer


LINT

    lib/lp/app/browser/configure.zcml
    lib/lp/app/browser/tests/test_launchpad.py
    lib/lp/app/templates/launchpad-badrequest.pt
    lib/lp/services/webapp/error.py
    lib/lp/services/webapp/errorlog.py
    lib/lp/services/webapp/interfaces.py
    lib/lp/services/webapp/publication.py
    lib/lp/services/webapp/servers.py
    lib/lp/services/webapp/tests/test_errorlog.py
    lib/lp/services/webapp/tests/test_publication.py
    lib/lp/services/webapp/tests/test_servers.py


LoC

    I have more than 10,000 lines of credit this week.


TEST

    ./bin/test -vc -t errorlog -t publication -t servers lp.services.webapp.tests
    ./bin/test -vc -t TestErrorViews lp.app.browser.tests



IMPLEMENTATION

I updated BasicLaunchpadRequest to handle the UnicodeDecodeError
exception. The request is made with a sanitised PATH_INFO. The response
status is immediately set to 400 in case something looks at the
response. The X-Launchpad-Bad-Request header is added so that something
can make a proper error for it when oops reports is available.
    lib/lp/services/webapp/servers.py
    lib/lp/services/webapp/tests/test_servers.py

I added BadRequestError. LaunchpadBrowserPublication.beforeTraversal
already raises errors when it is known that Lp cannot continue. I add a
rule to check for the X-Launchpad-Bad-Request header and make a
BadRequestError from it.
    lib/lp/services/webapp/interfaces.py
    lib/lp/services/webapp/publication.py
    lib/lp/services/webapp/tests/test_publication.py

Added BadRequestError to the list of error to ignore when Lp is not the
referrer.
    lib/lp/services/webapp/errorlog.py
    lib/lp/services/webapp/tests/test_errorlog.py


I redefined InvalidBatchSizeView to extend the new InvalidBatchSizeView
because both views are 400s and we want to explain what the user did. I
configured the Error view to use the Lp specific template. Since the
error is about the request, we cannot use standard templates. The
very-plain-oops.pt template from webapp was not appropriate because it
claimed there was something wrong with Lp, which was not true. I
provided a page that looks like Lp's error pages, but avoids anything
that requires a sane request object.
    lib/lp/services/webapp/error.py
    lib/lp/app/browser/configure.zcml
    lib/lp/app/browser/tests/test_launchpad.py
    lib/lp/app/templates/launchpad-badrequest.pt
-- 
https://code.launchpad.net/~sinzui/launchpad/path-info-bad-request/+merge/140698
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~sinzui/launchpad/path-info-bad-request into lp:launchpad.
=== modified file 'lib/lp/app/browser/configure.zcml'
--- lib/lp/app/browser/configure.zcml	2012-11-12 15:58:15 +0000
+++ lib/lp/app/browser/configure.zcml	2012-12-19 15:12:39 +0000
@@ -397,6 +397,15 @@
       class="lp.services.webapp.error.UnexpectedFormDataView"
       />
 
+  <!-- BadRequestError -->
+  <browser:page
+      for="lp.services.webapp.interfaces.BadRequestError"
+      name="index.html"
+      permission="zope.Public"
+      template="../templates/launchpad-badrequest.pt"
+      class="lp.services.webapp.error.BadRequestView"
+      />
+
   <!-- InvalidBatchSizeError -->
   <browser:page
       for="lazr.batchnavigator.interfaces.InvalidBatchSizeError"

=== modified file 'lib/lp/app/browser/tests/test_launchpad.py'
--- lib/lp/app/browser/tests/test_launchpad.py	2012-12-10 13:43:47 +0000
+++ lib/lp/app/browser/tests/test_launchpad.py	2012-12-19 15:12:39 +0000
@@ -31,6 +31,7 @@
 from lp.services.webapp import canonical_url
 from lp.services.webapp.escaping import html_escape
 from lp.services.webapp.interfaces import (
+    BadRequestError,
     BrowserNotificationLevel,
     ILaunchpadRoot,
     )
@@ -442,6 +443,12 @@
         self.assertEqual('Error: Page gone', view.page_title)
         self.assertEqual(410, view.request.response.getStatus())
 
+    def test_BadRequestError(self):
+        error = BadRequestError('PATH_INFO is not UTF-8 encoded.')
+        view = create_view(error, 'index.html')
+        self.assertEqual('Error: Bad request', view.page_title)
+        self.assertEqual(400, view.request.response.getStatus())
+
 
 class ExceptionHierarchyTestCase(TestCaseWithFactory):
 

=== added file 'lib/lp/app/templates/launchpad-badrequest.pt'
--- lib/lp/app/templates/launchpad-badrequest.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/app/templates/launchpad-badrequest.pt	2012-12-19 15:12:39 +0000
@@ -0,0 +1,22 @@
+<html
+  xmlns="http://www.w3.org/1999/xhtml";
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+  i18n:domain="launchpad"
+>
+  <head>
+    <title>Error: Bad request</title>
+    <link rel="stylesheet" href="/+icing/import.css" />
+  </head>
+  <body>
+    <div class="top-portlet">
+      <h1 class="exception">Bad request</h1>
+      <p>
+      Your request was not understood.<br />
+      <span tal:replace="view/error_message" />
+      </p>
+      <tal:replace tal:replace="structure view/maybeShowTraceback" />
+    </div>
+  </body>
+</html>

=== modified file 'lib/lp/services/webapp/error.py'
--- lib/lp/services/webapp/error.py	2012-02-17 23:00:56 +0000
+++ lib/lp/services/webapp/error.py	2012-12-19 15:12:39 +0000
@@ -233,10 +233,9 @@
         request.response.setHeader('Retry-After', 900)
 
 
-class InvalidBatchSizeView(SystemErrorView):
-    """View rendered when an InvalidBatchSizeError is raised."""
+class BadRequestView(SystemErrorView):
 
-    page_title = "Error: Invalid Batch Size"
+    page_title = "Error: Bad request"
 
     response_code = httplib.BAD_REQUEST
 
@@ -252,6 +251,12 @@
         return ""
 
 
+class InvalidBatchSizeView(BadRequestView):
+    """View rendered when an InvalidBatchSizeError is raised."""
+
+    page_title = "Error: Invalid Batch Size"
+
+
 class TranslationUnavailableView(SystemErrorView):
 
     page_title = 'Error: Translation page is not available'

=== modified file 'lib/lp/services/webapp/errorlog.py'
--- lib/lp/services/webapp/errorlog.py	2012-08-09 03:36:26 +0000
+++ lib/lp/services/webapp/errorlog.py	2012-12-19 15:12:39 +0000
@@ -296,7 +296,7 @@
 
     _ignored_exceptions = set(['TranslationUnavailable', 'NoReferrerError'])
     _ignored_exceptions_for_offsite_referer = set([
-        'GoneError', 'InvalidBatchSizeError', 'NotFound'])
+        'BadRequestError', 'GoneError', 'InvalidBatchSizeError', 'NotFound'])
     _default_config_section = 'error_reports'
 
     def __init__(self):

=== modified file 'lib/lp/services/webapp/interfaces.py'
--- lib/lp/services/webapp/interfaces.py	2012-09-28 06:47:29 +0000
+++ lib/lp/services/webapp/interfaces.py	2012-12-19 15:12:39 +0000
@@ -68,6 +68,10 @@
     """Marker interface for a Launchpad protocol error exception."""
 
 
+class BadRequestError(Exception):
+    """The request was not understood; the client must fix the issue"""
+
+
 class OffsiteFormPostError(Exception):
     """An attempt was made to post a form from a remote site."""
 

=== modified file 'lib/lp/services/webapp/publication.py'
--- lib/lp/services/webapp/publication.py	2012-09-28 06:25:44 +0000
+++ lib/lp/services/webapp/publication.py	2012-12-19 15:12:39 +0000
@@ -76,6 +76,7 @@
 from lp.services.osutils import open_for_writing
 import lp.services.webapp.adapter as da
 from lp.services.webapp.interfaces import (
+    BadRequestError,
     FinishReadOnlyRequestEvent,
     ILaunchpadRoot,
     IOpenLaunchBag,
@@ -249,6 +250,9 @@
         threadid = thread.get_ident()
         threadrequestfile = open_for_writing(
             'logs/thread-%s.request' % threadid, 'w')
+        bad_request = request.response.getHeader('X-Launchpad-Bad-Request', '')
+        if bad_request:
+            raise BadRequestError(bad_request)
         try:
             request_txt = unicode(request).encode('UTF-8')
         except Exception:

=== modified file 'lib/lp/services/webapp/servers.py'
--- lib/lp/services/webapp/servers.py	2012-07-06 16:39:38 +0000
+++ lib/lp/services/webapp/servers.py	2012-12-19 15:12:39 +0000
@@ -390,7 +390,6 @@
             the request does comply, (None, None).
         """
         method = environment.get('REQUEST_METHOD')
-
         if method in self.getAcceptableMethods(environment):
             factories = (None, None)
         else:
@@ -574,8 +573,22 @@
     def __init__(self, body_instream, environ, response=None):
         self.traversed_objects = []
         self._wsgi_keys = set()
-        super(BasicLaunchpadRequest, self).__init__(
-            body_instream, environ, response)
+        try:
+            super(BasicLaunchpadRequest, self).__init__(
+                body_instream, environ, response)
+        except UnicodeDecodeError:
+            # The environ must be santised for the request so that a
+            # ProtocolErrorPublicationFactory can handle this issue
+            # before traversal starts.
+            safe_path_info = environ['PATH_INFO'].decode('utf-8', 'ignore')
+            safe_env = dict(environ)
+            safe_env['PATH_INFO'] = safe_path_info
+            super(BasicLaunchpadRequest, self).__init__(
+                body_instream, safe_env, response)
+            self.response.setStatus(
+                400, reason='PATH_INFO is not UTF-8 encoded')
+            self.response.setHeader(
+                'X-Launchpad-Bad-Request', 'PATH_INFO is not UTF-8 encoded')
 
         # Our response always vary based on authentication.
         self.response.setHeader('Vary', 'Cookie, Authorization')

=== modified file 'lib/lp/services/webapp/tests/test_errorlog.py'
--- lib/lp/services/webapp/tests/test_errorlog.py	2012-06-29 08:40:05 +0000
+++ lib/lp/services/webapp/tests/test_errorlog.py	2012-12-19 15:12:39 +0000
@@ -46,6 +46,7 @@
     ScriptRequest,
     )
 from lp.services.webapp.interfaces import (
+    BadRequestError,
     IUnloggedException,
     NoReferrerError,
     )
@@ -419,7 +420,7 @@
         del utility._oops_config.publishers[:]
         errors = set([
             GoneError.__name__, InvalidBatchSizeError.__name__,
-            NotFound.__name__])
+            NotFound.__name__, BadRequestError.__name__])
         self.assertEqual(
             errors, utility._ignored_exceptions_for_offsite_referer)
 

=== modified file 'lib/lp/services/webapp/tests/test_publication.py'
--- lib/lp/services/webapp/tests/test_publication.py	2012-08-09 03:41:10 +0000
+++ lib/lp/services/webapp/tests/test_publication.py	2012-12-19 15:12:39 +0000
@@ -31,6 +31,7 @@
     )
 import lp.services.webapp.adapter as dbadapter
 from lp.services.webapp.interfaces import (
+    BadRequestError,
     NoReferrerError,
     OAuthPermission,
     OffsiteFormPostError,
@@ -79,6 +80,15 @@
         publication.callTraversalHooks(request, obj2)
         self.assertEquals(request.traversed_objects, [obj1])
 
+    def test_beforeTraversal_x_launchapd_bad_request(self):
+        # A BadRequestError is raised when the request's response has the
+        # X-Launchpad-Bad-Request header.
+        request = LaunchpadTestRequest()
+        request.response.setHeader('X-Launchpad-Bad-Request', 'Bad encoding')
+        publication = LaunchpadBrowserPublication(None)
+        self.assertRaises(
+            BadRequestError, publication.beforeTraversal, request)
+
 
 class TestWebServicePublication(TestCaseWithFactory):
     layer = DatabaseFunctionalLayer

=== modified file 'lib/lp/services/webapp/tests/test_servers.py'
--- lib/lp/services/webapp/tests/test_servers.py	2012-02-29 10:35:12 +0000
+++ lib/lp/services/webapp/tests/test_servers.py	2012-12-19 15:12:39 +0000
@@ -380,6 +380,20 @@
             response.getHeader(
                 'Strict-Transport-Security'), 'max-age=2592000')
 
+    def test_baserequest_recovers_from_bad_path_info_encoding(self):
+        # The request object handles the cases when sane_environment raises a
+        # UnicodeDecodeError decoding the path_info.
+        bad_path = 'fnord/trunk\xE4'
+        env = {'PATH_INFO': bad_path}
+        request = LaunchpadBrowserRequest(StringIO.StringIO(''), env)
+        self.assertEquals('fnord/trunk', request.getHeader('PATH_INFO'))
+        self.assertEquals(
+            'PATH_INFO is not UTF-8 encoded',
+            request.response.getHeader('X-Launchpad-Bad-Request'))
+        self.assertEquals(
+            '400 PATH_INFO is not UTF-8 encoded',
+            request.response.getStatusString())
+
 
 class TestFeedsBrowserRequest(TestCase):
     """Tests for `FeedsBrowserRequest`."""