← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~henninge/launchpad/bug-740208-ws-regression into lp:launchpad

 

Henning Eggers has proposed merging lp:~henninge/launchpad/bug-740208-ws-regression into lp:launchpad with lp:~launchpad-orange-squad/launchpad/bug-740208-obfuscate-ws as a prerequisite.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~henninge/launchpad/bug-740208-ws-regression/+merge/67937

= Summary =

This branch fixes the IDiff interface to not call something a text
which is really implemented as a dict nowadays. The "diffstat" attribute
is stored in the database as a JSON string but the Diff model implements
a getter and setter that deserialize it into a dict. I guess this was
added later but the interface was never updated.

== Pre-implementation notes ==

At first I was not sure if changing the interface would have a negative
impact on the web API as it breaks its consistency. Wgrant and I
agreed that obviously nobody is using this as a text field because
otherwise it would have been noticed much earlier. Therefore the change
should not cause any regressions.

== Implementation details ==

I used zope.schema.Dict but have since found that it is not used
elsewhere. I guess we'd normally use Attribute but as this attribute
*is* a dict, I find that fitting ...

== Tests ==

No new test. AFAIK there is no way to test if the implementation of an
attribute fits its interface. I ran the following to check for
regressions. It took about 15 minutes.

bin/test -vvct diff

== Demo and Q/A ==

No Q/A possible AFAICT.

= Launchpad lint =

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/app/browser/stringformatter.py
  lib/lp/app/webservice/__init__.py
  lib/lp/app/webservice/tests/__init__.py
  zcml/override-includes/ws-marshaller-configure.zcml
  lib/lp/app/webservice/tests/test_marshallers.py
  lib/lp/app/browser/tests/test_webservice.py
  lib/lp/app/webservice/marshallers.py
  lib/lp/app/browser/webservice.py
  lib/lp/code/interfaces/diff.py
  lib/lp/services/utils.py

./lib/lp/app/browser/stringformatter.py
     557: Line exceeds 78 characters.
     557: E501 line too long (86 characters)
-- 
https://code.launchpad.net/~henninge/launchpad/bug-740208-ws-regression/+merge/67937
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~henninge/launchpad/bug-740208-ws-regression into lp:launchpad.
=== modified file 'lib/lp/app/browser/stringformatter.py'
--- lib/lp/app/browser/stringformatter.py	2011-07-07 10:14:55 +0000
+++ lib/lp/app/browser/stringformatter.py	2011-07-14 10:07:24 +0000
@@ -34,6 +34,10 @@
 from canonical.launchpad.webapp.interfaces import ILaunchBag
 from lp.answers.interfaces.faq import IFAQSet
 from lp.registry.interfaces.person import IPersonSet
+from lp.services.utils import (
+    re_email_address,
+    obfuscate_email,
+    )
 
 
 def escape(text, quote=True):
@@ -237,7 +241,7 @@
 
 def extract_email_addresses(text):
     '''Unique email addresses in the text.'''
-    matches = re.finditer(FormattersAPI._re_email, text)
+    matches = re.finditer(re_email_address, text)
     return list(set([match.group() for match in matches]))
 
 
@@ -361,12 +365,13 @@
             # devaluing the return on effort for spammers that consider
             # using Launchpad.
             if not FormattersAPI._linkify_url_should_be_ignored(url):
-                link_string = ('<a rel="nofollow" '
-                               'href="%(url)s">%(linked_text)s</a>%(trailers)s' % {
-                                    'url': cgi.escape(url, quote=True),
-                                    'linked_text': add_word_breaks(cgi.escape(url)),
-                                    'trailers': cgi.escape(trailers)
-                                    })
+                link_string = (
+                    '<a rel="nofollow" '
+                    'href="%(url)s">%(linked_text)s</a>%(trailers)s' % {
+                        'url': cgi.escape(url, quote=True),
+                        'linked_text': add_word_breaks(cgi.escape(url)),
+                        'trailers': cgi.escape(trailers)
+                        })
                 return link_string
             else:
                 return full_url
@@ -776,25 +781,6 @@
             output.append(line)
         return '\n'.join(output)
 
-    # This is a regular expression that matches email address embedded in
-    # text. It is not RFC 2821 compliant, nor does it need to be. This
-    # expression strives to identify probable email addresses so that they
-    # can be obfuscated when viewed by unauthenticated users. See
-    # http://www.email-unlimited.com/stuff/email_address_validator.htm
-
-    # localnames do not have [&?%!@<>,;:`|{}()#*^~ ] in practice
-    # (regardless of RFC 2821) because they conflict with other systems.
-    # See https://lists.ubuntu.com
-    #     /mailman/private/launchpad-reviews/2007-June/006081.html
-
-    # This verson of the re is more than 5x faster that the orginal
-    # version used in ftest/test_tales.testObfuscateEmail.
-    _re_email = re.compile(r"""
-        \b[a-zA-Z0-9._/="'+-]{1,64}@  # The localname.
-        [a-zA-Z][a-zA-Z0-9-]{1,63}    # The hostname.
-        \.[a-zA-Z0-9.-]{1,251}\b      # Dot starts one or more domains.
-        """, re.VERBOSE)              # ' <- font-lock turd
-
     def obfuscate_email(self):
         """Obfuscate an email address if there's no authenticated user.
 
@@ -813,11 +799,7 @@
         """
         if getUtility(ILaunchBag).user is not None:
             return self._stringtoformat
-        text = self._re_email.sub(
-            r'<email address hidden>', self._stringtoformat)
-        text = text.replace(
-            "<<email address hidden>>", "<email address hidden>")
-        return text
+        return obfuscate_email(self._stringtoformat)
 
     def linkify_email(self, preloaded_person_data=None):
         """Linkify any email address recognised in Launchpad.
@@ -831,7 +813,7 @@
         """
         text = self._stringtoformat
 
-        matches = re.finditer(self._re_email, text)
+        matches = re.finditer(re_email_address, text)
         for match in matches:
             address = match.group()
             person = None

=== modified file 'lib/lp/app/browser/tests/test_webservice.py'
--- lib/lp/app/browser/tests/test_webservice.py	2011-07-07 10:14:55 +0000
+++ lib/lp/app/browser/tests/test_webservice.py	2011-07-14 10:07:24 +0000
@@ -30,7 +30,8 @@
         request = get_current_web_service_request()
         renderer = getMultiAdapter(
             (product, field, request), IFieldHTMLRenderer)
-        # The representation of a person is the same as a tales PersonFormatter.
+        # The representation of a person is the same as a tales
+        # PersonFormatter.
         self.assertEqual(format_link(eric), renderer(eric))
 
     def test_text(self):
@@ -42,9 +43,9 @@
         request = get_current_web_service_request()
         renderer = getMultiAdapter(
             (product, field, request), IFieldHTMLRenderer)
-        # The representation is linkified html with hidden email.
+        # The representation is linkified html.
         self.assertEqual(
-            u'<p>\N{SNOWMAN} &lt;email address hidden&gt; '
+            u'<p>\N{SNOWMAN} snowman@xxxxxxxxxxx '
             '<a href="/bugs/1">bug 1</a></p>',
             renderer(text))
 

=== modified file 'lib/lp/app/browser/webservice.py'
--- lib/lp/app/browser/webservice.py	2011-07-07 10:14:55 +0000
+++ lib/lp/app/browser/webservice.py	2011-07-14 10:07:24 +0000
@@ -18,7 +18,7 @@
     )
 from zope.schema.interfaces import IText
 
-from lp.app.browser.lazrjs import standard_text_html_representation
+from lp.app.browser.stringformatter import FormattersAPI
 from lp.app.browser.tales import format_link
 
 
@@ -41,4 +41,6 @@
 @implementer(IFieldHTMLRenderer)
 def text_xhtml_representation(context, field, request):
     """Render text as XHTML using the webservice."""
-    return standard_text_html_representation
+    return lambda text: (
+        '' if text is None
+        else FormattersAPI(text).text_to_html(linkify_text=True))

=== added directory 'lib/lp/app/webservice'
=== added file 'lib/lp/app/webservice/__init__.py'
--- lib/lp/app/webservice/__init__.py	1970-01-01 00:00:00 +0000
+++ lib/lp/app/webservice/__init__.py	2011-07-14 10:07:24 +0000
@@ -0,0 +1,4 @@
+# Copyright 2011 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""This package contains code for the Launchpad webservice."""

=== added file 'lib/lp/app/webservice/marshallers.py'
--- lib/lp/app/webservice/marshallers.py	1970-01-01 00:00:00 +0000
+++ lib/lp/app/webservice/marshallers.py	2011-07-14 10:07:24 +0000
@@ -0,0 +1,32 @@
+# Copyright 2011 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Launchpad-specific field marshallers for the web service."""
+
+__metaclass__ = type
+
+__all__ = [
+    'TextFieldMarshaller',
+    ]
+
+
+from lazr.restful.marshallers import (
+    TextFieldMarshaller as LazrTextFieldMarshaller,
+    )
+from zope.app.security.interfaces import IUnauthenticatedPrincipal
+
+from lp.services.utils import obfuscate_email
+
+
+class TextFieldMarshaller(LazrTextFieldMarshaller):
+    """Do not expose email addresses for anonymous users."""
+
+    def unmarshall(self, entry, value):
+        """See `IFieldMarshaller`.
+
+        Return the value as is.
+        """
+        if (value is not None and
+                IUnauthenticatedPrincipal.providedBy(self.request.principal)):
+            return obfuscate_email(value)
+        return value

=== added directory 'lib/lp/app/webservice/tests'
=== added file 'lib/lp/app/webservice/tests/__init__.py'
--- lib/lp/app/webservice/tests/__init__.py	1970-01-01 00:00:00 +0000
+++ lib/lp/app/webservice/tests/__init__.py	2011-07-14 10:07:24 +0000
@@ -0,0 +1,4 @@
+# Copyright 2011 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for code for the Launchpad webservice."""

=== added file 'lib/lp/app/webservice/tests/test_marshallers.py'
--- lib/lp/app/webservice/tests/test_marshallers.py	1970-01-01 00:00:00 +0000
+++ lib/lp/app/webservice/tests/test_marshallers.py	2011-07-14 10:07:24 +0000
@@ -0,0 +1,139 @@
+# Copyright 2011 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for the webservice marshallers."""
+
+__metaclass__ = type
+
+import transaction
+from zope.component import getUtility
+
+from canonical.launchpad.testing.pages import (
+    LaunchpadWebServiceCaller,
+    webservice_for_person,
+    )
+from canonical.launchpad.webapp.interfaces import IPlacelessAuthUtility
+from canonical.launchpad.webapp.servers import WebServiceTestRequest
+from canonical.testing.layers import DatabaseFunctionalLayer
+from lp.app.webservice.marshallers import TextFieldMarshaller
+from lp.testing import logout, TestCaseWithFactory
+
+
+def ws_url(bug):
+    url = "/bugs/%d" % bug.id
+    return url
+
+
+class TestTextFieldMarshaller(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def _makeRequest(self, is_anonymous):
+        """Create either an anonymous or authenticated request."""
+        request = WebServiceTestRequest()
+        if is_anonymous:
+            request.setPrincipal(
+                getUtility(IPlacelessAuthUtility).unauthenticatedPrincipal())
+        else:
+            request.setPrincipal(self.factory.makePerson())
+        return request
+
+    def test_unmarshall_obfuscated(self):
+        # Data is obfuccated if the request is anonynous.
+        request = self._makeRequest(is_anonymous=True)
+        marshaller = TextFieldMarshaller(None, request)
+        result = marshaller.unmarshall(None, u"foo@xxxxxxxxxxx")
+        self.assertEqual(u"<email address hidden>", result)
+
+    def test_unmarshall_not_obfuscated(self):
+        # Data is not obfuccated if the request is authenticated.
+        request = self._makeRequest(is_anonymous=False)
+        marshaller = TextFieldMarshaller(None, request)
+        result = marshaller.unmarshall(None, u"foo@xxxxxxxxxxx")
+        self.assertEqual(u"foo@xxxxxxxxxxx", result)
+
+
+class TestWebServiceObfuscation(TestCaseWithFactory):
+    """Integration test for obfuscation marshaller.
+
+    Not using WebServiceTestCase because that assumes too much about users
+    """
+
+    layer = DatabaseFunctionalLayer
+
+    email_address = "joe@xxxxxxxxxxx"
+    email_address_obfuscated = "<email address hidden>"
+    email_address_obfuscated_escaped = "&lt;email address hidden&gt;"
+    bug_title = "Title with address %s in it"
+    bug_description = "Description with address %s in it"
+
+    def _makeBug(self):
+        """Create a bug with an email address in title and description."""
+        bug = self.factory.makeBug(
+            title=self.bug_title % self.email_address,
+            description=self.bug_description % self.email_address)
+        transaction.commit()
+        return bug
+
+    def test_email_address_obfuscated(self):
+        # Email addresses are obfuscated for anonymous users.
+        bug = self._makeBug()
+        logout()
+        webservice = LaunchpadWebServiceCaller()
+        result = webservice(ws_url(bug)).jsonBody()
+        self.assertEqual(
+            self.bug_title % self.email_address_obfuscated,
+            result['title'])
+        self.assertEqual(
+            self.bug_description % self.email_address_obfuscated,
+            result['description'])
+
+    def test_email_address_not_obfuscated(self):
+        # Email addresses are not obfuscated for authenticated users.
+        bug = self._makeBug()
+        user = self.factory.makePerson()
+        webservice = webservice_for_person(user)
+        result = webservice(ws_url(bug)).jsonBody()
+        self.assertEqual(self.bug_title % self.email_address, result['title'])
+        self.assertEqual(
+            self.bug_description % self.email_address, result['description'])
+
+    def test_xhtml_email_address_not_obfuscated(self):
+        # Email addresses are not obfuscated for authenticated users.
+        bug = self._makeBug()
+        user = self.factory.makePerson()
+        webservice = webservice_for_person(user)
+        result = webservice(
+            ws_url(bug), headers={'Accept': 'application/xhtml+xml'})
+        self.assertIn(self.email_address, result.body)
+        self.assertNotIn(
+            self.email_address_obfuscated_escaped, result.body)
+
+    def test_xhtml_email_address_obfuscated(self):
+        # Email addresses are obfuscated in the XML representation for
+        # anonymous users.
+        bug = self._makeBug()
+        logout()
+        webservice = LaunchpadWebServiceCaller()
+        result = webservice(
+            ws_url(bug), headers={'Accept': 'application/xhtml+xml'})
+        self.assertNotIn(self.email_address, result.body)
+        self.assertIn(self.email_address_obfuscated_escaped, result.body)
+
+    def test_etags_differ_for_anon_and_non_anon_represetations(self):
+        # When a webservice client retrieves data anonymously, this
+        # data should not be used in later write requests, if the
+        # text fields contain obfuscated email addresses. The etag
+        # for a GET request is calculated after the email address
+        # obfuscation and thus differs from the etag returned for
+        # not obfuscated data, so clients usings etags to check if the
+        # cached data is up to date will not use the obfuscated data
+        # in PATCH or PUT requests.
+        bug = self._makeBug()
+        user = self.factory.makePerson()
+        webservice = webservice_for_person(user)
+        etag_logged_in = webservice(ws_url(bug)).getheader('etag')
+        logout()
+        webservice = LaunchpadWebServiceCaller()
+        etag_logged_out = webservice(ws_url(bug)).getheader('etag')
+        self.assertNotEqual(etag_logged_in, etag_logged_out)

=== modified file 'lib/lp/code/interfaces/diff.py'
--- lib/lp/code/interfaces/diff.py	2011-01-31 17:08:35 +0000
+++ lib/lp/code/interfaces/diff.py	2011-07-14 10:07:24 +0000
@@ -24,6 +24,7 @@
 from zope.schema import (
     Bool,
     Bytes,
+    Dict,
     Int,
     Text,
     TextLine,
@@ -53,7 +54,7 @@
         Int(title=_('The number of lines in this diff.'), readonly=True))
 
     diffstat = exported(
-        Text(title=_('Statistics about this diff'), readonly=True))
+        Dict(title=_('Statistics about this diff'), readonly=True))
 
     added_lines_count = exported(
         Int(title=_('The number of lines added in this diff.'),

=== modified file 'lib/lp/services/utils.py'
--- lib/lp/services/utils.py	2011-07-11 17:56:22 +0000
+++ lib/lp/services/utils.py	2011-07-14 10:07:24 +0000
@@ -18,6 +18,8 @@
     'file_exists',
     'iter_list_chunks',
     'iter_split',
+    'obfuscate_email',
+    're_email_address',
     'RegisteredSubclass',
     'run_capturing_output',
     'synchronize',
@@ -30,6 +32,7 @@
 from datetime import datetime
 from itertools import tee
 import os
+import re
 from StringIO import StringIO
 import string
 import sys
@@ -311,3 +314,40 @@
 def utc_now():
     """Return a timezone-aware timestamp for the current time."""
     return datetime.now(tz=pytz.UTC)
+
+
+# This is a regular expression that matches email address embedded in
+# text. It is not RFC 2821 compliant, nor does it need to be. This
+# expression strives to identify probable email addresses so that they
+# can be obfuscated when viewed by unauthenticated users. See
+# http://www.email-unlimited.com/stuff/email_address_validator.htm
+
+# localnames do not have [&?%!@<>,;:`|{}()#*^~ ] in practice
+# (regardless of RFC 2821) because they conflict with other systems.
+# See https://lists.ubuntu.com
+#     /mailman/private/launchpad-reviews/2007-June/006081.html
+
+# This verson of the re is more than 5x faster that the orginal
+# version used in ftest/test_tales.testObfuscateEmail.
+re_email_address = re.compile(r"""
+    \b[a-zA-Z0-9._/="'+-]{1,64}@  # The localname.
+    [a-zA-Z][a-zA-Z0-9-]{1,63}    # The hostname.
+    \.[a-zA-Z0-9.-]{1,251}\b      # Dot starts one or more domains.
+    """, re.VERBOSE)              # ' <- font-lock turd
+
+
+def obfuscate_email(text_to_obfuscate):
+    """Obfuscate an email address.
+
+    The email address is obfuscated as <email address hidden>.
+
+    The pattern used to identify an email address is not 2822. It strives
+    to match any possible email address embedded in the text. For example,
+    mailto:person@xxxxxxxxxx and http://person:password@xxxxxxxxxx both
+    match, though the http match is in fact not an email address.
+    """
+    text = re_email_address.sub(
+        r'<email address hidden>', text_to_obfuscate)
+    text = text.replace(
+        "<<email address hidden>>", "<email address hidden>")
+    return text

=== added file 'zcml/override-includes/ws-marshaller-configure.zcml'
--- zcml/override-includes/ws-marshaller-configure.zcml	1970-01-01 00:00:00 +0000
+++ zcml/override-includes/ws-marshaller-configure.zcml	2011-07-14 10:07:24 +0000
@@ -0,0 +1,23 @@
+<!-- Copyright 2011 Canonical Ltd.  This software is licensed under the
+     GNU Affero General Public License version 3 (see the file LICENSE).
+-->
+
+<configure xmlns="http://namespaces.zope.org/zope";>
+    <!-- Override lazr-restful marshallers for test field to enable
+         automatic obfuscation of email addresses for anonymous users. -->
+
+     <adapter
+        for="zope.schema.interfaces.IASCII
+             zope.publisher.interfaces.http.IHTTPRequest"
+        provides="lazr.restful.interfaces.IFieldMarshaller"
+        factory="lp.app.webservice.marshallers.TextFieldMarshaller"
+        />
+
+    <adapter
+        for="zope.schema.interfaces.IText
+             zope.publisher.interfaces.http.IHTTPRequest"
+        provides="lazr.restful.interfaces.IFieldMarshaller"
+        factory="lp.app.webservice.marshallers.TextFieldMarshaller"
+        />
+
+</configure>
\ No newline at end of file


Follow ups