diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 3f58b758b..5332c555c 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -4,11 +4,16 @@ Changelog 0.8 (xx.xx.2014) ~~~~~~~~~~~~~~~~ + * Added logging for page operations * The save/publish/submit buttons on the page edit page now redirects the user back to the edit page instead of the explorer * Signal handlers for ``wagtail.wagtailsearch`` and ``wagtail.contrib.wagtailfrontendcache`` are now automatically registered when using Django 1.7 or above. (Tim Heap) * Fix: Replaced references of .username with .get_username() on users for better custom user model support (John-Scott Atlakson) * Fix: Unpinned dependency versions for six and requests to help prevent dependency conflicts * Fix: Fixed TypeError when getting embed HTML with oembed on Python 3 (John-Scott Atlakson) + * Fix: Made HTML whitelisting in rich text fields more robust at catching disallowed URL schemes such as "jav\tascript:" (Tim Heap) + * Fix: created_at timestamps on page revisions were not being preserved on page copy, causing revisions to get out of sequence + * Fix: When copying pages recursively, revisions of sub-pages were being copied regardless of the copy_revisions flag + * Fix: Updated the migration dependencies within the project template to ensure that Wagtail's own migrations consistently apply first. 0.7 (09.10.2014) ~~~~~~~~~~~~~~~~ diff --git a/docs/releases/0.8.rst b/docs/releases/0.8.rst index 80526919b..94cff5937 100644 --- a/docs/releases/0.8.rst +++ b/docs/releases/0.8.rst @@ -15,6 +15,7 @@ What's new Minor features ~~~~~~~~~~~~~~ + * Page operations (creation, publishing, copying etc) are now logged via Python's ``logging`` framework; to configure this, add a logger entry for ``'wagtail'`` or ``'wagtail.core'`` to the ``LOGGING`` setup in your settings file. * The save/publish/submit buttons on the page edit page now redirects the user back to the edit page instead of the explorer * Signal handlers for ``wagtail.wagtailsearch`` and ``wagtail.contrib.wagtailfrontendcache`` are now automatically registered when using Django 1.7 or above. @@ -25,6 +26,11 @@ Bug fixes * Replaced references of .username with .get_username() on users for better custom user model support * Unpinned dependency versions for six and requests to help prevent dependency conflicts * Fixed TypeError when getting embed HTML with oembed on Python 3 + * Made HTML whitelisting in rich text fields more robust at catching disallowed URL schemes such as ``jav\tascript:`` + * ``created_at`` timestamps on page revisions were not being preserved on page copy, causing revisions to get out of sequence + * When copying pages recursively, revisions of sub-pages were being copied regardless of the ``copy_revisions`` flag + * Updated the migration dependencies within the project template to ensure that Wagtail's own migrations consistently apply first + Upgrade considerations ====================== diff --git a/wagtail/project_template/core/migrations/0001_initial.py b/wagtail/project_template/core/migrations/0001_initial.py index 9bf12542d..77e0625d7 100644 --- a/wagtail/project_template/core/migrations/0001_initial.py +++ b/wagtail/project_template/core/migrations/0001_initial.py @@ -7,7 +7,7 @@ from django.db import models, migrations class Migration(migrations.Migration): dependencies = [ - ('wagtailcore', '0002_initial_data'), + ('wagtailcore', '__latest__'), ] operations = [ diff --git a/wagtail/wagtailadmin/forms.py b/wagtail/wagtailadmin/forms.py index b515a0fde..950a71f7f 100644 --- a/wagtail/wagtailadmin/forms.py +++ b/wagtail/wagtailadmin/forms.py @@ -40,9 +40,12 @@ class EmailLinkChooserWithLinkTextForm(forms.Form): class LoginForm(AuthenticationForm): username = forms.CharField( max_length=254, + widget=forms.TextInput(attrs={'tabindex': '1',}), ) password = forms.CharField( - widget=forms.PasswordInput(attrs={'placeholder': ugettext_lazy("Enter password")}), + widget=forms.PasswordInput(attrs={'placeholder': ugettext_lazy("Enter password"), + 'tabindex': '2', + }), ) def __init__(self, request=None, *args, **kwargs): diff --git a/wagtail/wagtailadmin/templates/wagtailadmin/login.html b/wagtail/wagtailadmin/templates/wagtailadmin/login.html index 49fad3dd1..42e4188ec 100644 --- a/wagtail/wagtailadmin/templates/wagtailadmin/login.html +++ b/wagtail/wagtailadmin/templates/wagtailadmin/login.html @@ -55,7 +55,7 @@ {% endcomment %}
  • - +
  • @@ -68,4 +68,4 @@ $('form input[name=username]').focus(); }) -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/wagtail/wagtailcore/migrations/0009_remove_auto_now_add_from_pagerevision_created_at.py b/wagtail/wagtailcore/migrations/0009_remove_auto_now_add_from_pagerevision_created_at.py new file mode 100644 index 000000000..3dbd7e409 --- /dev/null +++ b/wagtail/wagtailcore/migrations/0009_remove_auto_now_add_from_pagerevision_created_at.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('wagtailcore', '0008_populate_latest_revision_created_at'), + ] + + operations = [ + migrations.AlterField( + model_name='pagerevision', + name='created_at', + field=models.DateTimeField(), + ), + ] diff --git a/wagtail/wagtailcore/models.py b/wagtail/wagtailcore/models.py index a4d0bed92..fdc30af67 100644 --- a/wagtail/wagtailcore/models.py +++ b/wagtail/wagtailcore/models.py @@ -1,7 +1,7 @@ +import logging import warnings import six -from six import string_types from six import StringIO from six.moves.urllib.parse import urlparse @@ -9,7 +9,7 @@ from modelcluster.models import ClusterableModel, get_all_child_relations from django.db import models, connection, transaction from django.db.models import Q -from django.db.models.signals import pre_delete +from django.db.models.signals import pre_delete, post_delete from django.dispatch.dispatcher import receiver from django.http import Http404 from django.core.cache import cache @@ -37,6 +37,9 @@ from wagtail.wagtailsearch import index from wagtail.wagtailsearch.backends import get_search_backend +logger = logging.getLogger('wagtail.core') + + class SiteManager(models.Manager): def get_by_natural_key(self, hostname, port): return self.get(hostname=hostname, port=port) @@ -316,8 +319,9 @@ class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, index.Indexed @transaction.atomic # ensure that changes are only committed when we have updated all descendant URL paths, to preserve consistency def save(self, *args, **kwargs): update_descendant_url_paths = False + is_new = self.id is None - if self.id is None: + if is_new: # we are creating a record. If we're doing things properly, this should happen # through a treebeard method like add_child, in which case the 'path' field # has been set and so we can safely call get_parent @@ -341,6 +345,11 @@ class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, index.Indexed if Site.objects.filter(root_page=self).exists(): cache.delete('wagtail_site_root_paths') + # Log + if is_new: + cls = type(self) + logger.info("Page created: \"%s\" id=%d content_type=%s.%s path=%s", self.title, self.id, cls._meta.app_label, cls.__name__, self.url_path) + return result def _update_descendant_url_paths(self, old_url_path, new_url_path): @@ -412,6 +421,7 @@ class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, index.Indexed raise Http404 def save_revision(self, user=None, submitted_for_moderation=False, approved_go_live_at=None): + # Create revision revision = self.revisions.create( content_json=self.to_json(), user=user, @@ -422,6 +432,12 @@ class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, index.Indexed self.latest_revision_created_at = revision.created_at self.save(update_fields=['latest_revision_created_at']) + # Log + logger.info("Page edited: \"%s\" id=%d revision_id=%d", self.title, self.id, revision.id) + + if submitted_for_moderation: + logger.info("Page submitted for moderation: \"%s\" id=%d revision_id=%d", self.title, self.id, revision.id) + return revision def get_latest_revision(self): @@ -448,6 +464,8 @@ class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, index.Indexed page_unpublished.send(sender=self.specific_class, instance=self.specific) + logger.info("Page unpublished: \"%s\" id=%d", self.title, self.id) + self.revisions.update(approved_go_live_at=None) def get_context(self, request, *args, **kwargs): @@ -650,6 +668,9 @@ class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, index.Indexed new_self.save() new_self._update_descendant_url_paths(old_url_path, new_url_path) + # Log + logger.info("Page moved: \"%s\" id=%d path=%s", self.title, self.id, new_url_path) + def copy(self, recursive=False, to=None, update_attrs=None, copy_revisions=True): # Make a copy page_copy = Page.objects.get(id=self.id).specific @@ -689,10 +710,13 @@ class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, index.Indexed revision.page = page_copy revision.save() + # Log + logger.info("Page copied: \"%s\" id=%d from=%d", page_copy.title, page_copy.id, self.id) + # Copy child pages if recursive: for child_page in self.get_children(): - child_page.specific.copy(recursive=True, to=page_copy) + child_page.specific.copy(recursive=True, to=page_copy, copy_revisions=copy_revisions) return page_copy @@ -794,12 +818,10 @@ class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, index.Indexed return ['/'] def get_sitemap_urls(self): - latest_revision = self.get_latest_revision() - return [ { 'location': self.full_url, - 'lastmod': latest_revision.created_at if latest_revision else None + 'lastmod': self.latest_revision_created_at } ] @@ -899,6 +921,11 @@ def unpublish_page_before_delete(sender, instance, **kwargs): instance.unpublish(commit=False) +@receiver(post_delete, sender=Page) +def log_page_deletion(sender, instance, **kwargs): + logger.info("Page deleted: \"%s\" id=%d", instance.title, instance.id) + + class Orderable(models.Model): sort_order = models.IntegerField(null=True, blank=True, editable=False) sort_order_field = 'sort_order' @@ -917,7 +944,7 @@ class SubmittedRevisionsManager(models.Manager): class PageRevision(models.Model): page = models.ForeignKey('Page', related_name='revisions') submitted_for_moderation = models.BooleanField(default=False) - created_at = models.DateTimeField(auto_now_add=True) + created_at = models.DateTimeField() user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True) content_json = models.TextField() approved_go_live_at = models.DateTimeField(null=True, blank=True) @@ -926,6 +953,12 @@ class PageRevision(models.Model): submitted_revisions = SubmittedRevisionsManager() def save(self, *args, **kwargs): + # Set default value for created_at to now + # We cannot use auto_now_add as that will override + # any value that is set before saving + if self.created_at is None: + self.created_at = timezone.now() + super(PageRevision, self).save(*args, **kwargs) if self.submitted_for_moderation: # ensure that all other revisions of this page have the 'submitted for moderation' flag unset @@ -955,10 +988,12 @@ class PageRevision(models.Model): def approve_moderation(self): if self.submitted_for_moderation: + logger.info("Page moderation approved: \"%s\" id=%d revision_id=%d", self.page.title, self.page.id, self.id) self.publish() def reject_moderation(self): if self.submitted_for_moderation: + logger.info("Page moderation rejected: \"%s\" id=%d revision_id=%d", self.page.title, self.page.id, self.id) self.submitted_for_moderation = False self.save(update_fields=['submitted_for_moderation']) @@ -995,8 +1030,12 @@ class PageRevision(models.Model): if page.live: page_published.send(sender=page.specific_class, instance=page.specific) + logger.info("Page published: \"%s\" id=%d revision_id=%d", page.title, page.id, self.id) + elif page.go_live_at: + logger.info("Page scheduled for publish: \"%s\" id=%d revision_id=%d go_live_at=%s", page.title, page.id, self.id, page.go_live_at.isoformat()) + def __str__(self): - return '"' + unicode(self.page) + '" at ' + unicode(self.created_at) + return '"' + six.text_type(self.page) + '" at ' + six.text_type(self.created_at) PAGE_PERMISSION_TYPE_CHOICES = [ diff --git a/wagtail/wagtailcore/tests/test_page_model.py b/wagtail/wagtailcore/tests/test_page_model.py index 804b2c4e1..0b44e2ba0 100644 --- a/wagtail/wagtailcore/tests/test_page_model.py +++ b/wagtail/wagtailcore/tests/test_page_model.py @@ -443,6 +443,23 @@ class TestCopyPage(TestCase): # Check that the new revision is not submitted for moderation self.assertFalse(new_christmas_event.revisions.first().submitted_for_moderation) + def test_copy_page_copies_revisions_and_doesnt_change_created_at(self): + christmas_event = EventPage.objects.get(url_path='/home/events/christmas/') + christmas_event.save_revision(submitted_for_moderation=True) + + # Set the created_at of the revision to a time in the past + revision = christmas_event.get_latest_revision() + revision.created_at = datetime.datetime(2014, 1, 1) + revision.save() + + # Copy it + new_christmas_event = christmas_event.copy(update_attrs={'title': "New christmas event", 'slug': 'new-christmas-event'}) + + # Check that the created_at time is the same + christmas_event_created_at = christmas_event.get_latest_revision().created_at + new_christmas_event_created_at = new_christmas_event.get_latest_revision().created_at + self.assertEqual(christmas_event_created_at, new_christmas_event_created_at) + def test_copy_page_copies_revisions_and_doesnt_schedule(self): christmas_event = EventPage.objects.get(url_path='/home/events/christmas/') christmas_event.save_revision(approved_go_live_at=datetime.datetime(2014, 9, 16, 9, 12, 00, tzinfo=pytz.utc)) @@ -456,6 +473,19 @@ class TestCopyPage(TestCase): # Check that the new revision is not scheduled self.assertEqual(new_christmas_event.revisions.first().approved_go_live_at, None) + def test_copy_page_doesnt_copy_revisions_if_told_not_to_do_so(self): + christmas_event = EventPage.objects.get(url_path='/home/events/christmas/') + christmas_event.save_revision() + + # Copy it + new_christmas_event = christmas_event.copy(update_attrs={'title': "New christmas event", 'slug': 'new-christmas-event'}, copy_revisions=False) + + # Check that the revisions weren't copied + self.assertEqual(new_christmas_event.revisions.count(), 0, "Revisions were copied") + + # Check that the revisions weren't removed from old page + self.assertEqual(christmas_event.revisions.count(), 1, "Revisions were removed from the original page") + def test_copy_page_copies_child_objects_with_nonspecific_class(self): # Get chrismas page as Page instead of EventPage christmas_event = Page.objects.get(url_path='/home/events/christmas/') @@ -519,6 +549,23 @@ class TestCopyPage(TestCase): # Check that the revisions weren't removed from old page self.assertEqual(old_christmas_event.specific.revisions.count(), 1, "Revisions were removed from the original page") + def test_copy_page_copies_recursively_but_doesnt_copy_revisions_if_told_not_to_do_so(self): + events_index = EventIndex.objects.get(url_path='/home/events/') + old_christmas_event = events_index.get_children().filter(slug='christmas').first() + old_christmas_event.save_revision() + + # Copy it + new_events_index = events_index.copy(recursive=True, update_attrs={'title': "New events index", 'slug': 'new-events-index'}, copy_revisions=False) + + # Get christmas event + new_christmas_event = new_events_index.get_children().filter(slug='christmas').first() + + # Check that the revisions weren't copied + self.assertEqual(new_christmas_event.specific.revisions.count(), 0, "Revisions were copied") + + # Check that the revisions weren't removed from old page + self.assertEqual(old_christmas_event.specific.revisions.count(), 1, "Revisions were removed from the original page") + class TestSubpageTypeBusinessRules(TestCase): def test_allowed_subpage_types(self): diff --git a/wagtail/wagtailcore/tests/test_whitelist.py b/wagtail/wagtailcore/tests/test_whitelist.py index d96100382..6589ecab5 100644 --- a/wagtail/wagtailcore/tests/test_whitelist.py +++ b/wagtail/wagtailcore/tests/test_whitelist.py @@ -17,6 +17,13 @@ class TestCheckUrl(TestCase): def test_disallowed_url_scheme(self): self.assertFalse(bool(check_url("invalid://url"))) + def test_crafty_disallowed_url_scheme(self): + """ + Some URL parsers do not parse 'jav\tascript:' as a valid scheme. + Browsers, however, do. The checker needs to catch these crafty schemes + """ + self.assertFalse(bool(check_url("jav\tascript:alert('XSS')"))) + class TestAttributeRule(TestCase): def setUp(self): diff --git a/wagtail/wagtailcore/whitelist.py b/wagtail/wagtailcore/whitelist.py index dba8982fd..356be9ecb 100644 --- a/wagtail/wagtailcore/whitelist.py +++ b/wagtail/wagtailcore/whitelist.py @@ -2,19 +2,33 @@ A generic HTML whitelisting engine, designed to accommodate subclassing to override specific rules. """ -from six.moves.urllib.parse import urlparse +import re + from bs4 import BeautifulSoup, NavigableString, Tag -ALLOWED_URL_SCHEMES = ['', 'http', 'https', 'ftp', 'mailto', 'tel'] +ALLOWED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'tel'] + +PROTOCOL_RE = re.compile("^[a-z0-9][-+.a-z0-9]*:") def check_url(url_string): - # TODO: more paranoid checks (urlparse doesn't catch - # "jav\tascript:alert('XSS')") - url = urlparse(url_string) - return (url_string if url.scheme in ALLOWED_URL_SCHEMES else None) + # Remove control characters and other disallowed characters + # Browsers sometimes ignore these, so that 'jav\tascript:alert("XSS")' + # is treated as a valid javascript: link + + unescaped = url_string.lower() + unescaped = unescaped.replace("<", "<") + unescaped = unescaped.replace(">", ">") + unescaped = unescaped.replace("&", "&") + unescaped = re.sub("[`\000-\040\177-\240\s]+", '', unescaped) + unescaped = unescaped.replace("\ufffd", "") + if PROTOCOL_RE.match(unescaped): + protocol = unescaped.split(':', 1)[0] + if protocol not in ALLOWED_URL_SCHEMES: + return None + return url_string def attribute_rule(allowed_attrs):