Commit fce98687 authored by Paul Brown's avatar Paul Brown

fix index_view delete button CSRF

parent 2b4bfe35
...@@ -4,7 +4,8 @@ import re ...@@ -4,7 +4,8 @@ import re
from flask import (request, redirect, flash, abort, json, Response, from flask import (request, redirect, flash, abort, json, Response,
get_flashed_messages) get_flashed_messages)
from jinja2 import contextfunction from jinja2 import contextfunction
from wtforms.validators import ValidationError from wtforms.fields import HiddenField
from wtforms.validators import ValidationError, Required
from flask.ext.admin.babel import gettext from flask.ext.admin.babel import gettext
...@@ -591,6 +592,7 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -591,6 +592,7 @@ class BaseModelView(BaseView, ActionsMixin):
self._create_form_class = self.get_create_form() self._create_form_class = self.get_create_form()
self._edit_form_class = self.get_edit_form() self._edit_form_class = self.get_edit_form()
self._delete_form_class = self.get_delete_form()
# List View In-Line Editing # List View In-Line Editing
if self.column_editable_list: if self.column_editable_list:
...@@ -888,6 +890,18 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -888,6 +890,18 @@ class BaseModelView(BaseView, ActionsMixin):
""" """
return self.get_form() return self.get_form()
def get_delete_form(self):
"""
Create form class for model delete view.
Override to implement customized behavior.
"""
class DeleteForm(self.form_base_class):
id = HiddenField(validators=[Required()])
url = HiddenField()
return DeleteForm
def create_form(self, obj=None): def create_form(self, obj=None):
""" """
Instantiate model creation form and return it. Instantiate model creation form and return it.
...@@ -904,6 +918,20 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -904,6 +918,20 @@ class BaseModelView(BaseView, ActionsMixin):
""" """
return self._edit_form_class(get_form_data(), obj=obj) return self._edit_form_class(get_form_data(), obj=obj)
def delete_form(self):
"""
Instantiate model delete form and return it.
Override to implement custom behavior.
"""
if request.form:
return self._delete_form_class(request.form)
elif request.args:
# allow request.args for backward compatibility
return self._delete_form_class(request.args)
else:
return self._delete_form_class()
def list_form(self, obj=None): def list_form(self, obj=None):
""" """
Instantiate model editing form for list view and return it. Instantiate model editing form for list view and return it.
...@@ -1293,6 +1321,11 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1293,6 +1321,11 @@ class BaseModelView(BaseView, ActionsMixin):
form = self.list_form() form = self.list_form()
else: else:
form = None form = None
if self.can_delete:
delete_form = self.delete_form()
else:
delete_form = None
# Grab parameters from URL # Grab parameters from URL
view_args = self._get_list_extra_args() view_args = self._get_list_extra_args()
...@@ -1340,6 +1373,7 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1340,6 +1373,7 @@ class BaseModelView(BaseView, ActionsMixin):
self.list_template, self.list_template,
data=data, data=data,
form=form, form=form,
delete_form=delete_form,
# List # List
list_columns=self._list_columns, list_columns=self._list_columns,
...@@ -1454,18 +1488,30 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1454,18 +1488,30 @@ class BaseModelView(BaseView, ActionsMixin):
""" """
return_url = get_redirect_target() or self.get_url('.index_view') return_url = get_redirect_target() or self.get_url('.index_view')
# TODO: Use post
if not self.can_delete: if not self.can_delete:
return redirect(return_url) return redirect(return_url)
id = get_mdict_item_or_list(request.args, 'id') form = self.delete_form()
if id is None:
return redirect(return_url)
model = self.get_one(id) if self.validate_form(form):
id = form.id.data # id is Required()
model = self.get_one(id)
if model: if model is None:
self.delete_model(model) return redirect(return_url)
# message is flashed from within delete_model if it fails
if self.delete_model(model):
flash(gettext('Record was successfully deleted.'))
return redirect(return_url)
else:
# flash validation errors
for field_name, errors in iteritems(form.errors):
errors = field_name + u": " + u", ".join(errors)
flash(gettext('Failed to delete record. %(error)s',
error=str(errors)),
'error')
return redirect(return_url) return redirect(return_url)
......
...@@ -107,9 +107,11 @@ ...@@ -107,9 +107,11 @@
</a> </a>
{%- endif -%} {%- endif -%}
{%- if admin_view.can_delete -%} {%- if admin_view.can_delete -%}
<form class="icon" method="POST" action="{{ get_url('.delete_view', id=get_pk_value(row), url=return_url) }}"> <form class="icon" method="POST" action="{{ get_url('.delete_view') }}">
{% if csrf_token %} <input type="hidden" name="id" value="{{ get_pk_value(row) }}"/>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="url" value="{{ return_url }}"/>
{% if delete_form.csrf_token %}
<input type="hidden" name="csrf_token" value="{{ delete_form.csrf_token() }}"/>
{% endif %} {% endif %}
<button onclick="return confirm('{{ _gettext('Are you sure you want to delete this record?') }}');" title="{{ _gettext('Delete record') }}"> <button onclick="return confirm('{{ _gettext('Are you sure you want to delete this record?') }}');" title="{{ _gettext('Delete record') }}">
<i class="icon-trash"></i> <i class="icon-trash"></i>
......
...@@ -107,11 +107,13 @@ ...@@ -107,11 +107,13 @@
</a> </a>
{%- endif -%} {%- endif -%}
{%- if admin_view.can_delete -%} {%- if admin_view.can_delete -%}
<form class="icon" method="POST" action="{{ get_url('.delete_view', id=get_pk_value(row), url=return_url) }}"> <form class="icon" method="POST" action="{{ get_url('.delete_view') }}">
{% if csrf_token %} <input type="hidden" name="id" value="{{ get_pk_value(row) }}"/>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="url" value="{{ return_url }}"/>
{% if delete_form.csrf_token %}
<input type="hidden" name="csrf_token" value="{{ delete_form.csrf_token() }}"/>
{% endif %} {% endif %}
<button onclick="return confirm('{{ _gettext('Are you sure you want to delete this record?') }}');" title="Delete record"> <button onclick="return confirm('{{ _gettext('Are you sure you want to delete this record?') }}');" title="Delete record">
<span class="glyphicon glyphicon-trash"></span> <span class="glyphicon glyphicon-trash"></span>
</button> </button>
</form> </form>
......
import wtforms
from nose.tools import eq_, ok_ from nose.tools import eq_, ok_
from flask import Flask from flask import Flask, session
from werkzeug.wsgi import DispatcherMiddleware from werkzeug.wsgi import DispatcherMiddleware
from werkzeug.test import Client from werkzeug.test import Client
...@@ -12,6 +14,14 @@ from flask.ext.admin._compat import iteritems, itervalues ...@@ -12,6 +14,14 @@ from flask.ext.admin._compat import iteritems, itervalues
from flask.ext.admin.model import base, filters from flask.ext.admin.model import base, filters
def wtforms2_and_up(func):
"""Decorator for skipping test if wtforms <2
"""
if int(wtforms.__version__[0]) < 2:
func.__test__ = False
return func
class Model(object): class Model(object):
def __init__(self, id=None, c1=1, c2=2, c3=3): def __init__(self, id=None, c1=1, c2=2, c3=3):
self.id = id self.id = id
...@@ -329,6 +339,104 @@ def test_form(): ...@@ -329,6 +339,104 @@ def test_form():
pass pass
@wtforms2_and_up
def test_csrf():
from datetime import timedelta
from wtforms.csrf.session import SessionCSRF
from wtforms.meta import DefaultMeta
# BaseForm w/ CSRF
class SecureForm(form.BaseForm):
class Meta(DefaultMeta):
csrf = True
csrf_class = SessionCSRF
csrf_secret = b'EPj00jpfj8Gx1SjnyLxwBBSQfnQ9DJYe0Ym'
csrf_time_limit = timedelta(minutes=20)
@property
def csrf_context(self):
return session
class SecureModelView(MockModelView):
form_base_class = SecureForm
def scaffold_form(self):
return SecureForm
def get_csrf_token(data):
data = data.split('name="csrf_token" type="hidden" value="')[1]
token = data.split('">')[0]
return token
app, admin = setup()
view = SecureModelView(Model, endpoint='secure')
admin.add_view(view)
client = app.test_client()
################
# create_view
################
rv = client.get('/admin/secure/new/')
eq_(rv.status_code, 200)
ok_(u'name="csrf_token"' in rv.data.decode('utf-8'))
csrf_token = get_csrf_token(rv.data.decode('utf-8'))
# Create without CSRF token
rv = client.post('/admin/secure/new/', data=dict(name='test1'))
eq_(rv.status_code, 200)
# Create with CSRF token
rv = client.post('/admin/secure/new/', data=dict(name='test1',
csrf_token=csrf_token))
eq_(rv.status_code, 302)
###############
# edit_view
###############
rv = client.get('/admin/secure/edit/?url=%2Fadmin%2Fsecure%2F&id=1')
eq_(rv.status_code, 200)
ok_(u'name="csrf_token"' in rv.data.decode('utf-8'))
csrf_token = get_csrf_token(rv.data.decode('utf-8'))
# Edit without CSRF token
rv = client.post('/admin/secure/edit/?url=%2Fadmin%2Fsecure%2F&id=1',
data=dict(name='test1'))
eq_(rv.status_code, 200)
# Edit with CSRF token
rv = client.post('/admin/secure/edit/?url=%2Fadmin%2Fsecure%2F&id=1',
data=dict(name='test1', csrf_token=csrf_token))
eq_(rv.status_code, 302)
################
# delete_view
################
rv = client.get('/admin/secure/')
eq_(rv.status_code, 200)
ok_(u'name="csrf_token"' in rv.data.decode('utf-8'))
csrf_token = get_csrf_token(rv.data.decode('utf-8'))
# Delete without CSRF token, test validation errors
rv = client.post('/admin/secure/delete/',
data=dict(id="1", url="/admin/secure/"), follow_redirects=True)
eq_(rv.status_code, 200)
ok_(u'Record was successfully deleted.' not in rv.data.decode('utf-8'))
ok_(u'Failed to delete record.' in rv.data.decode('utf-8'))
# Delete with CSRF token
rv = client.post('/admin/secure/delete/',
data=dict(id="1", url="/admin/secure/", csrf_token=csrf_token),
follow_redirects=True)
eq_(rv.status_code, 200)
ok_(u'Record was successfully deleted.' in rv.data.decode('utf-8'))
def test_custom_form(): def test_custom_form():
app, admin = setup() app, admin = setup()
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment