From 164cb5dfa23328cd3f827f714bb4a6309f4c0aa4 Mon Sep 17 00:00:00 2001 From: f Date: Tue, 11 Feb 2020 12:06:36 -0300 Subject: [PATCH] WIP: colaboraciones anonimas --- .../api/v1/invitades_controller.rb | 45 ++++++ app/controllers/api/v1/posts_controller.rb | 99 ++++++++++++ app/views/posts/show.haml | 1 + config/routes.rb | 5 +- .../20200130193655_add_anonymous_to_site.rb | 7 + doc/anonymous.md | 153 ++++++++++++++++++ 6 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 app/controllers/api/v1/invitades_controller.rb create mode 100644 app/controllers/api/v1/posts_controller.rb create mode 100644 db/migrate/20200130193655_add_anonymous_to_site.rb create mode 100644 doc/anonymous.md diff --git a/app/controllers/api/v1/invitades_controller.rb b/app/controllers/api/v1/invitades_controller.rb new file mode 100644 index 00000000..6ab0a1b7 --- /dev/null +++ b/app/controllers/api/v1/invitades_controller.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Api + module V1 + class InvitadesController < BaseController + # Obtiene una cookie válida por el tiempo especificado por el + # sitio. + # + # Aunque visitemos el sitio varias veces enviando la cookie + # anterior, la cookie se renueva. + def cookie + # XXX: Prestar atención a que estas acciones sean lo más rápidas + # y utilicen la menor cantidad posible de recursos, porque son + # un vector de DDOS. + site, anon = Site.where(name: params[:site_id], colaboracion_anonima: true) + .pluck(:name, :colaboracion_anonima) + .first + + # La cookie no es accesible a través de JS y todo su contenido + # está cifrado para que no lo modifiquen les visitantes + # + # Enviamos un token de protección CSRF + if anon + headers['Access-Control-Allow-Credentials'] = true + headers['Access-Control-Allow-Origin'] = "https://#{site}" + headers['Vary'] = 'Origin' + + expires = 30.minutes + cookies.encrypted[site] = { + httponly: true, + secure: true, + expires: expires, + same_site: :none, + value: { + csrf: form_authenticity_token, + expires: (Time.now + expires).to_i + } + } + end + + render html: nil, status: :no_content + end + end + end +end diff --git a/app/controllers/api/v1/posts_controller.rb b/app/controllers/api/v1/posts_controller.rb new file mode 100644 index 00000000..7cb9f08e --- /dev/null +++ b/app/controllers/api/v1/posts_controller.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module Api + module V1 + class PostsController < BaseController + # Ver doc/anonymous.md + skip_forgery_protection + # Protecciones antes de procesar los datos + before_action :cookie_is_valid?, unless: :performed? + before_action :valid_authenticity_token_in_cookie?, unless: :performed? + before_action :site_exists_and_is_anonymous?, unless: :performed? + before_action :site_is_origin?, unless: :performed? + + # Crea un artículo solo si el sitio es invitado, pero antes + # tenemos que averiguar varias cosas: + # + # * la cookie sea válida + # * el token anti CSRF es válido + # * el sitio existe + # * el sitio admite invitades + # * el origen de la petición no es el sitio + # + # TODO: Definir cuáles van a ser las respuestas para cada error + # o si simplemente vamos a aceptarlas sin dar feedback. + def create + # No procesar nada más si ya se aplicaron todos los filtros + return if performed? + + # Redirigir a la URL de agradecimiento + redirect_to params[:redirect_to] + end + + private + + # Comprueba que no se haya reutilizado una cookie vencida + # + # XXX: Si el navegador envió una cookie vencida es porque la está + # reutilizando, probablemente de forma maliciosa? + def cookie_is_valid? + unless cookies.encrypted[site_id] && + cookies.encrypted[site_id]['expires'] > Time.now.to_i + render html: nil, status: :no_content + end + end + + # Queremos comprobar que la cookie corresponda con la sesión. La + # cookie puede haber vencido, así que es uno de los chequeos más + # simples que hacemos. + # + # TODO: Pensar una forma de redirigir al origen sin vaciar el + # formulario para que le usuarie recargue la cookie. + def valid_authenticity_token_in_cookie? + unless valid_authenticity_token? session, cookies.encrypted[site_id] + render html: nil, status: :no_content + end + end + + # El sitio existe y soporta colaboracion anónima + # + # Pedimos el sitio aunque no lo necesitemos para que la consulta + # entre en la caché + def site_exists_and_is_anonymous? + _, anon = site_anon_pair + + render html: nil, status: :no_content + end + + # El navegador envía la URL del sitio en el encabezado Origin, + # queremos comprobar que los datos son enviados desde ahí. + def site_is_origin? + site, = site_anon_pair + + unless "https://#{site}" === request.headers['Origin'] + render html: nil, status: :no_content + end + end + + # Solo soy un atajo + def site_id + @site_id ||= params[:site_id] + end + + # La consulta más barata que podemos hacer y la reutilizamos para + # que esté en la caché + def site_anon_pair + Site.where(name: site_id, colaboracion_anonima: true) + .pluck(:name, :colaboracion_anonima) + .first + end + + # Instancia el sitio completo + # + # XXX: Solo usar después de comprobar que el sitio existe! + def site + @site ||= Site.find_by(name: site_id) + end + end + end +end diff --git a/app/views/posts/show.haml b/app/views/posts/show.haml index 5cac7f4b..c8ea655e 100644 --- a/app/views/posts/show.haml +++ b/app/views/posts/show.haml @@ -34,4 +34,5 @@ - next if @post.send(attr).front_matter? %section{ id: attr } + -# TODO: Esto es un agujero de XSS!!!! = raw @post.send(attr).value diff --git a/config/routes.rb b/config/routes.rb index d8b5f391..9e6c0660 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -21,7 +21,10 @@ Rails.application.routes.draw do namespace :v1 do resources :csp_reports, only: %i[create] get 'sites/allowed', to: 'sites#allowed' - resources :sites, only: %i[index] + resources :sites, only: %i[index], constraints: { site_id: /[a-z0-9\-\.]+/, id: /[a-z0-9\-\.]+/ } do + get 'invitades/cookie', to: 'invitades#cookie' + resources :posts, only: %i[create] + end end end end diff --git a/db/migrate/20200130193655_add_anonymous_to_site.rb b/db/migrate/20200130193655_add_anonymous_to_site.rb new file mode 100644 index 00000000..b00ea895 --- /dev/null +++ b/db/migrate/20200130193655_add_anonymous_to_site.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddAnonymousToSite < ActiveRecord::Migration[6.0] + def change + add_column :sites, :colaboracion_anonima, :boolean, default: false + end +end diff --git a/doc/anonymous.md b/doc/anonymous.md new file mode 100644 index 00000000..13026ab2 --- /dev/null +++ b/doc/anonymous.md @@ -0,0 +1,153 @@ +# Colaboraciones anónimas + +> Estamos escribiendo hipótesis para aclararnos las ideas. + + +[Ver discusión](https://0xacab.org/sutty/sutty/issues/75) + +## Configuración + +```yaml +# Actual +invitades: true + +# Nueva +invitades: + allowed: + - users + - guests + - anonymous +``` + +Pero en realidad no queremos instanciar todo el sitio y leer la +configuración para poder comprobar esto, así que lo movemos a la base de +datos. De todas formas es información que no tiene sentido almacenar en +`_config.yml` porque no tiene uso fuera de Sutty. + + +## Procedimiento + +* Al cargar el formulario, se incorpora una petición a la API de Sutty + que devuelve un recurso vacío y una cookie cifrada solo disponible + para HTTP (no para JS). Además agrega una cookie de sesión con un + token anti CSRF. Les decimos cookie-token y cookie-sesión + respectivamente. + +* Al enviar el formulario, la petición se envía con estas cookies. Si + los tokens coinciden, el envío se permite. Esto no es una protección + CSRF completa, sino una forma de validar que se solicitó una cookie + antes. + +* La sesión es válida si el token de sesión y el de la cookie coinciden. + +* La emisión de sesiones + cookies está limitada en el servidor. + +* Al cargar los datos correctamente, respondemos con una redirección + a la página de agradecimiento. + + +### CSRF + +* Las protecciones contra CSRF permitirían al sitio que envía obtener un + token de autenticación que se valida contra la cookie de sesión + enviada por el servidor al pedir el recurso. + +* El sitio tiene que enviar este token junto con la petición. + +* No tiene mucho sentido usar protección contra CSRF porque ya estamos + haciendo peticiones cruzadas. La protección contra CSRF previene + acciones que en realidad queremos realizar (!) + +* Trabajar con protección CSRF requiere que el sitio use JS que no + estábamos dispuestes a utilizar, porque hay que tomar el token + e incorporarlo al formulario en forma de campo oculto. Queremos que + les visitantes con JS deshabilitado puedan interactuar con nuestros + formularios también! + +* La validación que estamos haciendo entre cookie cifrada y fecha de + vencimiento de la cookie es una forma de protección contra CSRF + liviana y sin interacción, aprovechando los mecanismos del navegador. + +* Estamos mirando el valor de Origin para prevenir + +### XSS + +* Es importante limpiar todos las entradas de valores, para proteger + a les usuaries del sitio de ataques mayores, por ejemplo que se + introduzca JS en un artículo que luego se abre desde el panel. + +* Hay que chequear las protecciones CSRF en los formularios internos del + panel! + +* Hay que escapar todas las entradas al mostrarlas! + +* Si la redirección se obtiene desde el mismo formulario, estaría + abierto a XSS? + +### DOS + +* La idea es permitir el envío de colaboraciones a una tasa normal (1 + cada X minutos) y dificultar las tasas de envío agresivas (miles por + segundo). + +* El DOS no solo implica bajar el servidor, sino también llenar el sitio + de artículos basura y dificultar el uso. O sea, el DOS se aplica + a les usuaries, que se estresan. + +* Las cookies se pueden reutilizar, siempre y cuando el token sea + válido. Si guardáramos otra información como cantidad de + utilizaciones de una cookie, tendríamos que guardar el estado en la + base de datos y no queremos usar recursos en esto. + +* Los atacantes pueden descartar la cookie (volverla a emitir) + o utilizar siempre la misma. + +* Las cookies se emiten con límite, una cada 5 minutos. + +* La cookie tiene que durar lo que se puede tardar en cargar el + formulario y completarlo, con un excedente por las dudas. Los + formularios tienen que soportar el guardado offline / autocompletado + para evitar que les usuaries se frustren. También es posible hacer la + solicitud de cookie usando JS inmediatamente antes del envío para + reducir la duración de la cookie aun más. + +* Si la validez de la cookie-token es mayor que la tasa de emisión + (digamos, dura 30 minutos), un atacante puede enviar todos los + artículos que quiera durante ese tiempo (miles), con lo que tendríamos + que llevar un estado de las sesiones de todas formas. + +* El envío de información también puede tener tasa de petición. Si + aplicamos `rate limit` en nginx a X minutos entre cada una, tiene que + haber una diferencia de tiempo entre la emisión de la cookie y el + envío de la información. Si la cookie se reintenta usar muchas veces, + también aplica la limitación. + +* Si la tasa de envío es cada 5 minutos, un atacante podría enviar 288 + artículos por día desde una sola IP. + +## Casos de uso + +* Usuarie ingresa al sitio, completa el formulario y lo envía. + + Este es el caso que queremos. + +* Dogpiling: Usuarios maliciosos pero desorganizados ingresan al sitio, + completan el formulario muchas veces y lo envían. + + Para este caso estaríamos protegides por el rate limit. No tenemos + protección contra la paciencia y perseverancia del odio. + + Quizás les podamos empezar a enviar zip bombs. + +* DOS: Atacante individual o colectivo genera script que envía muchas + veces el formulario automáticamente. Puede enviar desde muchas + computadoras y es capaz de entender nuestra protecciones para + encontrarles puntos débiles. + + Nos protege el rate limit hasta cierto punto. + +* DDOS: Los atacantes aprovechan la capacidad de muchas personas que + voluntaria o inconcientemente ingresan a una URL que es capaz de enviar + información basura. + + Nos protege el rate limit, CORS y XSS.