Merge remote branch 'remotes/nyergler/569-application-middleware'
This commit is contained in:
commit
1268521b20
@ -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")
|
||||
|
@ -16,4 +16,5 @@
|
||||
|
||||
ENABLED_MIDDLEWARE = (
|
||||
'mediagoblin.middleware.noop:NoOpMiddleware',
|
||||
'mediagoblin.middleware.csrf:CsrfMiddleware',
|
||||
)
|
||||
|
135
mediagoblin/middleware/csrf.py
Normal file
135
mediagoblin/middleware/csrf.py
Normal file
@ -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()
|
@ -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 %}
|
||||
|
@ -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" />
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -26,6 +26,7 @@
|
||||
<tr>
|
||||
<td></td>
|
||||
<td><input type="submit" value="submit" class="button" /></td>
|
||||
{{ csrf_token }}
|
||||
</tr>
|
||||
</table>
|
||||
</form>
|
||||
|
@ -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 %}
|
||||
|
@ -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>
|
||||
|
69
mediagoblin/tests/test_csrf_middleware.py
Normal file
69
mediagoblin/tests/test_csrf_middleware.py
Normal file
@ -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
|
@ -39,6 +39,7 @@ from wtforms.form import Form
|
||||
from mediagoblin import mg_globals
|
||||
from mediagoblin import messages
|
||||
from mediagoblin.db.util import ObjectId
|
||||
from mediagoblin.middleware.csrf import render_csrf_form_token
|
||||
|
||||
from itertools import izip, count
|
||||
|
||||
@ -125,6 +126,8 @@ 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 TESTS_ENABLED:
|
||||
|
Loading…
x
Reference in New Issue
Block a user