← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~danilo/launchpad/bug-740640 into lp:launchpad

 

Данило Шеган has proposed merging lp:~danilo/launchpad/bug-740640 into lp:launchpad with lp:~danilo/launchpad/accordionoverlay as a prerequisite.

Requested reviews:
  Launchpad UI Reviewers (launchpad-ui-reviewers): ui
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~danilo/launchpad/bug-740640/+merge/55123

= Bug 740640 =

At the moment, structural subscription overlay allows XSS attacks.

We should stop them at the server-side and provide nice JS-based validation to inform the users of the conditions.

Since we want similar approach for client-side tags validation, this branch implements that as well.

== Pre-implementation notes ==

I've discussed approach to solving this with Gary: allowing everything in the subscription name, while probably the best approach, would complicate implementation in that existing infrastructure just populates the data from the server-side objects directly. Thus, we decided on black-listing potential XSS-attack characters for subscription name.

== Implementation details ==

For server-side, we implement "description" property through setter and getter.

Server-side changes:
 - bugs/model/bugsubscriptionfilter.py
 - bugs/browser/tests/test_bugsubscriptionfilter.py
 - bugs/model/tests/test_bugsubscriptionfilter.py


Client-side changes:
 - All the rest

== Tests ==

  bin/test -cvvt BugSubscriptionFilter.*description
  lib/lp/registry/javascript/tests/test_structural_subscription.html

== Demo and Q/A ==

See also https://devpad.canonical.com/~danilo/screencasts/inline-validation-error.ogv

  1. Go to https://launchpad.dev/+feature-rules and add "advanced-structural-subscriptions.enabled	default	10	on"
  2. Go to http://launchpad.dev/firefox and click on "Subscribe to bug mail" (on the right) and try entering "<", ">", "&" or "\"" into the subscription name: check gets triggered on the 'change' event, which means when you focus on the next element
  3. Similarly, expand "more options" and try adding a few invalid tags (valid tags are "[a-z0-9][a-z0-9+.-]*", i.e. all lowercase or digits with +, ., -)
  4. Try saving with the errors still present

= Launchpad lint =

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/registry/templates/product-index.pt
  lib/lp/registry/help/structural-subscription-name.html
  lib/lp/registry/help/structural-subscription-tags.html
  lib/lp/bugs/browser/structuralsubscription.py
  lib/lp/bugs/browser/tests/test_bugsubscriptionfilter.py
  lib/lp/registry/javascript/tests/test_structural_subscription.html
  lib/lp/app/javascript/lp.js
  lib/lp/registry/browser/product.py
  lib/lp/bugs/browser/tests/test_expose.py
  lib/lp/bugs/model/bugsubscriptionfilter.py
  lib/lp/registry/templates/product-portlet-license-missing.pt
  lib/lp/bugs/templates/bug-subscription-list.pt
  lib/lp/bugs/templates/bugtarget-subscription-list.pt
  lib/lp/services/inlinehelp/javascript/inlinehelp.js
  lib/lp/registry/javascript/tests/test_structural_subscription.js
  lib/lp/bugs/model/tests/test_bugsubscriptionfilter.py
  lib/lp/registry/javascript/structural-subscription.js
  buildout-templates/bin/combine-css.in
  lib/lp/app/javascript/tests/test_accordionoverlay.html

./lib/lp/bugs/model/tests/test_bugsubscriptionfilter.py
      82: E301 expected 1 blank line, found 0
-- 
https://code.launchpad.net/~danilo/launchpad/bug-740640/+merge/55123
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~danilo/launchpad/bug-740640 into lp:launchpad.
=== modified file 'lib/lp/bugs/browser/tests/test_bugsubscriptionfilter.py'
--- lib/lp/bugs/browser/tests/test_bugsubscriptionfilter.py	2011-02-18 18:16:34 +0000
+++ lib/lp/bugs/browser/tests/test_bugsubscriptionfilter.py	2011-03-28 11:22:31 +0000
@@ -183,6 +183,15 @@
         self.assertEqual(
             u"It's late.", self.subscription_filter.description)
 
+    def test_modify_description_xss_safeguard(self):
+        # The description can be modified.
+
+        # Modify, save, and start a new transaction.
+        self.ws_subscription_filter.description = u"&gt; <>"
+        error = self.assertRaises(
+            BadRequest, self.ws_subscription_filter.lp_save)
+        self.assertEqual(400, error.response.status)
+
     def test_modify_statuses(self):
         # The statuses field can be modified.
         self.assertEqual(set(), self.subscription_filter.statuses)

=== modified file 'lib/lp/bugs/model/bugsubscriptionfilter.py'
--- lib/lp/bugs/model/bugsubscriptionfilter.py	2011-03-22 14:53:26 +0000
+++ lib/lp/bugs/model/bugsubscriptionfilter.py	2011-03-28 11:22:31 +0000
@@ -22,6 +22,7 @@
 from canonical.database.sqlbase import sqlvalues
 from canonical.launchpad import searchbuilder
 from canonical.launchpad.interfaces.lpstorm import IStore
+from lazr.restful.error import expose
 from lp.bugs.enum import BugNotificationLevel
 from lp.bugs.interfaces.bugsubscriptionfilter import IBugSubscriptionFilter
 from lp.bugs.interfaces.bugtask import (
@@ -62,7 +63,20 @@
 
     other_parameters = Unicode()
 
-    description = Unicode()
+    _description = Unicode('description')
+
+    def _get_description(self):
+        return self._description
+
+    def _set_description(self, description):
+        if ('<' in description or '>' in description or
+            '"' in description or '&' in description):
+            raise expose(ValueError(
+                'BugSubscriptionFilter description cannot contain '
+                'any of <, >, " or &.'))
+        self._description = description
+
+    description = property(_get_description, _set_description)
 
     def _get_statuses(self):
         """Return a frozenset of statuses to filter on."""

=== modified file 'lib/lp/bugs/model/tests/test_bugsubscriptionfilter.py'
--- lib/lp/bugs/model/tests/test_bugsubscriptionfilter.py	2011-03-21 18:20:09 +0000
+++ lib/lp/bugs/model/tests/test_bugsubscriptionfilter.py	2011-03-28 11:22:31 +0000
@@ -70,6 +70,21 @@
         self.assertEqual(u"foo", bug_subscription_filter.other_parameters)
         self.assertEqual(u"bar", bug_subscription_filter.description)
 
+    def test_description(self):
+        """Test the description property."""
+        bug_subscription_filter = BugSubscriptionFilter()
+        bug_subscription_filter.description = u"foo"
+        self.assertEqual(u"foo", bug_subscription_filter.description)
+
+    def test_description_xss_safeguard(self):
+        """description property disallows a few HTML characters."""
+        bug_subscription_filter = BugSubscriptionFilter()
+        def set_description(filter, value):
+            filter.description = value
+        self.assertRaises(ValueError,
+                          setattr, bug_subscription_filter, 'description',
+                          u'<script>')
+
     def test_defaults(self):
         """Test the default values of `BugSubscriptionFilter` objects."""
         # Create.

=== modified file 'lib/lp/registry/javascript/structural-subscription.js'
--- lib/lp/registry/javascript/structural-subscription.js	2011-03-28 11:22:14 +0000
+++ lib/lp/registry/javascript/structural-subscription.js	2011-03-28 11:22:31 +0000
@@ -207,6 +207,9 @@
 
 function save_subscription(form_data) {
     var who;
+    var has_errors = check_for_errors_in_overlay(add_subscription_overlay);
+    if (has_errors)
+        return false;
     if (form_data.recipient[0] == 'user') {
         who = LP.links.me;
     } else {
@@ -217,7 +220,33 @@
 }
 namespace.save_subscription = save_subscription;
 
+function check_for_errors_in_overlay(overlay) {
+    var has_errors = false;
+    var errors = new Array();
+    for (var field in overlay.field_errors) {
+        if (overlay.field_errors[field]) {
+            has_errors = true;
+            errors.push(field);
+        }
+    };
+    if (has_errors) {
+        var error_text = errors.pop()
+        if (errors.length > 0) {
+            error_text = errors.join(', ') + ' and ' + error_text;
+        }
+
+        overlay.showError(
+            'Value for ' + error_text + ' is invalid.');
+        return true;
+    } else {
+        return false;
+    }
+};
+
 function edit_subscription_handler(context, form_data) {
+    var has_errors = check_for_errors_in_overlay(add_subscription_overlay);
+    if (has_errors)
+        return false;
     var on = {success: function (new_data) {
         var filter = new_data.getAttrs();
         var description_node = Y.one(
@@ -233,6 +262,18 @@
     patch_bug_filter(context.filter_info.filter, form_data, on);
 }
 
+function setup_overlay_validators(overlay, overlay_id) {
+    overlay.field_validators = {
+        name: get_error_for_subscription_name,
+        tags: get_error_for_tags_list
+    };
+    overlay.field_errors = {};
+    for (var field_name in overlay.field_validators) {
+        add_input_validator(overlay, overlay_id, field_name,
+                            overlay.field_validators[field_name]);
+    };
+};
+
 function create_overlay(content_box_id, overlay_id, submit_button,
         submit_callback) {
     // Create the overlay.
@@ -245,9 +286,15 @@
         visible: false,
         form_submit_button: submit_button,
         form_cancel_button: Y.Node.create(cancel_button_html),
-        form_submit_callback: submit_callback
+        form_submit_callback: function(formdata) {
+            // Do not clean up if saving was not successful.
+            var save_succeeded = submit_callback(formdata);
+            if (save_succeeded !== false)
+                clean_up();
+        }
     });
     add_subscription_overlay.render(content_box_id);
+    setup_overlay_validators(add_subscription_overlay, overlay_id);
     // Prevent cruft from hanging around upon closing.
     function clean_up(e) {
         var filter_wrapper = Y.one('#' + FILTER_WRAPPER);
@@ -256,8 +303,6 @@
     }
     add_subscription_overlay.get('form_cancel_button').on(
         'click', clean_up);
-    add_subscription_overlay.get('form_submit_button').on(
-        'click', clean_up);
     add_subscription_overlay.on('cancel', clean_up);
 }
 
@@ -766,6 +811,67 @@
     }, window);
 };
 
+/*
+ * Set up a validator function for a form input field.
+ * @method add_input_validator
+ * @param {Object} overlay Overlay object.
+ * @param {String} overlay_id Element ID of the containing overlay.
+ * @param {String} field_name Form <input> 'name' to set up a validator for.
+ * @param {String} validator Function which returns 'null' if there is
+      no error in the field value, and an error message otherwise.
+ */
+function add_input_validator(overlay, overlay_id, field_name, validator) {
+    var input = Y.one(overlay_id + ' input[name="'+field_name+'"]');
+    var field_container = input.get('parentNode');
+    var error_container = Y.Node.create('<div class="inline-warning"></div>');
+    field_container.appendChild(error_container);
+
+    input.on('change', function(e) {
+        var error_text = validator(input.get('value'));
+        if (error_text !== null) {
+            Y.lazr.anim.red_flash({node: input}).run();
+            error_container.setContent(error_text);
+            overlay.field_errors[field_name] = true;
+            field_container.get('parentNode').get('parentNode').setStyles(
+                {height: 'auto'});
+            // Firefox prohibits focus from inside the 'focus lost' event
+            // handler (probably to stop loops), so we need to run
+            // it from a different context (which we do with setTimeout).
+            setTimeout(function() { input.focus(); input.select(); }, 1);
+        } else {
+            error_container.setContent('');
+            overlay.field_errors[field_name] = false;
+        }
+    });
+};
+
+function get_error_for_subscription_name(value) {
+    if (value.search(/[\<\>\"\&]/) == -1) {
+        return null;
+    } else {
+        return ('Subscription name cannot contain any of the following ' +
+                'characters: &lt; &gt; &quot; &amp;');
+    }
+};
+// Export for testing
+namespace._get_error_for_subscription_name = get_error_for_subscription_name;
+
+function get_error_for_tags_list(value) {
+    // See database/schema/trusted.sql valid_name() function
+    // which is used to validate a single tag.
+    // As an extension, we also allow "-" (hyphen) in front of
+    // any tag to indicate exclusion of a tag, and we accept
+    // a space-separated list.
+    if (value.match(/^(\-?[a-z0-9][a-z0-9\+\.\-]*[ ]*)*$/) !== null) {
+        return null;
+    } else {
+        return ('Tags can only contain lowercase ASCII letters, ' +
+                'digits 0-9 and symbols "+", "-" or ".".');
+    }
+};
+// Export for testing
+namespace._get_error_for_tags_list = get_error_for_tags_list;
+
 function get_input_by_value(node, value) {
     // XXX broken: this should also care about input name because some values
     // repeat in other areas of the form
@@ -1134,7 +1240,6 @@
     // initialized except for the ones we added, so setupHelpTrigger
     // is idempotent.  Notice that this is old MochiKit code.
     forEach(findHelpLinks(), setupHelpTrigger);
-
 };               // setup
 
 }, '0.1', {requires: [

=== modified file 'lib/lp/registry/javascript/tests/test_structural_subscription.js'
--- lib/lp/registry/javascript/tests/test_structural_subscription.js	2011-03-28 11:22:14 +0000
+++ lib/lp/registry/javascript/tests/test_structural_subscription.js	2011-03-28 11:22:31 +0000
@@ -228,6 +228,123 @@
     suite.add(test_case);
 
     test_case = new Y.Test.Case({
+        name: 'Structural Subscription validation tests',
+
+        _should: {
+            error: {
+                }
+        },
+
+        setUp: function() {
+            // Monkeypatch LP to avoid network traffic and to allow
+            // insertion of test data.
+            window.LP = {
+                links: {},
+                cache: {}
+            };
+
+        },
+
+        test_get_error_for_subscription_name_valid: function() {
+            // Valid name makes get_error_for_subscription_name return null.
+            var name = 'Test + name!';
+            Assert.isNull(module._get_error_for_subscription_name(name));
+        },
+
+        test_get_error_for_subscription_name_left_angle_bracket: function() {
+            // For invalid names get_error_for_subscription_name returns
+            // an error message instead.
+            var name = '<Test';
+            var error_text = module._get_error_for_subscription_name(name);
+            Assert.isNotNull(error_text);
+            Assert.areEqual(
+                'Subscription name cannot contain any of the following ' +
+                    'characters: &lt; &gt; &quot; &amp;',
+                error_text);
+        },
+
+        test_get_error_for_subscription_name_right_angle_bracket: function() {
+            // For invalid names get_error_for_subscription_name returns
+            // an error message instead.
+            var name = 'Test>';
+            var error_text = module._get_error_for_subscription_name(name);
+            Assert.isNotNull(error_text);
+            Assert.areEqual(
+                'Subscription name cannot contain any of the following ' +
+                    'characters: &lt; &gt; &quot; &amp;',
+                error_text);
+        },
+
+        test_get_error_for_subscription_name_ampersand: function() {
+            // For invalid names get_error_for_subscription_name returns
+            // an error message instead.
+            var name = 'Test & fail';
+            var error_text = module._get_error_for_subscription_name(name);
+            Assert.isNotNull(error_text);
+            Assert.areEqual(
+                'Subscription name cannot contain any of the following ' +
+                    'characters: &lt; &gt; &quot; &amp;',
+                error_text);
+        },
+
+        test_get_error_for_subscription_name_quote: function() {
+            // For invalid names get_error_for_subscription_name returns
+            // an error message instead.
+            var name = 'Test "failure"';
+            var error_text = module._get_error_for_subscription_name(name);
+            Assert.isNotNull(error_text);
+            Assert.areEqual(
+                'Subscription name cannot contain any of the following ' +
+                    'characters: &lt; &gt; &quot; &amp;',
+                error_text);
+        },
+
+        test_get_error_for_tags_list_valid: function() {
+            // Valid tags list is a space-separated list of tags
+            // consisting of all lowercase and digits and potentially
+            // '+', '-', '.' in non-initial characters.
+            var tags = 'tag1 tag+2  tag.3 tag-4 5tag';
+            Assert.isNull(module._get_error_for_tags_list(tags));
+        },
+
+        test_get_error_for_tags_list_uppercase: function() {
+            // Uppercase is not allowed in tags.
+            var tags = 'Tag';
+            var error_text = module._get_error_for_tags_list(tags);
+            Assert.isNotNull(error_text);
+            Assert.areEqual(
+                'Tags can only contain lowercase ASCII letters, ' +
+                    'digits 0-9 and symbols "+", "-" or ".".',
+                error_text);
+        },
+
+        test_get_error_for_tags_list_invalid_characters: function() {
+            // Anything other than lowercase, digits or '+', '-' and '.'
+            // is invalid in tags.
+            var tags = 'tag#!';
+            var error_text = module._get_error_for_tags_list(tags);
+            Assert.isNotNull(error_text);
+            Assert.areEqual(
+                'Tags can only contain lowercase ASCII letters, ' +
+                    'digits 0-9 and symbols "+", "-" or ".".',
+                error_text);
+        },
+
+        test_get_error_for_tags_list_special_characters: function() {
+            // Even if '+', '-' or '.' are allowed in tags,
+            // they must not be at the beginning of a tag.
+            var tags = 'tag1 +tag2 -tag3 .tag4';
+            var error_text = module._get_error_for_tags_list(tags);
+            Assert.isNotNull(error_text);
+            Assert.areEqual(
+                'Tags can only contain lowercase ASCII letters, ' +
+                    'digits 0-9 and symbols "+", "-" or ".".',
+                error_text);
+        },
+    });
+    suite.add(test_case);
+
+    test_case = new Y.Test.Case({
         name: 'Structural Subscription interaction tests',
 
         _should: {