CSRF protection tests
=====================

Some background & an example attack
-----------------------------------

The following are integration tests trying to make sure the CSRF
protection in Plone 3.1 actually works. Plone 3.1 comes with the
packages implemented for `PLIP 224: CSRF protection framework
<http://plone.org/products/plone/roadmap/224>`_, so they already
should have been set up. This can be checked indirectly by making
sure the authenticator view exists:

  >>> app = layer['app']
  >>> portal = layer['portal']
  >>> portal.restrictedTraverse('@@authenticator')
  <Products.Five...AuthenticatorView object at ...>

So far, so good, but the important bit about this is that it should protect
Plone from CSRF attacks, so we try to test that. A CSRF attack works by
having an already logged in portal member, preferably with administrator
rights, browse a web page of another (or even the same) site and trick them
into making a malicious request by clicking a link or submitting a form using
their credentials.

The typical attack would use an invisible `<iframe>` with a form and some
javascript for auto-submitting it on load. Since the testbrowser doesn't
support javascript, the submit button needs to be hit manually in this test,
but that shouldn't make a difference in terms of testing if the protection
framework actually works.

So first we need a logged in user with manager rights:

  >>> import transaction; transaction.commit()
  >>> from plone.app.testing import SITE_OWNER_NAME
  >>> from plone.app.testing import SITE_OWNER_PASSWORD
  >>> from plone.app.testing import TEST_USER_NAME
  >>> from plone.app.testing import TEST_USER_PASSWORD
  >>> from plone.testing.zope import Browser
  >>> browser = Browser(app)
  >>> browser.handleErrors = False
  >>> browser.open('http://nohost/plone/login_form')
  >>> browser.getControl(name='__ac_name').value = SITE_OWNER_NAME
  >>> browser.getControl(name='__ac_password').value = SITE_OWNER_PASSWORD
  >>> browser.getControl('Log in').click()

Coincidentally the portal happens to be configured for users to get to pick
their own passwords. Again, this is only relevant for this test as otherwise
outgoing mails would have to be handled making things unnecessarily
complicated:

  >>> from zope.component import getUtility
  >>> from plone.base.interfaces import IMailSchema
  >>> from plone.base.interfaces import ISecuritySchema
  >>> from plone.registry.interfaces import IRegistry
  >>> registry = getUtility(IRegistry)
  >>> mail_settings = registry.forInterface(IMailSchema, prefix='plone')
  >>> mail_settings.smtp_host = u'localhost'
  >>> mail_settings.email_from_address = 'foo@bar.com'
  >>> security_settings = registry.forInterface(ISecuritySchema, prefix='plone')
  >>> security_settings.enable_user_pwd_choice = True
  >>> import transaction; transaction.commit()

We need to know what the register button is called, it might vary with form
frameworks:

  >>> browser.open('http://nohost/plone/@@new-user')
  >>> buttonName = browser.getControl('Register').name

Also, the form used for the attack needs to be created. Normally this would
happen on another domain, but for the purposes of this test it will just
be a fake form submit. Now let's say with some social engineering the user who
logged in above is lured to take a look at the "important" information and
unsuspectingly even clicks the button presented:

  >>> data = '&'.join([
  ...     'form.widgets.fullname=John Doe',
  ...     'form.widgets.username=john',
  ...     'form.widgets.email=john@spam-factory.com',
  ...     'form.widgets.password=johnnyrulez',
  ...     'form.widgets.password_ctl=johnnyrulez',
  ...     '{}=Register',
  ...     'form.submitted=1']).format(buttonName)
  >>> browser.open('http://nohost/plone/@@new-user', data)
  Traceback (most recent call last):
  ...
  zExceptions.Forbidden: Form authenticator is invalid.
  >>> bool(portal.acl_users.getUser('john'))
  False

So, he was protected from the attack — phew.  But of course, he should still
be able to add new users himself if he wishes so:

  >>> browser.open('http://nohost/plone/@@new-user')
  >>> browser.getControl(name='_authenticator', index=0)
  <Control name='_authenticator' type='hidden'>

  >>> browser.getControl('Full Name').value = 'John Doe'
  >>> browser.getControl('User Name').value = 'john'
  >>> browser.getControl('Email').value = 'john@foo-security.com'
  >>> browser.getControl('Password').value = 'correct horse battery staple'
  >>> browser.getControl('Confirm password').value = 'correct horse battery staple'
  >>> browser.getControl('Register').click()
  >>> 'User added.' in browser.contents
  True
  >>> bool(portal.acl_users.getUser('john'))
  True
  >>> browser.open('http://nohost/plone/logout')


More tests: User Preferences
----------------------------

Now that the basics have been tested and demonstrated in detail, the remainder
of this test will try to swiftly cover all forms that need protection, or
rather all of them which are supposed to get it at the moment.  Let's start
with the personal preferences:

  >>> browser.open('http://nohost/plone/login_form')
  >>> browser.getControl(name='__ac_name').value = TEST_USER_NAME
  >>> browser.getControl(name='__ac_password').value = TEST_USER_PASSWORD
  >>> browser.getControl('Log in').click()
  >>> browser.open('http://nohost/plone/@@personal-information')
  >>> browser.getControl('Full Name').value = 'John Doe'
  >>> browser.getControl('Email').value = 'john@foo-security.com'
  >>> browser.getControl('Save').click()
  >>> browser.contents
  '...Info...Changes saved...'

So this works, but we should also check if the form is actually using an
authenticator token as well.  The easiest way is to render the already
existing invalid, in which case submitting should yield an error:

  >>> browser.goBack()
  >>> browser.getControl(name='_authenticator', index=0).value = 'invalid!'
  >>> browser.getControl('Full Name').value = 'John Doe'
  >>> browser.getControl('Email').value = 'john@foo-security.com'
  >>> browser.getControl('Save').click()
  Traceback (most recent call last):
  ...
  zExceptions.Forbidden: Form authenticator is invalid.

Next up is the password form.  Well, technically an attacker would need to
know the current passwort to exploit this, but we'll check nevertheless:

  >>> browser.open('http://nohost/plone/@@change-password')
  >>> browser.getControl('Current password').value = TEST_USER_PASSWORD
  >>> browser.getControl('New password').value = 'new password'
  >>> browser.getControl('Confirm password').value = 'new password'
  >>> browser.getControl('Change Password').click()
  >>> browser.contents
  '...Info...Password changed...'

  >>> browser.goBack()
  >>> browser.getControl(name='_authenticator', index=0).value = 'invalid!'
  >>> browser.getControl('Current password').value = 'new password'
  >>> browser.getControl('New password').value = 'newer password'
  >>> browser.getControl('Confirm password').value = 'newer password'
  >>> browser.getControl('Change Password').click()
  Traceback (most recent call last):
  ...
  zExceptions.Forbidden: Form authenticator is invalid.
  >>> browser.open('http://nohost/plone/logout')

On the admin side of things there's also the user preferences
as its z3cform the portrait gets always modified because is IObject:

  >>> browser.open('http://nohost/plone/login_form')
  >>> browser.getControl(name='__ac_name').value = SITE_OWNER_NAME
  >>> browser.getControl(name='__ac_password').value = SITE_OWNER_PASSWORD
  >>> browser.getControl('Log in').click()
  >>> browser.open('http://nohost/plone/@@user-information?userid=%s' % SITE_OWNER_NAME)
  >>> browser.getControl('Full Name').value = 'John Doe'
  >>> browser.getControl('Email').value = 'john@foo-security.com'
  >>> browser.getControl('Save').click()
  >>> browser.contents
  '...Info...Changes saved...'

  >>> browser.goBack()
  >>> browser.getControl(name='_authenticator', index=0).value = 'invalid!'
  >>> browser.getControl('Full Name').value = 'John Doe'
  >>> browser.getControl('Email').value = 'john.doe@foo-security.net'
  >>> browser.getControl('Save').click()
  Traceback (most recent call last):
  ...
  zExceptions.Forbidden: Form authenticator is invalid.


More tests: Managing Users & Groups
-----------------------------------

Make sure users and roles can be managed through the control panel. First
we need to alter the security settings so that no email roundtrip is required
anymore (which at the same time tests the security control panel):

  >>> browser.open('http://nohost/plone/@@security-controlpanel')
  >>> browser.getControl(name='form.widgets.enable_user_pwd_choice:list').value = ['selected']
  >>> browser.getControl('Save').click()

  >>> browser.open('http://nohost/plone/@@usergroup-userprefs')
  >>> browser.getLink('Add New User').click()
  >>> browser.getControl('User Name').value = 'johnny'
  >>> browser.getControl('Email').value = 'john@foo-security.com'
  >>> browser.getControl('Password').value = TEST_USER_PASSWORD
  >>> browser.getControl('Confirm password').value = TEST_USER_PASSWORD
  >>> browser.getControl('Register').click()
  >>> browser.contents
  '...Info...User added...'

  >>> browser.getLink('Users').click()
  >>> browser.getControl('Show all').click()
  >>> browser.getControl(name='users.roles:list:records').value = ['Manager'] * 3
  >>> browser.getControl('Save').click()
  >>> browser.contents
  '...Info...Changes applied...'

  >>> browser.goBack()
  >>> browser.getControl(name='_authenticator', index=0).value = 'invalid!'
  >>> browser.getControl(name='users.roles:list:records').value = ['Manager'] * 3
  >>> browser.getControl('Save').click()
  Traceback (most recent call last):
  ...
  zExceptions.Forbidden: Form authenticator is invalid.

Let's also try adding that user to a group:

  >>> browser.open('http://nohost/plone/@@usergroup-userprefs')
  >>> browser.getControl('Show all').click()
  >>> browser.getLink('johnny').click()
  >>> browser.getLink('Group Memberships').click()
  >>> browser.getControl(name='add:list').value = ['Administrators']
  >>> browser.getControl('Add user to selected groups').click()
  >>> browser.contents
  '...johnny...
   ...Current group memberships...
   ...Administrators...
   ...Authenticated Users...'

  >>> browser.getControl(name='add:list').value = ['Reviewers']
  >>> browser.getControl(name='_authenticator', index=0).value = 'invalid!'
  >>> browser.getControl('Add user to selected groups').click()
  Traceback (most recent call last):
  ...
  zExceptions.Forbidden: Form authenticator is invalid.

There's an alternative way to adding a user to a group in which the group in
question is selected first and the user can then be added via the "Group
Members" tab:

  >>> browser.open('http://nohost/plone/@@usergroup-groupprefs')
  >>> browser.getLink('Reviewers').click()
  >>> browser.getControl('Show all').click()

  >>> browser.getControl(name='add:list').getControl(value='johnny').selected = True
  >>> browser.getControl('Add selected groups and users to this group').click()
  >>> browser.contents
  '...Info...Changes made...
   ...Group: Reviewers...
   ...Current group members...
   ...johnny...john@foo-security.com...
   ...Remove selected groups / users...'

  >>> browser.getControl('Show all').click()
  >>> browser.getControl(name='_authenticator', index=0).value = 'invalid!'
  >>> browser.getControl(name='add:list').getControl(value='john').selected = True
  >>> browser.getControl('Add selected groups and users to this group').click()
  Traceback (most recent call last):
  ...
  zExceptions.Forbidden: Form authenticator is invalid.


More tests: Object Actions
--------------------------

Plone's "object actions" should also be protected.  Let's check renaming
first:

  >>> browser.open('http://nohost/plone/')
  >>> browser.getLink(url='++add++Folder').click()
  >>> browser.getControl('Title').value = 'a folder'
  >>> browser.getControl('Save').click()
  >>> browser.url
  'http://nohost/plone/a-folder/view'

Reopen URL to clean up HTTP_REFERRER

  >>> browser.open('http://nohost/plone/a-folder/')

Now rename

  >>> browser.getLink('Rename').click()
  >>> browser.getControl(name='_authenticator', index=0).value = 'invalidxx!'
  >>> browser.getControl('New Short Name').value = 'folder'
  >>> browser.getControl(name='form.buttons.Rename').click()
  Traceback (most recent call last):
  ...
  zExceptions.Forbidden: Form authenticator is invalid.

  >>> browser.open('http://nohost/plone/a-folder/')
  >>> browser.getLink('Rename').click()
  >>> browser.getControl('New Short Name').value = 'folder'
  >>> browser.getControl(name='form.buttons.Rename').click()
  >>> browser.url.strip()
  'http://nohost/plone/folder'
  >>> browser.contents
  '...Info...Renamed...a-folder...to...folder...'

"Sharing" the item is next:

  >>> browser.getLink('Sharing').click()
  >>> browser.url
  'http://nohost/plone/folder/@@sharing?_auth...'
  >>> browser.getControl(name='entries.role_Editor:records').value
  []

Change the value of the second _authenticator and check for Exception

  >>> browser.getControl(name='_authenticator', index=1).value = 'invalid!'
  >>> browser.getControl(name='entries.role_Editor:records').value = ['True']
  >>> browser.getControl('Save').click()
  Traceback (most recent call last):
  ...
  zExceptions.Forbidden

  >>> browser.getLink('Sharing').click()
  >>> browser.getControl(name='entries.role_Editor:records').value = ['True']
  >>> browser.getControl('Save').click()
  >>> browser.url
  'http://nohost/plone/folder/@@sharing'
  >>> browser.contents
  '...Info...Changes saved...'
  >>> browser.getControl(name='entries.role_Editor:records').value
  ['True']

And finally removing the item again:

  >>> browser.getLink('View').click()
  >>> browser.getLink('Delete').click()
  >>> browser.url
  'http://nohost/plone/folder/delete_confirmation?_auth...'
  >>> browser.getControl(name='_authenticator', index=0).value = 'invalid!'
  >>> browser.getControl('Delete').click()
  Traceback (most recent call last):
  ...
  zExceptions.Forbidden: Form authenticator is invalid.

  >>> browser.reload()
  >>> browser.getControl('Delete').click()
  >>> browser.url
  'http://nohost/plone'
  >>> browser.contents
  '...Info...a folder has been deleted...'


More tests: Managing Workflow State
-----------------------------------

Changing the workflow state of object, i.e. submitting and publishing them etc
also needs to be protected.  Let's create a folder again to test this:

  >>> browser.open('http://nohost/plone/')
  >>> browser.getLink(url='++add++Folder').click()
  >>> browser.getControl('Title').value = 'another folder'
  >>> browser.getControl('Save').click()
  >>> browser.url
  'http://nohost/plone/another-folder/view'

Reopen URL to clean up HTTP_REFERRER

  >>> browser.open('http://nohost/plone/another-folder/')

Now we submit the document for review.  Unfortunately, this cannot be easily
protected, since it's not using a form and hence the link itself would have to
contain the authenticator token.  However, this a bad idea because the token
could easily get "lost".  Changing the workflow state using the "Advanced"
publishing process can be protected, though, so let's try this instead:

  >>> browser.getLink('Advanced...').click()
  >>> browser.url
  'http://nohost/plone/another-folder/content_status_history'
  >>> browser.getControl('Publish').selected = True
  >>> browser.getControl('Save').click()
  >>> browser.contents
  '...Info...Item state changed...'

  >>> browser.getLink('Advanced...').click()
  >>> browser.getControl(name='_authenticator', index=0).value = 'invalid!'
  >>> browser.getControl('Retract').selected = True
  >>> browser.getControl('Save').click()
  Traceback (most recent call last):
  ...
  zExceptions.Forbidden: Form authenticator is invalid.


More tests: Plone Control Panel
-------------------------------

Some parts of the control panel have already been tested, but the "configlets"
haven't. Luckily most of them are using the same form handlers and template,
so testing one of them already makes sure the protection works in most cases:

  >>> browser.open('http://nohost/plone/@@overview-controlpanel')
  >>> browser.getLink(url="/@@security-controlpanel").click()
  >>> browser.getControl(name='form.widgets.enable_self_reg:list').value
  []
  >>> browser.getControl(name='form.widgets.enable_self_reg:list').value = ['selected']
  >>> browser.getControl('Save').click()
  >>> browser.contents
  '...Info...Changes saved...'

  >>> browser.getLink(url="/@@security-controlpanel").click()
  >>> browser.getControl(name='_authenticator', index=0).value = 'invalid!'
  >>> browser.getControl(name='form.widgets.enable_self_reg:list').value = []

browser.getControl('Save').click()
Traceback (most recent call last):
...
zExceptions.Forbidden: Form authenticator is invalid.

Exceptions to the rule is the "Maintenance" configlet, which is tested
separately. The "Maintenance" configlet has some special security
limitations, which is why we need to log in as the portal owner first:

  >>> app_browser = Browser(app)
  >>> app_browser.handleErrors = False
  >>> app.acl_users.userFolderAddUser('app', TEST_USER_PASSWORD, ['Manager'], [])
  >>> from plone.testing import zope
  >>> zope.logout()
  >>> zope.login(app['acl_users'], 'app')
  >>> import transaction; transaction.commit()
  >>> app_browser.addHeader('Authorization', f'Basic app:{TEST_USER_PASSWORD}')
  >>> app_browser.open('http://nohost/plone/@@overview-controlpanel')
  >>> app_browser.getLink('Maintenance').click()
  >>> app_browser.getControl(name='_authenticator', index=0).value = 'invalid!'
  >>> app_browser.getControl(name='_authenticator', index=1).value = 'invalid!'
  >>> app_browser.getControl('Shut down').click()
  Traceback (most recent call last):
  ...
  zExceptions.Forbidden: Form authenticator is invalid.
