recibir colaboraciones anónimas

This commit is contained in:
f 2020-06-16 19:10:54 -03:00
parent 553c5ee6a9
commit ab8442d144
14 changed files with 254 additions and 202 deletions

View file

@ -9,7 +9,14 @@ REDIS_CLIENT=
# API authentication # API authentication
HTTP_BASIC_USER= HTTP_BASIC_USER=
HTTP_BASIC_PASSWORD= HTTP_BASIC_PASSWORD=
# Blazer
BLAZER_DATABASE_URL= BLAZER_DATABASE_URL=
BLAZER_SLACK_WEBHOOK_URL= BLAZER_SLACK_WEBHOOK_URL=
BLAZER_USERNAME= BLAZER_USERNAME=
BLAZER_PASSWORD= BLAZER_PASSWORD=
# Guardar los formularios enviados como LogEntries
# @see Api::V1::ProtectedController
DEBUG_FORMS=
# Duración de la Cookie de invitade
# @see Api::V1::ProtectedController
COOKIE_DURATION=30

View file

@ -24,6 +24,13 @@ module Api
def origin def origin
request.headers['Origin'] request.headers['Origin']
end end
# El primer sitio es el sitio por defecto
#
# @return [Site]
def default_site
Site.first
end
end end
end end
end end

View file

@ -3,30 +3,7 @@
module Api module Api
module V1 module V1
# API para formulario de contacto # API para formulario de contacto
class ContactController < BaseController class ContactController < ProtectedController
# Permitir conexiones desde varios sitios, estamos chequeando más
# adelante.
skip_forgery_protection
# Aplicar algunos chequeos básicos. Deberíamos registrar de
# alguna forma los errores pero tampoco queremos que nos usen
# recursos.
#
# Devolvemos un error 428: Precondition Required dando la
# oportunidad a les visitantes de reintentar en el caso de falsos
# positivos. No devolvemos contenido para que el servidor web
# capture y muestra la página de error específica de cada sitio o
# la genérica de Sutty.
#
# XXX: Ordenar en orden ascendiente según uso de recursos.
before_action :cookie_is_valid?, unless: :performed?
before_action :valid_authenticity_token_in_cookie?, unless: :performed?
before_action :site_exists?, unless: :performed?
before_action :site_is_origin?, unless: :performed?
before_action :form_exists?, unless: :performed?
before_action :from_is_address?, unless: :performed?
before_action :gave_consent?, unless: :performed?
# Recibe un mensaje a través del formulario de contacto y lo envía # Recibe un mensaje a través del formulario de contacto y lo envía
# a les usuaries del sitio. # a les usuaries del sitio.
# #
@ -50,97 +27,34 @@ module Api
private private
def site_cookie
@site_cookie ||= cookies.encrypted[site_id]
end
# 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? Pero también
# puede ser que haya tardado más de media hora en enviar el
# formulario.
def cookie_is_valid?
head :precondition_required unless (site_cookie.try(:[], 'expires') || 0) > Time.now.to_i
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?
return if valid_authenticity_token? session, site_cookie['csrf']
head :precondition_required
end
# Comprueba que el sitio existe
#
# TODO: Responder con una zip bomb!
def site_exists?
head :precondition_required if site.nil?
end
# Comprueba que el mensaje fue enviado desde el sitio o uno
# de los sitios permitidos.
#
# XXX: Este header se puede falsificar de todas formas pero al
# menos es una trampa.
def site_is_origin?
return if site.urls(slash: false).any? { |u| origin.to_s.start_with? u }
head :precondition_required
end
# Detecta si la dirección de contacto es válida. Además es
# opcional.
def from_is_address? def from_is_address?
return if contact_params[:from].blank? return unless contact_params[:from].blank?
return if EmailAddress.valid? contact_params[:from] return if EmailAddress.valid? contact_params[:from]
@reason = 'email_invalid'
head :precondition_required head :precondition_required
end end
# No aceptar nada si no dió su consentimiento
def gave_consent? def gave_consent?
return if contact_params[:consent].present? return if contact_params[:consent].present?
@reason = 'no_consent'
head :precondition_required head :precondition_required
end end
# Los campos que se envían tienen que corresponder con un # Los campos que se envían tienen que corresponder con un
# formulario de contacto. # formulario de contacto.
def form_exists? def form_exists?
return if site.form? params[:form] return if form? && site.form?(params[:form])
@reason = 'form_doesnt_exist'
head :precondition_required head :precondition_required
end end
# Encuentra el sitio o devuelve nulo
def site
@site ||= Site.find_by(name: site_id)
end
# Parámetros limpios # Parámetros limpios
def contact_params def contact_params
@contact_params ||= params.permit(site.form(params[:form]).params) @contact_params ||= params.permit(site.form(params[:form]).params)
end end
# Para poder testear, enviamos un mensaje en el cuerpo de la
# respuesta
#
# @param [Any] el mensaje
def body(message)
return message.to_s if Rails.env.test?
end
# No queremos informar nada a los spammers, pero en testeo
# queremos saber por qué. :no_content oculta el cuerpo.
def status
Rails.env.test? ? :unprocessable_entity : :no_content
end
end end
end end
end end

View file

@ -9,14 +9,17 @@ module Api
# anterior, la cookie se renueva. # anterior, la cookie se renueva.
class InvitadesController < BaseController class InvitadesController < BaseController
# Cookie para el formulario de contacto # Cookie para el formulario de contacto
#
# Usamos where porque no nos importa encontrar el resultado, todes
# les visitantes reciben lo mismo, pero algunes no reciben cookie.
def contact_cookie def contact_cookie
@site, contact = Site.where(name: site_id, contact: true) contact = Site.where(name: site_id, contact: true)
.pluck(:name, :contact) .pluck(:contact)
.first .first
set_cookie if contact set_cookie if contact
render file: Rails.root.join('public', '1x1.png'), render file: rails.root.join('public', '1x1.png'),
content_type: 'image/png', content_type: 'image/png',
layout: false layout: false
end end
@ -26,28 +29,45 @@ module Api
# XXX: Prestar atención a que estas acciones sean lo más rápidas # XXX: Prestar atención a que estas acciones sean lo más rápidas
# y utilicen la menor cantidad posible de recursos, porque son # y utilicen la menor cantidad posible de recursos, porque son
# un vector de DDOS. # un vector de DDOS.
@site, anon = Site.where(name: site_id, colaboracion_anonima: true) anon = Site.where(name: site_id, colaboracion_anonima: true)
.pluck(:name, :colaboracion_anonima) .pluck(:colaboracion_anonima)
.first .first
set_cookie if anon set_cookie if anon
render json: {}, status: :ok render file: rails.root.join('public', '1x1.png'),
content_type: 'image/png',
layout: false
end end
private private
# Genera el Origin correcto a partir de la URL del sitio.
#
# En desarrollo devuelve el Origin enviado.
#
# @return [String]
def return_origin
Rails.env.production? ? Site.find_by(name: site_id).url : origin
end
# La cookie no es accesible a través de JS y todo su contenido # La cookie no es accesible a través de JS y todo su contenido
# está cifrado para que no lo modifiquen les visitantes # está cifrado para que no lo modifiquen les visitantes
# #
# Enviamos un token de protección CSRF # Enviamos un token de protección CSRF
def set_cookie def set_cookie
expires = 30.minutes headers['Access-Control-Allow-Origin'] = return_origin
cookies.encrypted[@site] = { headers['Access-Control-Allow-Credentials'] = true
headers['Vary'] = 'Origin'
# TODO: Volver configurable por sitio
expires = ENV.fetch('COOKIE_DURATION', '30').to_i.minutes
cookies.encrypted[site_id] = {
httponly: true, httponly: true,
secure: !Rails.env.test?, secure: !Rails.env.test?,
expires: expires, expires: expires,
same_site: :none, same_site: :strict,
value: { value: {
csrf: form_authenticity_token, csrf: form_authenticity_token,
expires: (Time.now + expires).to_i expires: (Time.now + expires).to_i

View file

@ -2,31 +2,14 @@
module Api module Api
module V1 module V1
class PostsController < BaseController # Recibe artículos desde colaboraciones anónimas
# Ver doc/anonymous.md class PostsController < ProtectedController
skip_forgery_protection # Crea un artículo solo si el sitio admite invitades
# 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 def create
# No procesar nada más si ya se aplicaron todos los filtros # No procesar nada más si ya se aplicaron todos los filtros
return if performed? return if performed?
usuarie = Site::Author.new name: 'Anon', email: "anon@#{site.hostname}" usuarie = GitAuthor.new name: 'Anon', email: "anon@#{site.hostname}"
service = PostService.new(params: params, service = PostService.new(params: params,
site: site, site: site,
usuarie: usuarie) usuarie: usuarie)
@ -34,74 +17,27 @@ module Api
service.create_anonymous service.create_anonymous
# Redirigir a la URL de agradecimiento # Redirigir a la URL de agradecimiento
redirect_to params[:redirect_to] || site.url redirect_to params[:redirect_to] || origin.to_s
end end
private private
# Comprueba que no se haya reutilizado una cookie vencida def gave_consent?
# return if params[:consent].present?
# XXX: Si el navegador envió una cookie vencida es porque la está
# reutilizando, probablemente de forma maliciosa? @reason = 'no_consent'
def cookie_is_valid? head :precondition_required
unless cookies.encrypted[site_id] &&
cookies.encrypted[site_id]['expires'] > Time.now.to_i
render html: 'cookie_invalid', status: :no_content
end
end end
# Queremos comprobar que la cookie corresponda con la sesión. La def destination_exists?
# cookie puede haber vencido, así que es uno de los chequeos más return if post? && site.layout?(params[:layout])
# 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?
if valid_authenticity_token? session, cookies.encrypted[site_id]['csrf']
return
end
render html: 'token_invalid', status: :no_content @reason = 'layout_doesnt_exist'
head :precondition_required
end end
# El sitio existe y soporta colaboracion anónima def from_is_address?
# true
# 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: 'site_not_anon', status: :no_content unless anon
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
return if request.headers['Origin'] == "https://#{site}"
render html: 'site_not_origin', status: :no_content
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
end end

View file

@ -0,0 +1,140 @@
# frozen_string_literal: true
module Api
module V1
# API para recibir información sin autenticación
class ProtectedController < BaseController
# Permitir conexiones desde varios sitios, estamos chequeando más
# adelante.
skip_forgery_protection
# Aplicar algunos chequeos básicos. Deberíamos registrar de
# alguna forma los errores pero tampoco queremos que nos usen
# recursos.
#
# Devolvemos un error 428: Precondition Required dando la
# oportunidad a les visitantes de reintentar en el caso de falsos
# positivos. No devolvemos contenido para que el servidor web
# capture y muestra la página de error específica de cada sitio o
# la genérica de Sutty.
#
# XXX: Ordenar en orden ascendiente según uso de recursos.
before_action :cookie_is_valid?, unless: :performed?
before_action :valid_authenticity_token_in_cookie?, unless: :performed?
before_action :site_exists?, unless: :performed?
before_action :site_is_origin?, unless: :performed?
before_action :destination_exists?, unless: :performed?
before_action :from_is_address?, unless: :performed?
before_action :gave_consent?, unless: :performed?
# Reescribir performed? para registrar lo que pasó en el camino a
# menos que esté todo bien.
def performed?
if (performed = super) && response.status >= 400
log_entry if ENV['DEBUG_FORMS'].present?
end
performed
end
private
def site_cookie
@site_cookie ||= cookies.encrypted[site_id]
end
# 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? Pero también
# puede ser que haya tardado más de media hora en enviar el
# formulario.
def cookie_is_valid?
return if (site_cookie.try(:[], 'expires') || 0) > Time.now.to_i
@reason = 'expired_or_invalid_cookie'
head :precondition_required
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?
return if valid_authenticity_token? session, site_cookie['csrf']
@reason = 'invalid_auth_token'
head :precondition_required
end
# Comprueba que el sitio existe
#
# TODO: Responder con una zip bomb!
def site_exists?
return unless site.nil?
@reason = 'site_does_not_exist'
head :precondition_required
end
# Comprueba que el mensaje fue enviado desde el sitio o uno
# de los sitios permitidos.
#
# XXX: Este header se puede falsificar de todas formas pero al
# menos es una trampa.
def site_is_origin?
return if site.urls(slash: false).any? { |u| origin.to_s.start_with? u }
@reason = 'site_is_not_origin'
head :precondition_required
end
# Detecta si la dirección de contacto es válida. Además es
# opcional.
def from_is_address?
raise NotImplementedError
end
# No aceptar nada si no dió su consentimiento
def gave_consent?
raise NotImplementedError
end
def post?
params[:layout].present?
end
def form?
params[:form].present?
end
# Los campos que se envían tienen que corresponder con un
# formulario de contacto o un layout
def destination_exists?
raise NotImplementedError
end
# Encuentra el sitio o devuelve nulo
def site
@site ||= Site.find_by(name: site_id)
end
# Genera un registro con información básica para debug, quizás no
# quede asociado a ningún sitio.
#
# TODO: Chequear qué pasa con los archivos adjuntos.
#
# @return [TrueClass]
def log_entry
LogEntry.create site: site || default_site, text: {
reason: @reason,
status: response.status,
headers: request.headers.to_h.select { |k, _| /\A[A-Z]/ =~ k },
params: params
}
end
end
end
end

10
app/models/log_entry.rb Normal file
View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
# Un registro de cualquier cosa, nos sirve para debuguear
# selectivamente.
class LogEntry < ApplicationRecord
belongs_to :site
serialize :text, JSON
default_scope -> { order(created_at: :desc) }
end

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
# Implementa valores por sí o por no # Implementa valores por sí o por no
#
# Esto es increíblemente difícil de lograr que salga bien!
class MetadataBoolean < MetadataTemplate class MetadataBoolean < MetadataTemplate
def default_value def default_value
false false

View file

@ -26,6 +26,7 @@ class Site < ApplicationRecord
belongs_to :design belongs_to :design
belongs_to :licencia belongs_to :licencia
has_many :log_entries
has_many :deploys has_many :deploys
has_many :build_stats, through: :deploys has_many :build_stats, through: :deploys
has_many :roles has_many :roles
@ -245,7 +246,7 @@ class Site < ApplicationRecord
# Crea un Struct dinámico cuyas llaves son los nombres de todos los # Crea un Struct dinámico cuyas llaves son los nombres de todos los
# layouts. Si pasamos un layout que no existe, obtenemos un # layouts. Si pasamos un layout que no existe, obtenemos un
# NoMethodError # NoMethodError
@layouts_struct ||= Struct.new(*data.fetch('layouts', {}).keys.map(&:to_sym), keyword_init: true) @layouts_struct ||= Struct.new(*layout_keys, keyword_init: true)
@layouts ||= @layouts_struct.new(**data.fetch('layouts', {}).map do |name, metadata| @layouts ||= @layouts_struct.new(**data.fetch('layouts', {}).map do |name, metadata|
{ name.to_sym => Layout.new(site: self, { name.to_sym => Layout.new(site: self,
name: name.to_sym, name: name.to_sym,
@ -253,6 +254,18 @@ class Site < ApplicationRecord
end.inject(:merge)) end.inject(:merge))
end end
def layout_keys
@layout_keys ||= data.fetch('layouts', {}).keys.map(&:to_sym)
end
# Consulta si el Layout existe
#
# @param [String,Symbol] El nombre del Layout
# @return [Boolean]
def layout?(layout)
layout_keys.include? layout.to_sym
end
# Trae todos los valores disponibles para un campo # Trae todos los valores disponibles para un campo
# #
# TODO: Traer recursivamente, si el campo contiene Hash # TODO: Traer recursivamente, si el campo contiene Hash

View file

@ -1,5 +0,0 @@
# frozen_string_literal: true
class Site
Author = Struct.new :email, :name, keyword_init: true
end

View file

@ -1,8 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class Site class Site
MigrationAuthor = Struct.new :email, :name, keyword_init: true
# Obtiene todos los archivos relacionados en artículos del sitio y los # Obtiene todos los archivos relacionados en artículos del sitio y los
# sube a Sutty de forma que los podamos seguir utilizando normalmente # sube a Sutty de forma que los podamos seguir utilizando normalmente
# sin casos especiales (ej. soportar archivos locales al repositorio y # sin casos especiales (ej. soportar archivos locales al repositorio y
@ -79,9 +77,7 @@ class Site
end end
# Guardamos los cambios # Guardamos los cambios
unless doc.save(validate: false) log.write "#{doc.path.relative} no se pudo guardar\n" unless doc.save(validate: false)
log.write "#{doc.path.relative} no se pudo guardar\n"
end
modified << doc.path.absolute modified << doc.path.absolute
end end
@ -99,8 +95,8 @@ class Site
private private
def author def author
@author = MigrationAuthor.new email: "sutty@#{Site.domain}", @author ||= GitAuthor.new email: "sutty@#{Site.domain}",
name: 'Sutty' name: 'Sutty'
end end
# Encuentra todos los layouts con campos estáticos # Encuentra todos los layouts con campos estáticos

View file

@ -20,14 +20,14 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
post post
end end
# Crear un post anónimo, con opciones más limitadas # Crear un post anónimo, con opciones más limitadas. No usamos post.
def create_anonymous def create_anonymous
# XXX: Confiamos en el parámetro de idioma porque estamos # XXX: Confiamos en el parámetro de idioma porque estamos
# verificándolos en Site#posts # verificándolos en Site#posts
self.post = site.posts(lang: locale) self.post = site.posts(lang: locale)
.build(layout: layout) .build(layout: layout)
# Los artículos anónimos siempre son borradores # Los artículos anónimos siempre son borradores
params[:post][:draft] = true params[:draft] = true
commit(action: :created) if post.update(anon_post_params) commit(action: :created) if post.update(anon_post_params)
post post

View file

@ -17,10 +17,11 @@ Rails.application.routes.draw do
namespace :v1 do namespace :v1 do
resources :csp_reports, only: %i[create] resources :csp_reports, only: %i[create]
resources :sites, only: %i[index], constraints: { site_id: /[a-z0-9\-\.]+/, id: /[a-z0-9\-\.]+/ } do resources :sites, only: %i[index], constraints: { site_id: /[a-z0-9\-\.]+/, id: /[a-z0-9\-\.]+/ } do
get 'invitades/cookie', to: 'invitades#cookie' get :'invitades/cookie', to: 'invitades#cookie'
resources :posts, only: %i[create] post :'posts/:layout', to: 'posts#create'
get :'contact/cookie', to: 'invitades#contact_cookie'
post 'contact/:form', to: 'contact#receive' get :'contact/cookie', to: 'invitades#contact_cookie'
post :'contact/:form', to: 'contact#receive'
end end
end end
end end

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
class CreateLogEntries < ActiveRecord::Migration[6.0]
def change
create_table :log_entries do |t|
t.timestamps
t.belongs_to :site, index: true
t.text :text
end
end
end