Просмотр исходного кода

fix #520: component for routing posted links

Rafał Pitoń 10 лет назад
Родитель
Сommit
dd55f52aa1

+ 6 - 0
misago/emberapp/app/components/misago-markup.js

@@ -0,0 +1,6 @@
+import RoutedLinks from 'misago/components/routed-links';
+
+export default RoutedLinks.extend({
+  tagName: 'article',
+  classNames: ['misago-markup']
+});

+ 82 - 0
misago/emberapp/app/components/routed-links.js

@@ -0,0 +1,82 @@
+import Ember from 'ember';
+
+export default Ember.Component.extend({
+  _clicksHandlerName: function() {
+    return 'click.MisagoDelegatedLinks-' + this.get('elementId');
+  }.property('elementId'),
+
+  cleanHref: function(router, href) {
+    if (!href) { return; }
+
+    // Is link relative?
+    var isRelative = href.substr(0, 1) === '/' && href.substr(0, 2) !== '//';
+
+    // If link contains host, validate to see if its outgoing
+    if (!isRelative) {
+      var location = window.location;
+
+      // If protocol matches current one, strip it from string
+      // otherwhise stop handler
+      if (href.substr(0, 2) !== '//') {
+        var hrefProtocol = href.substr(0, location.protocol.length + 2);
+        if (hrefProtocol !== location.protocol + '//') { return; }
+        href = href.substr(location.protocol.length + 2);
+      } else {
+        href = href.substr(2);
+      }
+
+      // Host checks out?
+      if (href.substr(0, location.host.length) !== location.host) { return; }
+      href = href.substr(location.host.length);
+    }
+
+    // Is link within Ember app?
+    var rootUrl = router.get('rootURL');
+    if (href.substr(0, rootUrl.length) !== rootUrl) { return; }
+
+    // Is link to media/static/avatar server?
+    // NOTE: In ember serve staticUrl equals to /, making clean always fail
+    var staticUrl = this.get('staticUrl');
+    if (href.substr(0, staticUrl.length) === staticUrl) { return; }
+
+    var mediaUrl = this.get('mediaUrl');
+    if (href.substr(0, mediaUrl.length) === mediaUrl) { return; }
+
+    var avatarsUrl = '/user-avatar/';
+    if (href.substr(0, avatarsUrl.length) === avatarsUrl) { return; }
+
+    return href;
+  },
+
+  delegateLinkClickHandler: function() {
+    var self = this;
+    var router = this.container.lookup('router:main');
+
+    this.$().on(this.get('_clicksHandlerName'), 'a', function(e) {
+      var cleanedHref = self.cleanHref(router, e.target.href);
+
+      /*
+      If href was cleaned, prevent default action on link
+      and tell Ember's router to handle cleaned href instead.
+
+      NOTE: there's no way to reliably decide if user didn't maliciously
+      post an URL to something that should be routed by server instead, like
+      admin control panel.
+
+      If this happens, clicks on those links will fail in Ember's router,
+      resulting in 404 page for valid urls, confusing your users.
+
+      ...not like it's not your moderators job to keep an eye on what your
+      users are posting on your own site.
+      */
+      if (cleanedHref) {
+        e.preventDefault();
+        router.handleURL(cleanedHref);
+      }
+    });
+  }.on('didInsertElement'),
+
+  removeLinkClickHandler: function() {
+    this.$().off(this.get('_clicksHandlerName'));
+  }.on('willDestroyElement')
+});

+ 1 - 1
misago/emberapp/app/index.html

@@ -35,7 +35,7 @@
     <script src="misago-preload-data.js"></script>
 
     <script>
-      MisagoData.staticUrl = '';
+      MisagoData.staticUrl = '/';
     </script>
 
     <script src="misago/js/vendor.js"></script>

+ 2 - 2
misago/emberapp/app/templates/components/forms/edit-signature-form.hbs

@@ -31,14 +31,14 @@
       <span class="fa fa-warning fa-lg"></span>
     </div>
 
-    <div class="error-message">
+    {{#routed-links class="error-message"}}
       {{#if loadError.reason}}
       <p class="lead">{{loadError.detail}}</p>
       {{{loadError.reason}}}
       {{else}}
       <p>{{loadError.detail}}</p>
       {{/if}}
-    </div>
+    {{/routed-links}}
 
   </div>
   {{else}}

+ 11 - 11
misago/emberapp/app/templates/components/modals/change-avatar-modal.hbs

@@ -8,21 +8,21 @@
     {{component activeForm options=options activeForm=activeForm}}
   </div>
   {{else if loadError}}
-  <div class="modal-body modal-message">
+    {{#routed-links class="modal-body modal-message"}}
 
-    <div class="message-icon">
-      <span class="fa fa-warning fa-4x text-muted"></span>
-    </div>
+      <div class="message-icon">
+        <span class="fa fa-warning fa-4x text-muted"></span>
+      </div>
 
-    <p class="lead">
-      {{loadError.detail}}
-    </p>
+      <p class="lead">
+        {{loadError.detail}}
+      </p>
 
-    {{#if loadError.reason}}
-      {{{loadError.reason}}}
-    {{/if}}
+      {{#if loadError.reason}}
+        {{{loadError.reason}}}
+      {{/if}}
 
-  </div>
+    {{/routed-links}}
   {{else}}
   <div class="modal-body modal-message">
     <div class="loader"></div>

+ 2 - 2
misago/emberapp/app/templates/error-banned.hbs

@@ -7,9 +7,9 @@
       </div>
 
       <div class="error-message">
-        <div class="lead">
+        {{#routed-links class="lead"}}
           {{{model.message.html}}}
-        </div>
+        {{/routed-links}}
 
         {{ban-expires model=model}}
       </div>

+ 2 - 2
misago/emberapp/app/templates/privacy-policy.hbs

@@ -6,8 +6,8 @@
   </div>
 
   <div class="legal-body container">
-    <article class="misago-markup">
+    {{#misago-markup}}
       {{{model.body}}}
-    </article>
+    {{/misago-markup}}
   </div>
 </div>

+ 2 - 2
misago/emberapp/app/templates/terms-of-service.hbs

@@ -6,8 +6,8 @@
   </div>
 
   <div class="legal-body container">
-    <article class="misago-markup">
+    {{#misago-markup}}
       {{{model.body}}}
-    </article>
+    {{/misago-markup}}
   </div>
 </div>

+ 0 - 1
misago/emberapp/tests/acceptance/registration-test.js

@@ -1,7 +1,6 @@
 import Ember from 'ember';
 import { module, test } from 'qunit';
 import startApp from '../helpers/start-app';
-import PreloadStore from 'misago/services/preload-store';
 import destroyModal from '../helpers/destroy-modal';
 import getToastMessage from '../helpers/toast-message';
 

+ 53 - 0
misago/emberapp/tests/acceptance/routed-links-test.js

@@ -0,0 +1,53 @@
+import Ember from 'ember';
+import PreloadStore from 'misago/services/preload-store';
+import { module, test } from 'qunit';
+import startApp from '../helpers/start-app';
+
+var application;
+
+module('Acceptance: Routed Links Component', {
+  beforeEach: function() {
+    PreloadStore.set('staticUrl', '/static/');
+    application = startApp();
+  },
+  afterEach: function() {
+    Ember.run(application, 'destroy');
+    Ember.$.mockjax.clear();
+    PreloadStore.set('staticUrl', '/');
+  }
+});
+
+test('app link within component gets routed by ember', function(assert) {
+  assert.expect(1);
+
+  Ember.$.mockjax({
+    url: '/api/legal-pages/privacy-policy/',
+    status: 403,
+    responseText: {
+      'ban': {
+        'expires_on': null,
+        'message': {
+          'plain': 'You are banned. See /terms-of-service/.',
+          'html': '<p>You are banned. See <a class="posted-link" href="/terms-of-service/">/terms-of-service/</a>.</p>'
+        }
+      }
+    }
+  });
+
+  Ember.$.mockjax({
+    url: '/api/legal-pages/terms-of-service/',
+    responseText: {
+      'id': 'terms-of-service',
+      'title': 'Terms of service',
+      'link': '',
+      'body': '<p>Terms of service are working!</p>'
+    }
+  });
+
+  visit('/privacy-policy');
+  click('.error-message .posted-link');
+
+  andThen(function() {
+    assert.equal(currentPath(), 'terms-of-service');
+  });
+});

+ 61 - 0
misago/emberapp/tests/unit/components/routed-links-test.js

@@ -0,0 +1,61 @@
+import Ember from 'ember';
+import {
+  moduleFor,
+  test
+} from 'ember-qunit';
+
+moduleFor('component:routed-links', 'RoutedLinks component');
+
+test('it exists', function(assert) {
+  assert.expect(1);
+
+  var component = this.subject();
+  assert.ok(component);
+});
+
+test('cleanHref validates and cleans hrefs', function(assert) {
+  assert.expect(15);
+
+  var router = Ember.Object.create({
+    'rootURL': '/misago/'
+  });
+
+  var component = this.subject();
+  component.set('staticUrl', '/static/');
+  component.set('mediaUrl', '/media/');
+
+  var location = window.location;
+
+  // non-forbidden relative url passes
+  assert.equal(component.cleanHref(router, '/misago/some-url/'), '/misago/some-url/');
+
+  // protocol relative url passes
+  assert.equal(component.cleanHref(router, '//' + location.host + '/misago/some-url/'), '/misago/some-url/');
+
+  // whole url passes
+  assert.equal(component.cleanHref(router, 'http://' + location.host + '/misago/some-url/'), '/misago/some-url/');
+
+  // invalid app path fails
+  assert.ok(!component.cleanHref(router, '/django/some-url/'));
+
+  // invalid protocol fails
+  assert.ok(!component.cleanHref(router, 'https://' + location.host + '/misago/some-url/'));
+
+  // invalid host fails
+  assert.ok(!component.cleanHref(router, '//notlocalhost.com/misago/some-url/'));
+
+  // static/media/avatar-server urls fail
+  router.set('rootURL', '/');
+
+  assert.ok(!component.cleanHref(router, '/static/some-url/'));
+  assert.ok(!component.cleanHref(router, '/media/some-url/'));
+  assert.ok(!component.cleanHref(router, '/user-avatar/some-url/'));
+
+  assert.ok(!component.cleanHref(router, '//' + location.host + '/static/some-url/'));
+  assert.ok(!component.cleanHref(router, '//' + location.host + '/media/some-url/'));
+  assert.ok(!component.cleanHref(router, '//' + location.host + '/user-avatar/some-url/'));
+
+  assert.ok(!component.cleanHref(router, 'http://' + location.host + '/static/some-url/'));
+  assert.ok(!component.cleanHref(router, 'http://' + location.host + '/media/some-url/'));
+  assert.ok(!component.cleanHref(router, 'http://' + location.host + '/user-avatar/some-url/'));
+});