mirror of
https://0xacab.org/sutty/sutty
synced 2024-11-15 05:41:42 +00:00
WIP: colaboraciones anonimas
This commit is contained in:
parent
e6dce0e055
commit
164cb5dfa2
6 changed files with 309 additions and 1 deletions
45
app/controllers/api/v1/invitades_controller.rb
Normal file
45
app/controllers/api/v1/invitades_controller.rb
Normal file
|
@ -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
|
99
app/controllers/api/v1/posts_controller.rb
Normal file
99
app/controllers/api/v1/posts_controller.rb
Normal file
|
@ -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
|
|
@ -34,4 +34,5 @@
|
||||||
- next if @post.send(attr).front_matter?
|
- next if @post.send(attr).front_matter?
|
||||||
|
|
||||||
%section{ id: attr }
|
%section{ id: attr }
|
||||||
|
-# TODO: Esto es un agujero de XSS!!!!
|
||||||
= raw @post.send(attr).value
|
= raw @post.send(attr).value
|
||||||
|
|
|
@ -21,7 +21,10 @@ Rails.application.routes.draw do
|
||||||
namespace :v1 do
|
namespace :v1 do
|
||||||
resources :csp_reports, only: %i[create]
|
resources :csp_reports, only: %i[create]
|
||||||
get 'sites/allowed', to: 'sites#allowed'
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|
7
db/migrate/20200130193655_add_anonymous_to_site.rb
Normal file
7
db/migrate/20200130193655_add_anonymous_to_site.rb
Normal file
|
@ -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
|
153
doc/anonymous.md
Normal file
153
doc/anonymous.md
Normal file
|
@ -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.
|
Loading…
Reference in a new issue