From b7d8ca565ae495004e4fa88c9286114fc245d380 Mon Sep 17 00:00:00 2001 From: LorenzoBellomo Date: Tue, 29 Sep 2026 11:31:09 -0300 Subject: [PATCH 1/6] fix: [TESIS-130] accept only the front origins in CORS The API answered Access-Control-Allow-Origin: * to any origin, so any page could call it from its visitors' browsers, for instance spreading login attempts across many IPs to dodge the per-IP limit. The origins now come from CORS_ALLOWED_ORIGINS, with a one-level wildcard for the tenant subdomain; without it, outside production, only localhost is accepted. Co-Authored-By: Claude Opus 5.5 --- config/initializers/cors.rb | 26 +++++++++++++++++++- spec/requests/cors_spec.rb | 48 +++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 spec/requests/cors_spec.rb diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb index 5acc0941..9db74a54 100644 --- a/config/initializers/cors.rb +++ b/config/initializers/cors.rb @@ -5,9 +5,33 @@ # Read more: https://github.com/cyu/rack-cors +# Sólo el front puede llamar a la API desde un navegador (TESIS-130, ADR-018). +# Con `origins '*'`, cualquier página podía hacerlo: por ejemplo, repartir intentos +# de login entre los navegadores de sus visitantes, cada uno con su IP, y esquivar +# el límite por IP de TESIS-82. +# +# Los orígenes salen de CORS_ALLOWED_ORIGINS, separados por comas, y la variable +# es obligatoria en producción (config/environments/production.rb). Como cada +# empresa entra por su subdominio, se acepta un comodín para ese nivel: +# `https://*.precision-logistics.duckdns.org` vale para norte., sur., etc., pero no +# para a.b. ni para el dominio sin subdominio. rack-cors compara los strings tal +# cual, así que el comodín se traduce a una regex anclada. +# +# Sin la variable (desarrollo y test) se acepta localhost en cualquier puerto: el +# dev server de Vite (5173), `vite preview` o el contenedor de nginx del front. +cors_origins = ENV.fetch('CORS_ALLOWED_ORIGINS', '').split(',').map(&:strip).reject(&:empty?) +cors_origins.map! do |origin| + next origin unless origin.include?('*') + + /\A#{Regexp.escape(origin).sub('\*', '[a-z0-9-]+')}\z/ +end +if cors_origins.empty? && !Rails.env.production? + cors_origins = [%r{\Ahttp://(localhost|127\.0\.0\.1)(:\d+)?\z}] +end + Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do - origins '*' + origins(*cors_origins) resource '*', headers: :any, methods: %i[get post put patch delete options head], diff --git a/spec/requests/cors_spec.rb b/spec/requests/cors_spec.rb new file mode 100644 index 00000000..bd3aca95 --- /dev/null +++ b/spec/requests/cors_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'rails_helper' + +# Hasta TESIS-130 la API respondía `Access-Control-Allow-Origin: *` a cualquier +# origen. Ahora sólo al front: en producción, la lista de CORS_ALLOWED_ORIGINS; +# en desarrollo y test, localhost en cualquier puerto (ADR-018). +RSpec.describe 'CORS', type: :request do + def preflight(origin) + options '/api/v1/auth/login', headers: { + 'Origin' => origin, + 'Access-Control-Request-Method' => 'POST', + 'Access-Control-Request-Headers' => 'content-type' + } + end + + def allowed_origin + response.headers['Access-Control-Allow-Origin'] + end + + it 'lets the front dev server call the API' do + preflight('http://localhost:5173') + + expect(allowed_origin).to eq('http://localhost:5173') + end + + it 'answers other origins without the CORS headers' do + preflight('https://evil.example') + + expect(allowed_origin).to be_nil + end + + it 'does not take a host that only starts like localhost' do + preflight('http://localhost.evil.example') + + expect(allowed_origin).to be_nil + end + + # El ETag tiene que seguir expuesto: sin él, el front no manda If-Match y el + # locking optimista de TESIS-101 se apaga sin que nada falle a la vista. + it 'still exposes the ETag to the allowed origin', :aggregate_failures do + get '/api/v1/tenant-config', params: { slug: 'norte' }, + headers: { 'Origin' => 'http://localhost:5173' } + + expect(allowed_origin).to eq('http://localhost:5173') + expect(response.headers['Access-Control-Expose-Headers']).to include('ETag') + end +end From 3202020778a14d6e434afe6276327089e6947d8e Mon Sep 17 00:00:00 2001 From: LorenzoBellomo Date: Tue, 29 Sep 2026 11:31:09 -0300 Subject: [PATCH 2/6] fix: [TESIS-130] require the deploy secrets and serve production over HTTPS only Production now refuses to boot without DEVISE_JWT_SECRET_KEY, CORS_ALLOWED_ORIGINS and RAILS_ALLOWED_HOSTS instead of signing tokens with secret_key_base and skipping the Host check. assume_ssl and force_ssl are on behind the TLS proxy, which adds HSTS and marks the backoffice session cookie as Secure. /up stays out of the host check for the health checks. Co-Authored-By: Claude Opus 5.5 --- .env.example | 22 +++++++++++++-- config/environments/production.rb | 45 +++++++++++++++++++++---------- config/initializers/devise_jwt.rb | 5 +++- 3 files changed, 55 insertions(+), 17 deletions(-) diff --git a/.env.example b/.env.example index 3caa2172..f5986ca5 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,8 @@ -# Copiar a .env y completar. En test/CI, DEVISE_JWT_SECRET_KEY cae por defecto -# a Rails.application.secret_key_base si no está seteada. +# Copiar a .env y completar. En desarrollo y test, DEVISE_JWT_SECRET_KEY cae por +# defecto a Rails.application.secret_key_base si no está seteada. # Clave secreta para firmar los JWT. Generar con: rails secret +# En producción es obligatoria, una distinta por entorno (ADR-018). DEVISE_JWT_SECRET_KEY= # Password del rol de PostgreSQL en desarrollo. @@ -11,3 +12,20 @@ PROYECTO_API_DATABASE_PASSWORD=admin # Generar cada una con: rails secret. En dev/test caen a secret_key_base. ENCRYPTION_KEY= ENCRYPTION_KEY_DERIVATION_SALT= + +# --- Sólo producción (ADR-018) ---------------------------------------------- +# Sin DEVISE_JWT_SECRET_KEY, CORS_ALLOWED_ORIGINS ni RAILS_ALLOWED_HOSTS la API +# no arranca en producción. En desarrollo no hacen falta. + +# Orígenes del front, separados por comas. `*` vale para un nivel de subdominio +# (el de cada empresa). Sin la variable, fuera de producción: localhost. +# CORS_ALLOWED_ORIGINS=https://*.precision-logistics.duckdns.org + +# Hosts a los que responde la API, separados por comas. +# RAILS_ALLOWED_HOSTS=api.precision-logistics.duckdns.org + +# Contraseñas de las cuentas que crea db:seed (el entrypoint del Dockerfile lo +# corre sobre toda base nueva). En producción son obligatorias para sembrar y +# no pueden ser las de desarrollo que trae el repo. +# SEED_USER_PASSWORD= +# SEED_ADMIN_PASSWORD= diff --git a/config/environments/production.rb b/config/environments/production.rb index 35fd999a..cdef212d 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -3,6 +3,22 @@ Rails.application.configure do # Settings specified here will take precedence over those in config/application.rb. + # Sin estas variables la API no arranca en producción (TESIS-130, ADR-018). + # Sin ellas, el JWT se firmaría con secret_key_base, el Host no se validaría y + # CORS no dejaría pasar al front: las dos primeras dejan la puerta abierta sin + # que nada falle a la vista, y la tercera rompe el front sin decir por qué. + # Que el contenedor no levante es mejor que cualquiera de las tres. + # SECRET_KEY_BASE_DUMMY marca el `assets:precompile` del Dockerfile, que + # arranca la app en el build, cuando todavía no hay secretos del entorno. + unless ENV['SECRET_KEY_BASE_DUMMY'] + missing = %w[DEVISE_JWT_SECRET_KEY CORS_ALLOWED_ORIGINS RAILS_ALLOWED_HOSTS].select do |name| + ENV[name].blank? + end + if missing.any? + raise "Missing environment variables for production: #{missing.join(', ')} (see ADR-018)" + end + end + # Code is not reloaded between requests. config.enable_reloading = false @@ -21,11 +37,12 @@ # Store uploaded files on the local file system (see config/storage.yml for options). config.active_storage.service = :local - # Assume all access to the app is happening through a SSL-terminating reverse proxy. - # config.assume_ssl = true - - # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. - # config.force_ssl = true + # El TLS termina en el proxy (Caddy), que también redirige HTTP a HTTPS: a Rails + # le llega HTTP plano. assume_ssl hace que tome esos requests como HTTPS, y + # force_ssl suma Strict-Transport-Security y marca las cookies como Secure, + # entre ellas la de la sesión del backoffice (TESIS-130, ADR-018). + config.assume_ssl = true + config.force_ssl = true # Skip http-to-https redirect for the default health check endpoint. # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } @@ -81,15 +98,15 @@ # "example.com", # Allow requests from example.com # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` # ] - # - # Skip DNS rebinding protection for the default health check endpoint. - # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } # Despliegue en contenedor detrás de un proxy TLS (Caddy + DuckDNS): el Host - # que ve Rails es el subdominio público, y sin listarlo acá la protección - # contra DNS rebinding responde 403. Se configura por entorno - # (RAILS_ALLOWED_HOSTS, separado por comas); sin la variable se mantiene el - # comportamiento por defecto. - allowed_hosts = ENV.fetch('RAILS_ALLOWED_HOSTS', '').split(',').map(&:strip).reject(&:empty?) - config.hosts.concat(allowed_hosts) if allowed_hosts.any? + # que ve Rails es el subdominio público de la API. Los hosts salen de + # RAILS_ALLOWED_HOSTS, separados por comas, y la variable es obligatoria + # (ver arriba): sin lista, Rails no valida el Host en producción. Con ella, un + # request con otro Host recibe 403 (TESIS-120, TESIS-130). + config.hosts.concat(ENV.fetch('RAILS_ALLOWED_HOSTS', '').split(',').map(&:strip).reject(&:empty?)) + + # El health check de /up llega con el Host que use quien lo haga (la IP del + # contenedor, localhost), no con el público: validarlo lo daría por caído. + config.host_authorization = { exclude: ->(request) { request.path == '/up' } } end diff --git a/config/initializers/devise_jwt.rb b/config/initializers/devise_jwt.rb index c63785a5..3be22058 100644 --- a/config/initializers/devise_jwt.rb +++ b/config/initializers/devise_jwt.rb @@ -16,7 +16,10 @@ end config.jwt do |jwt| - jwt.secret = ENV.fetch('DEVISE_JWT_SECRET_KEY') { Rails.application.secret_key_base } + # El fallback a secret_key_base es para desarrollo y test. En producción la + # variable es obligatoria (config/environments/production.rb): cada entorno + # firma con su propio secreto y un token emitido en otro no sirve (TESIS-130). + jwt.secret =ENV.fetch('DEVISE_JWT_SECRET_KEY') { Rails.application.secret_key_base } jwt.expiration_time = 1.day.to_i end end From 6b94b2df456afd2dd5333f1239b68fb1ff532d1c Mon Sep 17 00:00:00 2001 From: LorenzoBellomo Date: Tue, 29 Sep 2026 11:31:09 -0300 Subject: [PATCH 3/6] fix: [TESIS-130] keep the repo passwords out of the deployed seeds The Docker entrypoint runs db:prepare, which seeds every new database, so a deploy started with admin123 and password123. In production the seeded passwords now come from SEED_USER_PASSWORD and SEED_ADMIN_PASSWORD, the seed refuses to run without them or with a repo password, and it rotates the accounts a previous seed left with the repo passwords. Co-Authored-By: Claude Opus 5.5 --- db/seeds.rb | 54 ++++++++++++++++++++----- spec/db/seeds_spec.rb | 93 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 9 deletions(-) create mode 100644 spec/db/seeds_spec.rb diff --git a/db/seeds.rb b/db/seeds.rb index 29820e21..b728e587 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -7,6 +7,40 @@ # # Multi-tenancy: todos los datos viven bajo una Company (tenant). Ver docs/guidelines/multi-tenancy-rls.md. +# --------------------------------------------------------------------------- +# TESIS-130 — Contraseñas de las cuentas sembradas +# --------------------------------------------------------------------------- +# Las contraseñas de desarrollo están en el repo: en un entorno desplegado le +# abrirían las cuentas a cualquiera que lo lea. El entrypoint del Dockerfile +# corre db:prepare, que siembra toda base nueva, así que en producción salen de +# SEED_USER_PASSWORD (usuarios de las empresas) y SEED_ADMIN_PASSWORD +# (administrador del backoffice). Sin ellas, o con una del repo, el seed no corre. + +repo_passwords = { user: 'password123', admin: 'admin123' }.freeze + +seed_passwords = + if Rails.env.production? + env_passwords = { user: ENV['SEED_USER_PASSWORD'], admin: ENV['SEED_ADMIN_PASSWORD'] } + if env_passwords.values.any?(&:blank?) || env_passwords.values.intersect?(repo_passwords.values) + raise 'Production seeds need SEED_USER_PASSWORD and SEED_ADMIN_PASSWORD, ' \ + 'and neither can be a password from the repo (see ADR-018)' + end + + env_passwords + else + repo_passwords + end + +# find_or_create_by! no toca una cuenta que ya existe, y una base sembrada antes +# de TESIS-130 las tiene con la contraseña del repo. En producción se rotan acá: +# correr `bin/rails db:seed` con las variables cierra esas cuentas, y las que ya +# tienen otra contraseña quedan como están. +rotate_repo_password = lambda do |account, kind| + return unless Rails.env.production? && account.valid_password?(repo_passwords[kind]) + + account.update!(password: seed_passwords[kind]) +end + # --------------------------------------------------------------------------- # TESIS-25 — Core & Tenancy: Companies, Users, Warehouses # --------------------------------------------------------------------------- @@ -31,8 +65,8 @@ 'tagline' => 'Logística del norte' }, users: [ - { email: 'admin@norte.com', password: 'password123' }, - { email: 'operador@norte.com', password: 'password123' } + { email: 'admin@norte.com' }, + { email: 'operador@norte.com' } ], warehouses: [ { name: 'Depósito Central', zip_code: '1900', address: 'Av. 7 N° 1234, La Plata' }, @@ -59,8 +93,8 @@ 'theme_mode' => 'light' }, users: [ - { email: 'admin@sur.com', password: 'password123' }, - { email: 'deposito@sur.com', password: 'password123' } + { email: 'admin@sur.com' }, + { email: 'deposito@sur.com' } ], warehouses: [ { name: 'Depósito Sur', zip_code: '8000', address: 'Av. Colón N° 789, Bahía Blanca' } @@ -81,7 +115,7 @@ 'tagline' => 'Empresa dada de baja' }, users: [ - { email: 'admin@vieja.com', password: 'password123' } + { email: 'admin@vieja.com' } ], warehouses: [ { name: 'Depósito en Liquidación', zip_code: '5000', address: 'Bv. San Juan N° 100, Córdoba' } @@ -112,10 +146,11 @@ ) attrs[:users].each do |user_attrs| - User.find_or_create_by!(email: user_attrs[:email]) do |u| - u.password = user_attrs[:password] + user = User.find_or_create_by!(email: user_attrs[:email]) do |u| + u.password = seed_passwords[:user] u.company = company end + rotate_repo_password.call(user, :user) end attrs[:warehouses].each do |warehouse_attrs| @@ -130,9 +165,10 @@ # TESIS-29 — Backoffice: administrador inicial del panel /admin # --------------------------------------------------------------------------- -AdminUser.find_or_create_by!(email: 'admin@backoffice.com') do |admin| - admin.password = 'admin123' +backoffice_admin = AdminUser.find_or_create_by!(email: 'admin@backoffice.com') do |admin| + admin.password = seed_passwords[:admin] end +rotate_repo_password.call(backoffice_admin, :admin) # --------------------------------------------------------------------------- # TESIS-28 — Integraciones: Services (plantillas globales) + CompanyIntegrations diff --git a/spec/db/seeds_spec.rb b/spec/db/seeds_spec.rb new file mode 100644 index 00000000..7669bf9e --- /dev/null +++ b/spec/db/seeds_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'rails_helper' + +# Los seeds corren también en el entorno desplegado: el entrypoint del Dockerfile +# hace db:prepare, que siembra toda base nueva. Las contraseñas que el repo trae +# para desarrollo no pueden terminar ahí (TESIS-130, ADR-018). +RSpec.describe 'db/seeds.rb' do # rubocop:disable RSpec/DescribeClass + let(:company_emails) do + %w[admin@norte.com operador@norte.com admin@sur.com deposito@sur.com admin@vieja.com] + end + + def run_seeds + load Rails.root.join('db/seeds.rb') + end + + def seeded_admin + AdminUser.find_by!(email: 'admin@backoffice.com') + end + + def password_of?(email, password) + User.find_by!(email: email).valid_password?(password) + end + + # El resumen del final; en la salida de los specs es ruido. + before { allow($stdout).to receive(:puts) } + + around do |example| + previous = ENV.to_h.slice('SEED_USER_PASSWORD', 'SEED_ADMIN_PASSWORD') + example.run + %w[SEED_USER_PASSWORD SEED_ADMIN_PASSWORD].each { |name| ENV[name] = previous[name] } + end + + context 'when not in production' do + it 'seeds the accounts with the development passwords', :aggregate_failures do + run_seeds + + expect(password_of?('admin@norte.com', 'password123')).to be(true) + expect(seeded_admin.valid_password?('admin123')).to be(true) + end + end + + context 'when in production' do + let(:earlier_company) { Company.create!(name: 'Seeded before', tax_id: '30-99999999-9') } + + before do + allow(Rails.env).to receive(:production?).and_return(true) + ENV['SEED_USER_PASSWORD'] = 'users-password-from-env' + ENV['SEED_ADMIN_PASSWORD'] = 'admin-password-from-env' + end + + it 'creates every account with the passwords from the environment', :aggregate_failures do + run_seeds + + users = User.where(email: company_emails) + expect(users.size).to eq(company_emails.size) + expect(users).to all(satisfy { |user| user.valid_password?('users-password-from-env') }) + expect(seeded_admin.valid_password?('admin-password-from-env')).to be(true) + end + + it 'rotates the accounts a previous seed left with the repo passwords', :aggregate_failures do + User.create!(email: 'admin@norte.com', password: 'password123', company: earlier_company) + AdminUser.create!(email: 'admin@backoffice.com', password: 'admin123') + + run_seeds + + expect(password_of?('admin@norte.com', 'password123')).to be(false) + expect(seeded_admin.valid_password?('admin123')).to be(false) + end + + it 'leaves alone an account whose password was already changed' do + User.create!(email: 'admin@sur.com', password: 'changed-by-the-operator', company: earlier_company) + + run_seeds + + expect(password_of?('admin@sur.com', 'changed-by-the-operator')).to be(true) + end + + it 'refuses to seed without the passwords', :aggregate_failures do + ENV['SEED_ADMIN_PASSWORD'] = nil + + expect { run_seeds }.to raise_error(RuntimeError, /SEED_ADMIN_PASSWORD/) + expect(Company.count).to eq(0) + end + + it 'refuses a password that lives in the repo', :aggregate_failures do + ENV['SEED_USER_PASSWORD'] = 'password123' + + expect { run_seeds }.to raise_error(RuntimeError, /neither can be a password from the repo/) + expect(User.count).to eq(0) + end + end +end From 2e4456ac04ab692ce880cb8b6a1adfaa2114ff62 Mon Sep 17 00:00:00 2001 From: LorenzoBellomo Date: Tue, 29 Sep 2026 11:31:10 -0300 Subject: [PATCH 4/6] docs: [TESIS-130] record the deploy security decisions in ADR-018 Co-Authored-By: Claude Opus 5.5 --- docs/adr/ADR-017-backoffice.md | 4 +- docs/adr/ADR-018-seguridad-del-despliegue.md | 102 +++++++++++++++++++ docs/guidelines/architecture.md | 4 +- 3 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 docs/adr/ADR-018-seguridad-del-despliegue.md diff --git a/docs/adr/ADR-017-backoffice.md b/docs/adr/ADR-017-backoffice.md index 89d44ff3..c69f1ac9 100644 --- a/docs/adr/ADR-017-backoffice.md +++ b/docs/adr/ADR-017-backoffice.md @@ -40,7 +40,7 @@ La consecuencia es que la seguridad del backoffice descansa entera en el login y - **El logout invalida la cookie, no sólo la borra del navegador.** El cookie store no guarda nada del lado del servidor, así que una cookie copiada antes del logout seguía abriendo el panel. Devise valida la cookie comparando `authenticatable_salt`. `AdminUser` le suma una columna `session_token`, y el logout la rota (`AdminUser#expire_sessions!`). El efecto es que el logout cierra **todas** las sesiones de esa cuenta, también la de «Recordarme» y la de otro navegador. Cambiar la password también las cierra, como antes. - **`Cache-Control: no-store`** en todas las páginas de Avo (`Admin::UncacheablePages`). Con el default de Rails (`private, must-revalidate`), después del logout el botón «atrás» podía mostrar una página del panel desde la cache del navegador. El concern se incluye en `Avo::ApplicationController` desde el initializer, que es la forma que documenta Avo para sumar comportamiento a todos sus controllers sin copiar el suyo. - **Orden de los middlewares.** Cookies, sesión y flash van antes de `Warden::Manager`. Con `config.middleware.use` quedaban después, porque Devise registra Warden al cargarse. Cuando Warden corta un request con `throw :warden`, el throw se salteaba el commit de la sesión. No tuvo consecuencias visibles hasta que se agregó el timeout: el cierre por inactividad no llegaba a la cookie y el navegador entraba en un loop de redirecciones. -- **Cookie:** `HttpOnly` y `SameSite=Lax`, los defaults de Rails. ⚠️ `Secure` depende de servir la app por HTTPS (`config.assume_ssl` / `config.force_ssl`), y eso corresponde a la card de seguridad transversal y despliegue. +- **Cookie:** `HttpOnly` y `SameSite=Lax`, los defaults de Rails, y `Secure` en producción: `config.assume_ssl` y `config.force_ssl` se prendieron en TESIS-130 (ver [ADR-018](ADR-018-seguridad-del-despliegue.md)). - **CSRF:** Avo y el controller de sesiones de Devise usan `protect_from_forgery with: :exception`, así que un POST sin token responde 422. En test la protección está apagada; `spec/requests/admin/sessions_spec.rb` la prende para verificarlo. ### Datos sensibles: credenciales de las integraciones @@ -103,7 +103,7 @@ Un store en la base (gema `activerecord-session_store`) o en la cache (`:cache_s - ✅ Las credenciales de las empresas no salen de la base en claro por ningún camino: ni la API ni el backoffice las muestran - ✅ El alta de usuarios desde el backoffice funciona, y un usuario no se puede pasar a otra empresa - ✅ La búsqueda de los listados funciona +- ✅ La cookie lleva `Secure` en producción (TESIS-130, ADR-018) - ⚠️ El logout cierra todas las sesiones de la cuenta, no sólo la del navegador que sale - ⚠️ Con «Recordarme», la sesión dura 2 semanas aunque no haya actividad - ⚠️ El límite por IP no frena un ataque distribuido -- ⚠️ La cookie no lleva `Secure` hasta que la app se sirva por HTTPS diff --git a/docs/adr/ADR-018-seguridad-del-despliegue.md b/docs/adr/ADR-018-seguridad-del-despliegue.md new file mode 100644 index 00000000..d0bbcc91 --- /dev/null +++ b/docs/adr/ADR-018-seguridad-del-despliegue.md @@ -0,0 +1,102 @@ +# ADR-018: Seguridad transversal y configuración del despliegue + +**Fecha:** 2026-09-29 +**Estado:** Aceptado + +--- + +## Contexto + +La demo corre en contenedores detrás de Caddy, que termina el TLS de los subdominios de DuckDNS: `api.` para la API (Thruster + Puma) y uno por empresa para el front (`norte.`, `sur.`, servidos por nginx). La infraestructura (Caddy, las variables de cada contenedor) vive en el servidor y no en el repo. + +La QA de TESIS-130 revisó los controles que no son de un módulo en particular y encontró la API desplegable con defaults pensados para desarrollo: + +- CORS respondía `Access-Control-Allow-Origin: *` a cualquier origen. +- `force_ssl` y `assume_ssl` estaban comentados: sin HSTS, y la cookie del backoffice salía sin `Secure` (ADR-017 lo dejó anotado para esta card). +- Sin `RAILS_ALLOWED_HOSTS`, Rails no validaba el Host. +- Sin `DEVISE_JWT_SECRET_KEY`, los JWT se firmaban con `secret_key_base`, que no es propio del entorno cuando sale de las credenciales del repo. +- El entrypoint del Dockerfile corre `db:prepare`, que siembra toda base nueva, y los seeds no distinguían entorno: el despliegue arrancaba con `admin@backoffice.com` / `admin123` y los usuarios de empresa con `password123`. + +Del lado del front (proyecto-web, su ADR-008), nginx no mandaba ningún header de seguridad y `npm audit` reportaba 11 vulnerabilidades altas. El token de sesión vive en `localStorage`, así que un XSS equivale a robar la sesión. + +## Decisión + +### Variables obligatorias: sin ellas, la API no arranca + +`config/environments/production.rb` corta el arranque si falta alguna de estas: + +| Variable | Qué define | Ejemplo en la demo | +| ----------------------- | -------------------------------------------- | ------------------------------------------- | +| `DEVISE_JWT_SECRET_KEY` | Secreto con el que se firman los JWT | `bin/rails secret`, uno distinto por entorno | +| `CORS_ALLOWED_ORIGINS` | Orígenes del front, separados por comas | `https://*.precision-logistics.duckdns.org` | +| `RAILS_ALLOWED_HOSTS` | Hosts de la API, separados por comas | `api.precision-logistics.duckdns.org` | + +Sin la primera, los tokens se firmarían con `secret_key_base`; sin la tercera, el Host no se validaría. Las dos dejan algo abierto sin que nada falle a la vista. Sin la segunda, CORS no dejaría pasar al front, que se rompería sin decir por qué. Un contenedor que no levanta se ve en el deploy; uno que levanta abierto no se ve nunca. La excepción es `SECRET_KEY_BASE_DUMMY`, que marca el `assets:precompile` del Dockerfile: arranca la app durante el build, cuando todavía no hay secretos del entorno. + +### Secreto de los JWT + +El fallback a `secret_key_base` queda sólo para desarrollo y test. Cada entorno firma con su propio secreto, así que un token emitido en otro entorno no pasa la verificación de la firma. + +### CORS + +Sólo el front puede llamar a la API desde un navegador. El riesgo de `*` no era que un sitio ajeno lea datos con el token del usuario, porque el token no viaja solo como una cookie. Era que cualquier página podía hacer requests a la API desde los navegadores de sus visitantes, por ejemplo repartir intentos de login entre muchas IPs y esquivar el límite por IP de TESIS-82. + +- Como cada empresa entra por su subdominio, `CORS_ALLOWED_ORIGINS` acepta un comodín para ese único nivel: `https://*.dominio` vale para `norte.dominio`, pero no para `a.b.dominio` ni para el dominio sin subdominio. rack-cors compara los strings tal cual, así que el initializer lo traduce a una regex anclada. +- Sin la variable (desarrollo y test) se acepta `localhost` en cualquier puerto. En producción no hay fallback. +- El `ETag` sigue expuesto para el locking optimista de TESIS-101. +- `spec/requests/cors_spec.rb` fija el comportamiento sin la variable. El de producción depende del entorno y se verifica contra la imagen. + +### HTTPS de punta a punta + +- Caddy termina el TLS y redirige HTTP a HTTPS (308). +- Rails recibe HTTP plano del proxy. `assume_ssl` hace que lo tome como HTTPS y `force_ssl` suma `Strict-Transport-Security` y marca las cookies como `Secure`, entre ellas la de la sesión del backoffice. +- Rails no redirige nada: con `assume_ssl`, todo request cuenta como HTTPS. La redirección es la de Caddy. + +### Hosts permitidos + +Con `RAILS_ALLOWED_HOSTS` obligatoria, un request con otro Host (o con otro `X-Forwarded-Host`) recibe 403. `/up` queda afuera de la validación, porque el health check llega con el Host de quien lo hace (la IP del contenedor, `localhost`). + +### Seeds + +Las contraseñas del repo siguen siendo las de desarrollo y test. En producción, las de las cuentas sembradas salen de `SEED_USER_PASSWORD` (usuarios de empresa) y `SEED_ADMIN_PASSWORD` (administrador del backoffice). Sin ellas, o si alguna es una contraseña del repo, el seed no corre. Estas dos variables no las pide el arranque: sólo hacen falta cuando se siembra, que con el entrypoint es el primer arranque contra una base nueva. + +`find_or_create_by!` no toca una cuenta que ya existe, y una base sembrada antes de este cambio tiene las contraseñas del repo. En producción el seed las rota: correr `bin/rails db:seed` con las variables cierra esas cuentas y deja como están las que ya tienen otra contraseña (`spec/db/seeds_spec.rb`). + +### Front + +Detalle en el ADR-008 de proyecto-web: CSP que limita los scripts al propio origen, `frame-ancestors`/`X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy` y HSTS en nginx; reglas de ESLint que prohíben insertar HTML sin escapar; `npm audit` en su CI. + +Los datos que cargan las empresas o llegan por webhook se muestran como texto: React los escapa, y el backoffice ya estaba cubierto (ADR-017, `spec/requests/admin/html_escaping_spec.rb`). + +## Alternativas consideradas + +### Avisar en el log en vez de cortar el arranque + +- ✅ Un deploy sin las variables seguiría funcionando +- ❌ Funcionaría abierto, y nadie lee el log de un contenedor que anda + +### Una lista cerrada de orígenes, sin comodín + +- ✅ Más explícita +- ❌ Cada empresa nueva obliga a cambiar la variable y reiniciar la API para que su front funcione + +### `force_ssl` confiando en `X-Forwarded-Proto`, sin `assume_ssl` + +- ✅ Rails redirigiría también un acceso directo por HTTP al contenedor +- ❌ Depende de que cada salto (Caddy → Thruster → Puma) reenvíe el header: si uno no lo hace, cada request vuelve redirigido a sí mismo + +### Contraseñas al azar en los seeds, impresas en el log + +- ✅ No hace falta ninguna variable +- ❌ Deja secretos en los logs, y el equipo no puede reproducir las cuentas de la demo + +## Consecuencias + +- ✅ Un token de otro entorno no sirve, y el deploy no arranca con un secreto que no sea propio +- ✅ Ninguna página que no sea el front puede llamar a la API desde un navegador +- ✅ La cookie del backoffice lleva `Secure` y el navegador recuerda que el sitio es HTTPS +- ✅ Ninguna cuenta sembrada en un entorno desplegado tiene una contraseña del repo +- ⚠️ El próximo deploy no arranca si el servidor no define las tres variables: hay que cargarlas antes +- ⚠️ Una base ya desplegada conserva las contraseñas del repo hasta que se corra `bin/rails db:seed` con `SEED_USER_PASSWORD` y `SEED_ADMIN_PASSWORD` +- ⚠️ `ENCRYPTION_KEY` y `ENCRYPTION_KEY_DERIVATION_SALT` siguen cayendo a `secret_key_base`. No entran en la validación del arranque: cambiarlas en una base que ya tiene credenciales cifradas las deja ilegibles, así que necesitan su propia migración +- ⚠️ Un acceso directo al contenedor por HTTP (sin Caddy) no se redirige: se asume que el puerto del contenedor no está expuesto diff --git a/docs/guidelines/architecture.md b/docs/guidelines/architecture.md index 5240f0ee..a1a0fd5c 100644 --- a/docs/guidelines/architecture.md +++ b/docs/guidelines/architecture.md @@ -37,6 +37,8 @@ DEVISE_JWT_SECRET_KEY= # Clave para firmar los tokens JWT PROYECTO_API_DATABASE_PASSWORD=admin # Contraseña de PostgreSQL (producción) ``` +En producción, además, la API no arranca sin `DEVISE_JWT_SECRET_KEY`, `CORS_ALLOWED_ORIGINS` y `RAILS_ALLOWED_HOSTS`, y `db:seed` pide `SEED_USER_PASSWORD` y `SEED_ADMIN_PASSWORD`. Qué define cada una y por qué está en [ADR-018](../adr/ADR-018-seguridad-del-despliegue.md) y en `.env.example`. + --- ## 3. Estructura de carpetas @@ -82,7 +84,7 @@ config/ ├── routes.rb # namespace :api > namespace :v1 ├── database.yml # PostgreSQL (dev, test, prod + cache/queue DBs) ├── initializers/ -│ ├── cors.rb # rack-cors: todos los orígenes (dev) +│ ├── cors.rb # rack-cors: sólo el front (CORS_ALLOWED_ORIGINS; localhost en dev) │ └── devise.rb # Configuración de Devise └── environments/ From 9dab4ca6227fe72525f752102ddad17ea9bfa9e2 Mon Sep 17 00:00:00 2001 From: LorenzoBellomo Date: Tue, 29 Sep 2026 14:27:11 -0300 Subject: [PATCH 5/6] fix: [TESIS-130] build the Docker image from a Windows checkout with CRLF scripts With core.autocrlf=true, git checks out bin/* with CRLF, so the shebang of bin/rails asks env for `ruby\r` and `docker build` dies at `./bin/rails assets:precompile` (exit 127). The Dockerfile already strips the CRs, but that step ran after assets:precompile, which was added above it. - Move "Adjust binfiles" (chmod +x, strip CR) back before the first RUN that executes bin/, the order `rails new` generates. Existing Windows clones keep CRLF on disk even after .gitattributes changes, so this is what makes them build without re-checking out bin/. - Force LF on bin/* and *.sh in .gitattributes (CRLF for the bin/kamal.cmd batch file), so fresh checkouts and containers that bind-mount the repo get runnable scripts. The index already stored them as LF: renormalizing changes no blobs. Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 8 ++++++++ Dockerfile | 17 +++++++++-------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/.gitattributes b/.gitattributes index 8dc43234..be01a356 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,3 +7,11 @@ db/schema.rb linguist-generated vendor/* linguist-vendored config/credentials/*.yml.enc diff=rails_credentials config/credentials.yml.enc diff=rails_credentials + +# Scripts que se ejecutan en Linux (la imagen de Docker, el server): siempre LF, +# aunque el checkout sea de Windows con core.autocrlf=true. Con CRLF el shebang +# busca `ruby\r` o `bash\r` y el script no arranca. +bin/* text eol=lf +*.sh text eol=lf +# kamal.cmd es un batch de Windows y se queda con CRLF. +bin/*.cmd text eol=crlf diff --git a/Dockerfile b/Dockerfile index a10396b0..4b69cfe7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,6 +55,15 @@ COPY . . # -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495 RUN bundle exec bootsnap precompile -j 1 app/ lib/ +# Adjust binfiles to be executable on Linux +# Tiene que ir antes del primer RUN que ejecute algo de bin/: un contexto que +# viene de un checkout de Windows (core.autocrlf=true) trae los scripts con CRLF +# y el shebang busca `ruby\r`. .gitattributes ya los fuerza a LF, pero un clon +# existente conserva los CRLF hasta que se vuelvan a extraer esos archivos. +RUN chmod +x bin/* && \ + sed -i "s/\r$//g" bin/* && \ + sed -i 's/ruby\.exe$/ruby/' bin/* + # Precompilar los assets (Propshaft) en build y no en runtime: el backoffice de # Avo sirve CSS/JS con nombre digest desde public/assets, y sin este paso las # páginas del panel piden esos archivos y reciben 404 (panel sin estilos). @@ -62,14 +71,6 @@ RUN bundle exec bootsnap precompile -j 1 app/ lib/ # durante la compilación, cuando no hay base de datos ni secrets disponibles. RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile -# Adjust binfiles to be executable on Linux -RUN chmod +x bin/* && \ - sed -i "s/\r$//g" bin/* && \ - sed -i 's/ruby\.exe$/ruby/' bin/* - - - - # Final stage for app image FROM base From 4e58549925ab06402afb2ed7023e240c890f8db6 Mon Sep 17 00:00:00 2001 From: LorenzoBellomo Date: Tue, 29 Sep 2026 14:44:58 -0300 Subject: [PATCH 6/6] style: [TESIS-130] add the missing space in the JWT secret assignment Co-Authored-By: Claude Opus 5.5 --- config/initializers/devise_jwt.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/initializers/devise_jwt.rb b/config/initializers/devise_jwt.rb index 3be22058..e878de75 100644 --- a/config/initializers/devise_jwt.rb +++ b/config/initializers/devise_jwt.rb @@ -19,7 +19,7 @@ # El fallback a secret_key_base es para desarrollo y test. En producción la # variable es obligatoria (config/environments/production.rb): cada entorno # firma con su propio secreto y un token emitido en otro no sirve (TESIS-130). - jwt.secret =ENV.fetch('DEVISE_JWT_SECRET_KEY') { Rails.application.secret_key_base } + jwt.secret = ENV.fetch('DEVISE_JWT_SECRET_KEY') { Rails.application.secret_key_base } jwt.expiration_time = 1.day.to_i end end