angle-uparrow-clockwisearrow-counterclockwisearrow-down-uparrow-leftatcalendarcard-listchatcheckenvelopefolderhouseinfo-circlepencilpeoplepersonperson-fillperson-plusphoneplusquestion-circlesearchtagtrashx

Flask, WTForms y AJAX: CSRF protección, before_request y multilenguaje

Siempre debes comprobar si la protección CSRF está funcionando. Con Flask esto no es obvio.

29 febrero 2020
post main image
https://unsplash.com/@christineashleydonaldson

Nunca comprobé realmente si la protección CSRF funcionaba en mi aplicación Flask , este sitio web. ¿Está activado por defecto? De la documentación de la extensión Flask_WTF:

Cualquier vista que utilice FlaskForm para procesar la solicitud ya está recibiendo la protección CSRF .

Y del texto del post de Miguel Grinberg "Seguridad de las cookies para las aplicaciones Flask ":

Si está manejando sus formularios web con la extensión Flask-WTF , ya está protegido contra CSRF en sus formularios por defecto.

Debería estar habilitado, comprobemos si esto es cierto. En la página con el formulario que añadí:

<script>
$(document).ready(function(){
    $('#csrf_token').val('ABC');
});
</script>

Entonces refresqué el formulario. En el depurador verifiqué que el csrf_token cambió a 'ABC'. Al pulsar el botón de envío debería dar un error CSRF , bueno esperaba una excepción CSRF . Para mí, no hay excepción, lo que significa que no hay protección CSRF . ¿Cómo es posible? ¿Tiene que ver con el hecho de que estoy usando DispatcherMiddleWare o hay algo más que está mal?

Sí, algo estaba mal. ¡Yo! Y fue causado por el TL;DR. De hecho, hubo un error CSRF pero no una excepción CSRF . Tengo muchos formularios, la mayoría en la sección de administración, y han estado trabajando con la protección CSRF todo el tiempo sin ningún error CSRF . ¿Qué es lo que está pasando?

Mi testapp:

def  create_app():
    fname = 'create_app'
    print(fname  +  '()')

    app =  Flask(__name__)

    app.config['SECRET_KEY'] = 'some-secret-key'

    class MyCSRFForm(FlaskForm):
        submit = SubmitField(_l('Send'))

    @app.route('/csrf_form', methods=['GET', 'POST'])
    def csrf_form():
        fname = 'csrf_form'
        print(fname  +  '()')

        form = MyCSRFForm()
        if form.validate_on_submit():
            print(fname  +  ': no validate_on_submit errors, processing form')

        print(fname  +  ': form.errors = '.format(form.errors))
        for field, errors in form.errors.items():
            print(fname  +  ': form.errors, field = {}, errors = {}'.format(field, errors))
                
        return  render_template('csrf_form.html', form=form)

y la plantilla csrf_form.html:

<html>
<head></head>
<body>

<form method="post">
    {{ form.csrf_token }}
    <p>{{ form.submit() }}</p>
</form>

<script>
elem = document.getElementById('csrf_token');
elem.setAttribute('value', 'ABC'); 
</script>

</body>
</html>

El resultado:

csrf_form: form.errors, field = csrf_token, errors = ['The  CSRF  token is invalid.']

Hay más información sobre esto en "Inconsistencia con el aumento de CSRFError #381", ver los enlaces de abajo. Parece que se puede utilizar la protección Flask-WTF CSRF de dos maneras:

  • como un mensaje de error CSRF durante la validación de la forma
  • como una excepción CSRF

Ambos tienen ventajas y desventajas. Decidí ir por la excepción CSRF porque no hay manera de que puedas olvidar esto por accidente. Para generar las excepciones CSRF , añadí el código sugerido en mi __init__.py:

from flask_wtf.csrf import  CSRFProtect
csrf =  CSRFProtect()
...

def  create_app(project_config):
    ...
    # csrf protection
    csrf.init_app(app)

Ahora cuando envío el formulario obtengo una excepción CSRF :

Bad Request
The  CSRF  token is invalid.

Cambié el jQuery y eliminé el nombre de campo csrf_token:

<script>
$(document).ready(function(){
    $('#csrf_token').attr({name: 'nothing'});
});
</script>

Actualizar y presentar, la excepción CSRF es:

Bad Request
The  CSRF  token is missing.

Y como prueba final quitamos el script y fijamos el tiempo de expiración del token CSRF en 5 segundos en create_app():

    app.config['WTF_CSRF_TIME_LIMIT'] = 5

La excepción de CSRF es:

Bad Request
The  CSRF  token has expired.

Resumen de estas pruebas:

  • El código de error http es 400 Bad Request
  • Parecía que la protección CSRF funcionaba por defecto y puede ser implementada de dos maneras, la documentación no es muy clara al respecto
  • Quería excepciones CSRF y añadí el código extra sugerido
  • Fue muy fácil generar las excepciones CSRF para las condiciones:
    - La ficha CSRF es inválida
    - La ficha CSRF falta
    - La ficha CSRF ha caducado

Múltiples formas y la ficha CSRF

Una página de entradas de blog en este sitio tiene dos tipos de comment forms: el comment form y el comment reply form, ver también una entrada anterior.
Al mirar la página con el comment form y comment reply form noté que ambos tenían el valor token CSRF después de una carga de la página.

comment form: 
<input id="comment_form-csrf_token" name="comment_form-csrf_token" type="hidden" value="IjM4N2Y3NmEzMGFkNGMwOTY2MzM5OGI0NTVjOGQwNDI3MjY3MmQxOGIi.Xk6ReA.dFbMlM0bClY6BvAVjxZ0GfiFBM4">

comment reply form: 
<input id="comment_reply_form-csrf_token" name="comment_reply_form-csrf_token" type="hidden" value="IjM4N2Y3NmEzMGFkNGMwOTY2MzM5OGI0NTVjOGQwNDI3MjY3MmQxOGIi.Xk6ReA.dFbMlM0bClY6BvAVjxZ0GfiFBM4">

o:

comment_form-csrf_token: IjM4N2Y3NmEzMGFkNGMwOTY2MzM5OGI0NTVjOGQwNDI3MjY3MmQxOGIi.Xk6ReA.dFbMlM0bClY6BvAVjxZ0GfiFBM4

comment_reply_form-csrf_token: IjM4N2Y3NmEzMGFkNGMwOTY2MzM5OGI0NTVjOGQwNDI3MjY3MmQxOGIi.Xk6ReA.dFbMlM0bClY6BvAVjxZ0GfiFBM4

Entonces presento un mensaje demasiado corto usando el comment reply form. El formulario se envía al servidor, se renderiza en el servidor y se devuelve al cliente.
La inspección demostró que la ficha CSRF del comment reply form cambió:

comment_reply_form-csrf_token: IjM4N2Y3NmEzMGFkNGMwOTY2MzM5OGI0NTVjOGQwNDI3MjY3MmQxOGIi.Xk6SUg.6axMGCN2KmQQ4WeAAxYgTg6UdAs

Aquí surgieron las siguientes preguntas:

  • ¿Cómo se construye el token CSRF ?
  • ¿Cuándo expira la ficha CSRF ?
  • ¿Las diferentes formas de una página requieren diferentes fichas de CSRF ?
  • ¿Cómo puede cambiar la ficha CSRF de la comment reply form ?
  • ¿Qué es el X-CSRFToken?

¿Cómo se construye el token CSRF ?

Es hora de empezar a leer de nuevo. De la guía OWASP :

En general, los desarrolladores sólo tienen que generar esta ficha una vez para el período de sesiones en curso. Después de la generación inicial de este testigo, el valor se almacena en la sesión y se utiliza para cada solicitud posterior hasta que la sesión expire.

En Flask-WTF la ficha en bruto está en la sesión y la ficha firmada está en g. O más exacto, la ficha en bruto siempre está en la sesión y la ficha firmada está en g después de que se haya utilizado un formulario. Antes de instanciar una forma:

session['csrf_token']: 387f76a30ad4c09663398b455c8d04272672d18b
g.csrf_token:  None

Después de instanciar una forma:

session['csrf_token']: 387f76a30ad4c09663398b455c8d04272672d18b
g.csrf_token: IjM4N2Y3NmEzMGFkNGMwOTY2MzM5OGI0NTVjOGQwNDI3MjY3MmQxOGIi.Xk6vnw.L7uNOptXpBxemh_TIEy3e9Ujllg

La ficha firmada se pone en g para que en las solicitudes posteriores, por ejemplo en caso de formularios múltiples, se pueda utilizar la misma ficha firmada y no sea necesario generarla de nuevo. Si quieres puedes experimentar con la generación y validación de fichas usando las funciones generate_csrf() y validate_csrf():

    from flask_wtf.csrf import generate_csrf, validate_csrf

    secret_key =  current_app.config['SECRET_KEY']
    # default: WTF_CSRF_FIELD_NAME = 'csrf_token'
    token_key =  current_app.config['WTF_CSRF_FIELD_NAME']

    csrf_token = generate_csrf(secret_key=secret_key, token_key=token_key)
     current_app.logger.debug(fname  +  ': csrf_token = {}'.format(csrf_token))

    validated = False
    try:
        validate_csrf(csrf_token, secret_key=secret_key, time_limit=3600, token_key=token_key)
        validated = True
    except:
        pass
     current_app.logger.debug(fname  +  ': validated = {}'.format(validated))

Las funciones anteriores hacen algo así como una caja negra. Pero podemos ver que la ficha generada está firmada e incluye una marca de tiempo.

¿Cuándo expira la ficha CSRF ?

Ya vimos que el token CSRF expiran tiempo puede ser controlado con:

    app.config['WTF_CSRF_TIME_LIMIT'] = 5

El valor por defecto es 3600, una hora. Esta vez está en algún lugar de la ficha. Puede haber dos maneras posibles de cómo se implementa esto:

  • La hora de inicio está en la ficha CSRF
  • El tiempo final está en la ficha CSRF

Esto no es importante saberlo a menos que quiera cambiar el tiempo de expiración dentro de su solicitud. La función generate_csrf() no incluye un parámetro de límite de tiempo mientras que la función validate_csrf sí lo incluye. Esto significaría que la hora de inicio está en la ficha CSRF . Para verificar esto puse una página con un formulario en la pantalla, luego cambié el límite de tiempo por defecto a 10 segundos y luego envié el formulario. Como era de esperar, se planteó una excepción CSRF .

Hay otra condición cuando su ficha CSRF expira y es cuando la sesión expira. La razón es que el token en bruto CSRF está almacenado en la sesión. Esto significa que si su sesión vive menos tiempo que su token CSRF expiran, puede obtener errores CSRF .

¿Las diferentes formas de una página requieren diferentes fichas de CSRF ?

Responde: Esta es una pregunta más general. La respuesta es no. Podemos usar el mismo token CSRF para todos los formularios de una página.

¿Cómo puede cambiar la ficha CSRF de la comment reply form ?

Responde: La ficha no cambia, pero la ficha firmada cambia, porque también hay una marca de tiempo en la ficha firmada. Si miran las fichas firmadas arriba, notarán que la primera parte es idéntica.

El parámetro de cabecera X-CSRFToken

Puede que hayas leído sobre el X-CSRFToken. El X-CSRFToken es un ajuste en la cabecera. Normalmente no necesitamos esto. Si tiene un parámetro de token CSRF en su AJAX POST entonces esto será utilizado. Si no es así, puede configurarlo en el encabezado del POST usando jQuery:

var csrf_token = ...

    ...
    $.ajax({
        beforeSend: function(xhr, settings){
            if(!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain){
                xhr.setRequestHeader("X-CSRFToken", csrf_token);
            }
        },

Depende de su implementación si envía formularios con un parámetro de token CSRF o si establece el parámetro de encabezado X-CSRFToken. La documentación dice que sólo puedes usar esto en el "Modo de excepción CSRF " (añadiendo el código extra). No he comprobado esto.

Manejando las excepciones CSRF y before_request

Ya manejo excepciones de errores HTTP como 401 (No autorizado), 403 (Prohibido), 404 (No encontrado), 405 (Método no permitido), 500 (Error interno del servidor) pero ¿cómo manejamos la excepción CSRF ? El error CSRF se propaga a un 400 (Bad Request).

He creado bonitas páginas de error de campanas y silbatos que muestran las excepciones. Lo hice confiando en el hecho de que ese before_request fue llamado.
En before_request proceso la solicitud entrante y entre otras cosas, establezco el idioma seleccionado en consecuencia.

Desafortunadamente por defecto before_request no es llamado en una excepción de CSRF . Pero hay una forma de evitarlo. Podemos establecerlo:

    app.config['WTF_CSRF_CHECK_DEFAULT'] = False

El resultado será que se llama before_request . Luego en before_request, después de algún procesamiento esencial, podemos llamar:

     csrf.protect()

Esto levantará una excepción de la CSFR, si es que hay una, y llamará al controlador de errores en consecuencia.

Mensajes de excepción CSRF multi-lenguaje

Utilizo Flask-Babel y los mensajes de validación se traducen al idioma seleccionado. Los mensajes de excepción CSRF no se tradujeron y al comprobar csrf.py pareció que estos mensajes están codificados en inglés(?). Por el momento he creado un diccionario para manejar la traducción:

    csrf_error_message2translated_error_messages = {
        ...
        'The  CSRF  token has expired.': _('The  CSRF  token has expired.'),
        ...
    }
    if error.description in error_description2error_messages:
        error_message = error_description2error_messages[error.description]
    else:
        error_message = error.description

Responder a las excepciones, incluyendo CSRF, en las solicitudes de AJAX

Con AJAX no podemos mostrar nuestra agradable página de error HTML en una excepción. En una solicitud debemos devolver un código de error o HTML que el cliente entienda. Se trata de devolver la excepción como un mensaje de error codificado JSON al cliente. En Flask ya tenemos nuestros manejadores de error.
¿Pero cómo sabemos que la solicitud provenía de una llamada AJAX ? Estoy enviando el formulario con la codificación application/x-www-form-urlencoded:

    $.ajax({
        data: $('#'  +  form_id).serialize()   +  '&'  +  encodeURI( $(submit_button).attr('name') )  +  '='  +  encodeURI( $(submit_button).val() ),
        type: 'POST',
        url: url_comment_new,
        contentType: 'application/x-www-form-urlencoded; charset=UTF-8', 
        dataType: 'json'
    })

Una forma es mirar el encabezado de aceptación recibido.

     current_app.logger.error(fname  +  ': request.accept_mimetypes = {}'.format(request.accept_mimetypes))
    # text/javascript,application/json,*/*;q=0.01

Del documento "Lista de valores predeterminados Aceptar":

Observe que todos los navegadores añaden el tipo */* MIME para cubrir todos los casos. Esto se utiliza típicamente para las solicitudes iniciadas a través de la barra de direcciones de un navegador, o a través de un elemento HTML <a>.

Esto significa que tanto "request.accept_mimetypes.accept_json" como "request.accept_mimetypes.accept_html" son verdaderos. No es muy útil. Los tipos_de_aceptación se enumeran por orden de preferencia. Podríamos escanear la lista y decidir devolver JSON cuando encontremos 'application/json' antes de 'text/html'. Pero no sé exactamente qué navegadores envían qué accept_mimetypes, lo que significa que no estoy 100% seguro en este momento de que las páginas de error "sin AJAX" no se muestren accidentalmente como JSON.

Una forma mucho más segura es añadir un parámetro a las solicitudes AJAX y comprobar en el manejador de errores si este parámetro está presente. Si está presente, entonces devolvemos un mensaje de error JSON , si no lo está, emitimos la página de error estándar. No me gusta esto, pero funciona y algún tiempo se profundizará en esto.

Resumen

Siempre debes comprobar si la protección CSRF está funcionando. No lo hice, y ese fue el comienzo de una gran lectura. Parecía que hay dos maneras de tratar con la protección de CSRF , el error de forma de CSRF y la excepción de CSRF . Elijo la segunda. Para mí, el manejo de la protección de CSRF para este sitio multilingüe no era estándar, la razón principal era que before_request no se llama por defecto en una excepción de CSRF . En before_request compruebo y establezco el lenguaje entre otras cosas para que esto realmente debe funcionar. Afortunadamente podemos retrasar la protección de CSRF y llamarla explícitamente en before_request después del procesamiento esencial usando csrf.protect(). Ya tenemos nuestras páginas de error de excepción, pero con las solicitudes AJAX debemos devolver los mensajes de error JSON . Detectar si la solicitud proviene de una solicitud AJAX es complejo, así que usé un parámetro extra en las solicitudes AJAX .

Enlaces / créditos

Cookie Security for Flask Applications
https://blog.miguelgrinberg.com/post/cookie-security-for-flask-applications

Cross-Site Request Forgery (CSRF) Prevention Cheat Sheet
https://owasp.org/www-project-cheat-sheets/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#Prevention_Measures_That_Do_NOT_Work

Flask-WTF CSRF Protection
https://flask-wtf.readthedocs.io/en/latest/csrf.html

Generating CSRF tokens for multiple forms on a single page
https://stackoverflow.com/questions/14715250/generating-csrf-tokens-for-multiple-forms-on-a-single-page

How to use Flask-WTForms CSRF protection with AJAX?
https://stackoverflow.com/questions/31888316/how-to-use-flask-wtforms-csrf-protection-with-ajax

Inconsistency with raising CSRFError #381
https://github.com/lepture/flask-wtf/issues/381

List of default Accept values
https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation/List_of_default_Accept_values

Posting a WTForm via AJAX with Flask
https://medium.com/@doobeh/posting-a-wtform-via-ajax-with-flask-b977782edeee

Unacceptable Browser HTTP Accept Headers (Yes, You Safari and Internet Explorer)
https://www.newmediacampaigns.com/blog/browser-rest-http-accept-headers

Deje un comentario

Comente de forma anónima o inicie sesión para comentar.

Comentarios (1)

Deje una respuesta.

Responda de forma anónima o inicie sesión para responder.

avatar

Thanks for placing this post.
I have a problem with my webapp that raises CSRF errors, which I asked in
https://stackoverflow.com/questions/65985400/getting-csrf-errors-from-put-fetch-request-that-does-not-involve-flask-forms
Can you please explain why I'm getting CSRF error in the first place, and how to fix the problem?
Thanks again.
Avner