diff --git a/.env.example b/.env.example index 2d888779..f02a3a88 100644 --- a/.env.example +++ b/.env.example @@ -9,7 +9,14 @@ REDIS_CLIENT= # API authentication HTTP_BASIC_USER= HTTP_BASIC_PASSWORD= +# Blazer BLAZER_DATABASE_URL= BLAZER_SLACK_WEBHOOK_URL= BLAZER_USERNAME= 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 diff --git a/app/controllers/api/v1/base_controller.rb b/app/controllers/api/v1/base_controller.rb index 7e673f2f..de58c343 100644 --- a/app/controllers/api/v1/base_controller.rb +++ b/app/controllers/api/v1/base_controller.rb @@ -24,6 +24,13 @@ module Api def origin request.headers['Origin'] end + + # El primer sitio es el sitio por defecto + # + # @return [Site] + def default_site + Site.first + end end end end diff --git a/app/controllers/api/v1/contact_controller.rb b/app/controllers/api/v1/contact_controller.rb index 4807814e..9200efca 100644 --- a/app/controllers/api/v1/contact_controller.rb +++ b/app/controllers/api/v1/contact_controller.rb @@ -3,30 +3,7 @@ module Api module V1 # API para formulario de contacto - class ContactController < 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 :form_exists?, unless: :performed? - before_action :from_is_address?, unless: :performed? - before_action :gave_consent?, unless: :performed? - + class ContactController < ProtectedController # Recibe un mensaje a través del formulario de contacto y lo envía # a les usuaries del sitio. # @@ -50,97 +27,34 @@ module Api 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? - return if contact_params[:from].blank? + return unless contact_params[:from].blank? return if EmailAddress.valid? contact_params[:from] + @reason = 'email_invalid' head :precondition_required end - # No aceptar nada si no dió su consentimiento def gave_consent? return if contact_params[:consent].present? + @reason = 'no_consent' head :precondition_required end # Los campos que se envían tienen que corresponder con un # formulario de contacto. def form_exists? - return if site.form? params[:form] + return if form? && site.form?(params[:form]) + @reason = 'form_doesnt_exist' head :precondition_required end - # Encuentra el sitio o devuelve nulo - def site - @site ||= Site.find_by(name: site_id) - end - # Parámetros limpios def contact_params @contact_params ||= params.permit(site.form(params[:form]).params) 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 diff --git a/app/controllers/api/v1/invitades_controller.rb b/app/controllers/api/v1/invitades_controller.rb index f0094408..360bb975 100644 --- a/app/controllers/api/v1/invitades_controller.rb +++ b/app/controllers/api/v1/invitades_controller.rb @@ -9,14 +9,17 @@ module Api # anterior, la cookie se renueva. class InvitadesController < BaseController # 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 - @site, contact = Site.where(name: site_id, contact: true) - .pluck(:name, :contact) - .first + contact = Site.where(name: site_id, contact: true) + .pluck(:contact) + .first set_cookie if contact - render file: Rails.root.join('public', '1x1.png'), + render file: rails.root.join('public', '1x1.png'), content_type: 'image/png', layout: false end @@ -26,28 +29,45 @@ module Api # 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: site_id, colaboracion_anonima: true) - .pluck(:name, :colaboracion_anonima) - .first + anon = Site.where(name: site_id, colaboracion_anonima: true) + .pluck(:colaboracion_anonima) + .first set_cookie if anon - render json: {}, status: :ok + render file: rails.root.join('public', '1x1.png'), + content_type: 'image/png', + layout: false end 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 # está cifrado para que no lo modifiquen les visitantes # # Enviamos un token de protección CSRF def set_cookie - expires = 30.minutes - cookies.encrypted[@site] = { + headers['Access-Control-Allow-Origin'] = return_origin + 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, secure: !Rails.env.test?, expires: expires, - same_site: :none, + same_site: :strict, value: { csrf: form_authenticity_token, expires: (Time.now + expires).to_i diff --git a/app/controllers/api/v1/posts_controller.rb b/app/controllers/api/v1/posts_controller.rb index 1729de78..3757fd12 100644 --- a/app/controllers/api/v1/posts_controller.rb +++ b/app/controllers/api/v1/posts_controller.rb @@ -2,31 +2,14 @@ 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. + # Recibe artículos desde colaboraciones anónimas + class PostsController < ProtectedController + # Crea un artículo solo si el sitio admite invitades def create # No procesar nada más si ya se aplicaron todos los filtros 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, site: site, usuarie: usuarie) @@ -34,74 +17,27 @@ module Api service.create_anonymous # Redirigir a la URL de agradecimiento - redirect_to params[:redirect_to] || site.url + redirect_to params[:redirect_to] || origin.to_s 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: 'cookie_invalid', status: :no_content - end + def gave_consent? + return if params[:consent].present? + + @reason = 'no_consent' + 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? - if valid_authenticity_token? session, cookies.encrypted[site_id]['csrf'] - return - end + def destination_exists? + return if post? && site.layout?(params[:layout]) - render html: 'token_invalid', status: :no_content + @reason = 'layout_doesnt_exist' + head :precondition_required 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: '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) + def from_is_address? + true end end end diff --git a/app/controllers/api/v1/protected_controller.rb b/app/controllers/api/v1/protected_controller.rb new file mode 100644 index 00000000..579732f7 --- /dev/null +++ b/app/controllers/api/v1/protected_controller.rb @@ -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 diff --git a/app/models/log_entry.rb b/app/models/log_entry.rb new file mode 100644 index 00000000..45023d51 --- /dev/null +++ b/app/models/log_entry.rb @@ -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 diff --git a/app/models/metadata_boolean.rb b/app/models/metadata_boolean.rb index a06ab7da..a2c9c106 100644 --- a/app/models/metadata_boolean.rb +++ b/app/models/metadata_boolean.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true # Implementa valores por sí o por no +# +# Esto es increíblemente difícil de lograr que salga bien! class MetadataBoolean < MetadataTemplate def default_value false diff --git a/app/models/site.rb b/app/models/site.rb index 3a0c13e5..915097f4 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -26,6 +26,7 @@ class Site < ApplicationRecord belongs_to :design belongs_to :licencia + has_many :log_entries has_many :deploys has_many :build_stats, through: :deploys has_many :roles @@ -245,7 +246,7 @@ class Site < ApplicationRecord # Crea un Struct dinámico cuyas llaves son los nombres de todos los # layouts. Si pasamos un layout que no existe, obtenemos un # 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| { name.to_sym => Layout.new(site: self, name: name.to_sym, @@ -253,6 +254,18 @@ class Site < ApplicationRecord end.inject(:merge)) 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 # # TODO: Traer recursivamente, si el campo contiene Hash diff --git a/app/models/site/author.rb b/app/models/site/author.rb deleted file mode 100644 index 28df9b22..00000000 --- a/app/models/site/author.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -class Site - Author = Struct.new :email, :name, keyword_init: true -end diff --git a/app/models/site/static_file_migration.rb b/app/models/site/static_file_migration.rb index e7875275..6b72df5c 100644 --- a/app/models/site/static_file_migration.rb +++ b/app/models/site/static_file_migration.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class Site - MigrationAuthor = Struct.new :email, :name, keyword_init: true - # Obtiene todos los archivos relacionados en artículos del sitio y los # sube a Sutty de forma que los podamos seguir utilizando normalmente # sin casos especiales (ej. soportar archivos locales al repositorio y @@ -79,9 +77,7 @@ class Site end # Guardamos los cambios - unless doc.save(validate: false) - log.write "#{doc.path.relative} no se pudo guardar\n" - end + log.write "#{doc.path.relative} no se pudo guardar\n" unless doc.save(validate: false) modified << doc.path.absolute end @@ -99,8 +95,8 @@ class Site private def author - @author = MigrationAuthor.new email: "sutty@#{Site.domain}", - name: 'Sutty' + @author ||= GitAuthor.new email: "sutty@#{Site.domain}", + name: 'Sutty' end # Encuentra todos los layouts con campos estáticos diff --git a/app/services/post_service.rb b/app/services/post_service.rb index 220a219d..48dd917c 100644 --- a/app/services/post_service.rb +++ b/app/services/post_service.rb @@ -20,14 +20,14 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do post 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 # XXX: Confiamos en el parámetro de idioma porque estamos # verificándolos en Site#posts self.post = site.posts(lang: locale) .build(layout: layout) # Los artículos anónimos siempre son borradores - params[:post][:draft] = true + params[:draft] = true commit(action: :created) if post.update(anon_post_params) post diff --git a/config/routes.rb b/config/routes.rb index 57a1fdf1..4bea1498 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -17,10 +17,11 @@ Rails.application.routes.draw do namespace :v1 do resources :csp_reports, only: %i[create] 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] - get :'contact/cookie', to: 'invitades#contact_cookie' - post 'contact/:form', to: 'contact#receive' + get :'invitades/cookie', to: 'invitades#cookie' + post :'posts/:layout', to: 'posts#create' + + get :'contact/cookie', to: 'invitades#contact_cookie' + post :'contact/:form', to: 'contact#receive' end end end diff --git a/db/migrate/20200615171026_create_log_entries.rb b/db/migrate/20200615171026_create_log_entries.rb new file mode 100644 index 00000000..58f38932 --- /dev/null +++ b/db/migrate/20200615171026_create_log_entries.rb @@ -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