# 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&.dig('expires') || 0) > Time.now.to_i @reason = 'expired_or_invalid_cookie' render plain: Rails.env.production? ? nil : @reason, status: :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' render plain: Rails.env.production? ? nil : @reason, status: :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' render plain: Rails.env.production? ? nil : @reason, status: :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 origin? && site.urls(slash: false).any? { |u| origin.to_s.start_with? u } @reason = 'site_is_not_origin' render plain: Rails.env.production? ? nil : @reason, status: :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 || Site.default, text: { reason: @reason, status: response.status, headers: request.headers.to_h.select { |k, _| /\A[A-Z]/ =~ k }, params: params } end end end end