aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--mediagoblin/config_spec.ini2
-rw-r--r--mediagoblin/gmg_commands/__init__.py6
-rw-r--r--mediagoblin/middleware/__init__.py1
-rw-r--r--mediagoblin/middleware/csrf.py135
-rw-r--r--mediagoblin/storage/mountstorage.py2
-rw-r--r--mediagoblin/templates/mediagoblin/auth/login.html1
-rw-r--r--mediagoblin/templates/mediagoblin/auth/register.html1
-rw-r--r--mediagoblin/templates/mediagoblin/edit/attachments.html1
-rw-r--r--mediagoblin/templates/mediagoblin/edit/edit.html1
-rw-r--r--mediagoblin/templates/mediagoblin/edit/edit_profile.html1
-rw-r--r--mediagoblin/templates/mediagoblin/submit/start.html1
-rw-r--r--mediagoblin/templates/mediagoblin/test_submit.html1
-rw-r--r--mediagoblin/templates/mediagoblin/user_pages/media.html1
-rw-r--r--mediagoblin/templates/mediagoblin/user_pages/media_confirm_delete.html1
-rw-r--r--mediagoblin/tests/test_csrf_middleware.py69
-rw-r--r--mediagoblin/tools/template.py2
-rw-r--r--paste.ini2
17 files changed, 224 insertions, 4 deletions
diff --git a/mediagoblin/config_spec.ini b/mediagoblin/config_spec.ini
index 0801b39e..298a6951 100644
--- a/mediagoblin/config_spec.ini
+++ b/mediagoblin/config_spec.ini
@@ -41,6 +41,8 @@ celery_setup_elsewhere = boolean(default=False)
# source files for a media file but can also be a HUGE security risk.
allow_attachments = boolean(default=False)
+# Cookie stuff
+csrf_cookie_name = string(default='mediagoblin_nonce')
[storage:publicstore]
storage_class = string(default="mediagoblin.storage.filestorage:BasicFileStorage")
diff --git a/mediagoblin/gmg_commands/__init__.py b/mediagoblin/gmg_commands/__init__.py
index 0071c65b..92ae840e 100644
--- a/mediagoblin/gmg_commands/__init__.py
+++ b/mediagoblin/gmg_commands/__init__.py
@@ -16,7 +16,7 @@
import argparse
-from mediagoblin import util as mg_util
+from mediagoblin.tools.common import import_component
SUBCOMMAND_MAP = {
@@ -67,8 +67,8 @@ def main_cli():
else:
subparser = subparsers.add_parser(command_name)
- setup_func = mg_util.import_component(command_struct['setup'])
- exec_func = mg_util.import_component(command_struct['func'])
+ setup_func = import_component(command_struct['setup'])
+ exec_func = import_component(command_struct['func'])
setup_func(subparser)
diff --git a/mediagoblin/middleware/__init__.py b/mediagoblin/middleware/__init__.py
index 586debbf..05325ee5 100644
--- a/mediagoblin/middleware/__init__.py
+++ b/mediagoblin/middleware/__init__.py
@@ -16,4 +16,5 @@
ENABLED_MIDDLEWARE = (
'mediagoblin.middleware.noop:NoOpMiddleware',
+ 'mediagoblin.middleware.csrf:CsrfMiddleware',
)
diff --git a/mediagoblin/middleware/csrf.py b/mediagoblin/middleware/csrf.py
new file mode 100644
index 00000000..44b799d5
--- /dev/null
+++ b/mediagoblin/middleware/csrf.py
@@ -0,0 +1,135 @@
+# GNU MediaGoblin -- federated, autonomous media hosting
+# Copyright (C) 2011 MediaGoblin contributors. See AUTHORS.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import hashlib
+import random
+
+from webob.exc import HTTPForbidden
+from wtforms import Form, HiddenField, validators
+
+from mediagoblin import mg_globals
+
+# Use the system (hardware-based) random number generator if it exists.
+# -- this optimization is lifted from Django
+if hasattr(random, 'SystemRandom'):
+ randrange = random.SystemRandom().randrange
+else:
+ randrange = random.randrange
+
+
+class CsrfForm(Form):
+ """Simple form to handle rendering a CSRF token and confirming it
+ is included in the POST."""
+
+ csrf_token = HiddenField("",
+ [validators.Required()])
+
+
+def render_csrf_form_token(request):
+ """Render the CSRF token in a format suitable for inclusion in a
+ form."""
+
+ form = CsrfForm(csrf_token=request.environ['CSRF_TOKEN'])
+
+ return form.csrf_token
+
+
+class CsrfMiddleware(object):
+ """CSRF Protection Middleware
+
+ Adds a CSRF Cookie to responses and verifies that it is present
+ and matches the form token for non-safe requests.
+ """
+
+ MAX_CSRF_KEY = 2 << 63
+ SAFE_HTTP_METHODS = ("GET", "HEAD", "OPTIONS", "TRACE")
+
+ def __init__(self, mg_app):
+ self.app = mg_app
+
+ def process_request(self, request):
+ """For non-safe requests, confirm that the tokens are present
+ and match.
+ """
+
+ # get the token from the cookie
+ try:
+ request.environ['CSRF_TOKEN'] = \
+ request.cookies[mg_globals.app_config['csrf_cookie_name']]
+
+ except KeyError, e:
+ # if it doesn't exist, make a new one
+ request.environ['CSRF_TOKEN'] = self._make_token(request)
+
+ # if this is a non-"safe" request (ie, one that could have
+ # side effects), confirm that the CSRF tokens are present and
+ # valid
+ if request.method not in self.SAFE_HTTP_METHODS \
+ and ('gmg.verify_csrf' in request.environ or
+ 'paste.testing' not in request.environ):
+
+ return self.verify_tokens(request)
+
+ def process_response(self, request, response):
+ """Add the CSRF cookie to the response if needed and set Vary
+ headers.
+ """
+
+ # set the CSRF cookie
+ response.set_cookie(
+ mg_globals.app_config['csrf_cookie_name'],
+ request.environ['CSRF_TOKEN'],
+ max_age=60 * 60 * 24 * 7 * 52,
+ path='/',
+ domain=mg_globals.app_config.get('csrf_cookie_domain', None),
+ secure=(request.scheme.lower() == 'https'),
+ httponly=True)
+
+ # update the Vary header
+ response.vary = (response.vary or []) + ['Cookie']
+
+ def _make_token(self, request):
+ """Generate a new token to use for CSRF protection."""
+
+ return hashlib.md5("%s%s" %
+ (randrange(0, self.MAX_CSRF_KEY),
+ randrange(0, self.MAX_CSRF_KEY))).hexdigest()
+
+ def verify_tokens(self, request):
+ """Verify that the CSRF Cookie exists and that it matches the
+ form value."""
+
+ # confirm the cookie token was presented
+ cookie_token = request.cookies.get(
+ mg_globals.app_config['csrf_cookie_name'],
+ None)
+
+ if cookie_token is None:
+ # the CSRF cookie must be present in the request
+ return HTTPForbidden()
+
+ # get the form token and confirm it matches
+ form = CsrfForm(request.POST)
+ if form.validate():
+ form_token = form.csrf_token.data
+
+ if form_token == cookie_token:
+ # all's well that ends well
+ return
+
+ # either the tokens didn't match or the form token wasn't
+ # present; either way, the request is denied
+ return HTTPForbidden()
diff --git a/mediagoblin/storage/mountstorage.py b/mediagoblin/storage/mountstorage.py
index 6adb7a0d..7239931f 100644
--- a/mediagoblin/storage/mountstorage.py
+++ b/mediagoblin/storage/mountstorage.py
@@ -14,7 +14,7 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
-from medigoblin.storage import StorageInterface, clean_listy_filepath
+from mediagoblin.storage import StorageInterface, clean_listy_filepath
class MountStorage(StorageInterface):
diff --git a/mediagoblin/templates/mediagoblin/auth/login.html b/mediagoblin/templates/mediagoblin/auth/login.html
index 3926a1df..61c5a203 100644
--- a/mediagoblin/templates/mediagoblin/auth/login.html
+++ b/mediagoblin/templates/mediagoblin/auth/login.html
@@ -22,6 +22,7 @@
{% block mediagoblin_content %}
<form action="{{ request.urlgen('mediagoblin.auth.login') }}"
method="POST" enctype="multipart/form-data">
+ {{ csrf_token }}
<div class="grid_6 prefix_1 suffix_1 form_box">
<h1>{% trans %}Log in{% endtrans %}</h1>
{% if login_failed %}
diff --git a/mediagoblin/templates/mediagoblin/auth/register.html b/mediagoblin/templates/mediagoblin/auth/register.html
index e72b3a52..25b68058 100644
--- a/mediagoblin/templates/mediagoblin/auth/register.html
+++ b/mediagoblin/templates/mediagoblin/auth/register.html
@@ -26,6 +26,7 @@
<div class="grid_6 prefix_1 suffix_1 form_box">
<h1>{% trans %}Create an account!{% endtrans %}</h1>
{{ wtforms_util.render_divs(register_form) }}
+ {{ csrf_token }}
<div class="form_submit_buttons">
<input type="submit" value="{% trans %}Create{% endtrans %}"
class="button" />
diff --git a/mediagoblin/templates/mediagoblin/edit/attachments.html b/mediagoblin/templates/mediagoblin/edit/attachments.html
index 63b06581..d8b55f58 100644
--- a/mediagoblin/templates/mediagoblin/edit/attachments.html
+++ b/mediagoblin/templates/mediagoblin/edit/attachments.html
@@ -49,6 +49,7 @@
<div class="form_submit_buttons">
<a href="{{ media.url_for_self(request.urlgen) }}">Cancel</a>
<input type="submit" value="Save changes" class="button" />
+ {{ csrf_token }}
</div>
</div>
</form>
diff --git a/mediagoblin/templates/mediagoblin/edit/edit.html b/mediagoblin/templates/mediagoblin/edit/edit.html
index 8c4e2efb..b4b3be85 100644
--- a/mediagoblin/templates/mediagoblin/edit/edit.html
+++ b/mediagoblin/templates/mediagoblin/edit/edit.html
@@ -35,6 +35,7 @@
<div class="form_submit_buttons">
<a href="{{ media.url_for_self(request.urlgen) }}">{% trans %}Cancel{% endtrans %}</a>
<input type="submit" value="{% trans %}Save changes{% endtrans %}" class="button" />
+ {{ csrf_token }}
</div>
</div>
</form>
diff --git a/mediagoblin/templates/mediagoblin/edit/edit_profile.html b/mediagoblin/templates/mediagoblin/edit/edit_profile.html
index 464c663d..93b2a792 100644
--- a/mediagoblin/templates/mediagoblin/edit/edit_profile.html
+++ b/mediagoblin/templates/mediagoblin/edit/edit_profile.html
@@ -33,6 +33,7 @@
{{ wtforms_util.render_divs(form) }}
<div class="form_submit_buttons">
<input type="submit" value="{% trans %}Save changes{% endtrans %}" class="button" />
+ {{ csrf_token }}
</div>
</div>
</form>
diff --git a/mediagoblin/templates/mediagoblin/submit/start.html b/mediagoblin/templates/mediagoblin/submit/start.html
index f2e844df..7bc6ff45 100644
--- a/mediagoblin/templates/mediagoblin/submit/start.html
+++ b/mediagoblin/templates/mediagoblin/submit/start.html
@@ -26,6 +26,7 @@
<h1>{% trans %}Submit yer media{% endtrans %}</h1>
{{ wtforms_util.render_divs(submit_form) }}
<div class="form_submit_buttons">
+ {{ csrf_token }}
<input type="submit" value="{% trans %}Submit{% endtrans %}" class="button" />
</div>
</div>
diff --git a/mediagoblin/templates/mediagoblin/test_submit.html b/mediagoblin/templates/mediagoblin/test_submit.html
index 78b88ae8..190b9ac3 100644
--- a/mediagoblin/templates/mediagoblin/test_submit.html
+++ b/mediagoblin/templates/mediagoblin/test_submit.html
@@ -26,6 +26,7 @@
<tr>
<td></td>
<td><input type="submit" value="submit" class="button" /></td>
+ {{ csrf_token }}
</tr>
</table>
</form>
diff --git a/mediagoblin/templates/mediagoblin/user_pages/media.html b/mediagoblin/templates/mediagoblin/user_pages/media.html
index 442bef6d..433f74dc 100644
--- a/mediagoblin/templates/mediagoblin/user_pages/media.html
+++ b/mediagoblin/templates/mediagoblin/user_pages/media.html
@@ -72,6 +72,7 @@
{{ wtforms_util.render_divs(comment_form) }}
<div class="form_submit_buttons">
<input type="submit" value="{% trans %}Post comment!{% endtrans %}" class="button" />
+ {{ csrf_token }}
</div>
</form>
{% endif %}
diff --git a/mediagoblin/templates/mediagoblin/user_pages/media_confirm_delete.html b/mediagoblin/templates/mediagoblin/user_pages/media_confirm_delete.html
index 01323a6e..dd6923a9 100644
--- a/mediagoblin/templates/mediagoblin/user_pages/media_confirm_delete.html
+++ b/mediagoblin/templates/mediagoblin/user_pages/media_confirm_delete.html
@@ -48,6 +48,7 @@
{# TODO: This isn't a button really... might do unexpected things :) #}
<a class="cancel_link" href="{{ media.url_for_self(request.urlgen) }}">{% trans %}Cancel{% endtrans %}</a>
<input type="submit" value="{% trans %}Delete Permanently{% endtrans %}" class="button" />
+ {{ csrf_token }}
</div>
</div>
</form>
diff --git a/mediagoblin/tests/test_csrf_middleware.py b/mediagoblin/tests/test_csrf_middleware.py
new file mode 100644
index 00000000..cf03fe58
--- /dev/null
+++ b/mediagoblin/tests/test_csrf_middleware.py
@@ -0,0 +1,69 @@
+# GNU MediaGoblin -- federated, autonomous media hosting
+# Copyright (C) 2011 MediaGoblin contributors. See AUTHORS.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import urlparse
+import datetime
+
+from nose.tools import assert_equal
+
+from mediagoblin.tests.tools import setup_fresh_app
+from mediagoblin import mg_globals
+
+
+@setup_fresh_app
+def test_csrf_cookie_set(test_app):
+
+ # get login page
+ response = test_app.get('/auth/login/')
+
+ # assert that the mediagoblin nonce cookie has been set
+ assert 'Set-Cookie' in response.headers
+ assert 'mediagoblin_nonce' in response.cookies_set
+
+ # assert that we're also sending a vary header
+ assert response.headers.get('Vary', False) == 'Cookie'
+
+
+@setup_fresh_app
+def test_csrf_token_must_match(test_app):
+
+ # construct a request with no cookie or form token
+ assert test_app.post('/auth/login/',
+ extra_environ={'gmg.verify_csrf': True},
+ expect_errors=True).status_int == 403
+
+ # construct a request with a cookie, but no form token
+ assert test_app.post('/auth/login/',
+ headers={'Cookie': str('%s=foo; ' %
+ mg_globals.app_config['csrf_cookie_name'])},
+ extra_environ={'gmg.verify_csrf': True},
+ expect_errors=True).status_int == 403
+
+ # if both the cookie and form token are provided, they must match
+ assert test_app.post('/auth/login/',
+ {'csrf_token': 'blarf'},
+ headers={'Cookie': str('%s=foo; ' %
+ mg_globals.app_config['csrf_cookie_name'])},
+ extra_environ={'gmg.verify_csrf': True},
+ expect_errors=True).\
+ status_int == 403
+
+ assert test_app.post('/auth/login/',
+ {'csrf_token': 'foo'},
+ headers={'Cookie': str('%s=foo; ' %
+ mg_globals.app_config['csrf_cookie_name'])},
+ extra_environ={'gmg.verify_csrf': True}).\
+ status_int == 200
diff --git a/mediagoblin/tools/template.py b/mediagoblin/tools/template.py
index c346c33d..a773ca99 100644
--- a/mediagoblin/tools/template.py
+++ b/mediagoblin/tools/template.py
@@ -22,6 +22,7 @@ from mediagoblin import mg_globals
from mediagoblin import messages
from mediagoblin.tools import common
from mediagoblin.tools.translate import setup_gettext
+from mediagoblin.middleware.csrf import render_csrf_form_token
SETUP_JINJA_ENVS = {}
@@ -73,6 +74,7 @@ def render_template(request, template_path, context):
template = request.template_env.get_template(
template_path)
context['request'] = request
+ context['csrf_token'] = render_csrf_form_token(request)
rendered = template.render(context)
if common.TESTS_ENABLED:
diff --git a/paste.ini b/paste.ini
index fc459989..7eee528b 100644
--- a/paste.ini
+++ b/paste.ini
@@ -19,10 +19,12 @@ config = %(here)s/mediagoblin.ini
[app:publicstore_serve]
use = egg:Paste#static
document_root = %(here)s/user_dev/media/public/
+cache_max_age = 604800
[app:mediagoblin_static]
use = egg:Paste#static
document_root = %(here)s/mediagoblin/static/
+cache_max_age = 86400
[filter:beaker]
use = egg:Beaker#beaker_session