diff --git a/.gitignore b/.gitignore index 9ad8bab..ef4d893 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,4 @@ dmypy.json .pyre/ tmp/ +.claude/settings.json diff --git a/docs/openspec/changes/security-audit-hardening/.openspec.yaml b/docs/openspec/changes/security-audit-hardening/.openspec.yaml new file mode 100644 index 0000000..4f63482 --- /dev/null +++ b/docs/openspec/changes/security-audit-hardening/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-15 diff --git a/docs/openspec/changes/security-audit-hardening/design.md b/docs/openspec/changes/security-audit-hardening/design.md new file mode 100644 index 0000000..b4b874b --- /dev/null +++ b/docs/openspec/changes/security-audit-hardening/design.md @@ -0,0 +1,112 @@ +## Context + +`website_sale_aplicoop` expone el portal de compra colaborativa (Eskaera) a usuarios de portal, y +la API externa de Odoo (XML-RPC/JSON-RPC) es accesible aunque el puerto de la BD esté firewallado. +La recon del código confirmó cinco hallazgos (H1–H5) y varios puntos de config de infra. Producción +corre tras nginx/traefik con TLS; el `docker-compose.yml` del repo es lab/dev y no refleja prod. + +Los controladores de eskaera ya usan `sudo()` para sus escrituras y ya disponen de gatekeepers de +pertenencia (`_validate_user_group_access`, `_get_consumer_group_for_user` en +`controllers/website_sale_validators.py`); el problema es que las ACL/record rules son demasiado +permisivas y que algunos endpoints no invocan esos gatekeepers. + +## Goals / Non-Goals + +**Goals:** +- Cerrar H1–H5 con mínimo cambio de comportamiento observable para el usuario legítimo, respaldado + por tests que impidan regresión. +- Entregar un proceso de auditoría repetible (checklist, runbook, scripts, findings register). +- Documentar y aplicar hardening de infra (odoo.conf, proxy, fail2ban, secretos/BD). + +**Non-Goals:** +- No se corrigen hallazgos nuevos de la auditoría activa en este change (se registran). +- No se toca `ocb/` ni addons OCA originales. +- No se rediseña la UX del portal; los cambios de endpoint son de transporte/seguridad. + +## Decisions + +**D1 — ACL de `group.order`/`group.order.slot` (H1): reemplazar la fila de `group_id` vacío por +ACLs explícitas de mínimo privilegio.** La fila vacía concede write+create a todos. Se sustituye +por: lectura interna (`base.group_user`, `1,0,0,0`, que empareja con la record rule interna ya +existente), lectura de portal explícita donde haga falta, y escritura solo para +`group_group_order_manager`. Alternativa descartada: añadir record rules que bloqueen write/create a +portal — más frágil que quitar el permiso en la ACL, porque una ACL permisiva sin regla restrictiva +ya abre el acceso. Verificar si el portal lee `group.order.slot` por ACL o por `sudo()` en el +controlador; si es por `sudo()`, no añadir ACL de portal para el slot. + +**D2 — `product.supplierinfo` (H2): acotar el acceso, no eliminarlo.** El portal SÍ usa +`supplierinfo`: la web pinta el **origen** del producto y el **proveedor principal** a partir de él +(vía `product.seller_ids`), además de que el pricing se computa server-side en +`controllers/website_sale_pricing.py` vía `sudo()`. Por tanto no se puede quitar la ACL de portal +sin romper ese render. Dos opciones, en orden de preferencia: +- **(preferida) Preparar origen + proveedor principal en el controlador** vía `sudo()` y pasarlos ya + resueltos a la plantilla (alineado con la regla del repo "sin lógica en QWeb"). Entonces el portal + deja de necesitar ACL directa sobre `product.supplierinfo` → se elimina la ACL de portal (fila 6) y + la record rule `rule_product_supplierinfo_portal_read`. Es lo más seguro: no expone ningún coste. +- **(fallback) Acotar la record rule** para que el portal solo lea `supplierinfo` de los productos de + los grupos a los que pertenece, sustituyendo `domain=[(1,'=',1)]` por un dominio filtrado, y sin + exponer campos de coste/precio (restringir con `groups=` en el campo o exponer campo derivado). + +El punto clave: hay que verificar en las plantillas/controladores qué campos de `supplierinfo` se +leen realmente (origen, nombre del proveedor) antes de elegir; el objetivo es no exponer +`price`/coste de todos los proveedores a cualquier usuario de portal. + +**D3 — CSRF (H3): convertir a `type="json"`.** Los endpoints de estado (`save-order`, `confirm`, +`clear-cart`, `save-cart`) pasan de `type="http"` + `csrf=False` a `type="json"`. Un `type="json"` +exige `Content-Type: application/json`, que un formulario HTML cross-site no puede fijar, de modo +que neutraliza el CSRF por formulario sin gestionar tokens manualmente. Plantilla de referencia: +`confirm_order_from_portal` (ya `type="json"`). Implica ajustar el JS (solo transporte: fetch con +envelope JSON-RPC y lectura de `result`) y el Python (devolver `dict` en vez de `Response`). +Alternativa descartada: mantener `type="http"` y validar token CSRF — más código y más fácil de +olvidar en endpoints futuros. + +**D4 — Autorización horizontal (H4): invocar los gatekeepers existentes.** En cada endpoint que hace +`group.order.sudo().browse(order_id)` y solo comprueba `exists()`/`state`, añadir la comprobación de +pertenencia tras ese check. Usar `_get_consumer_group_for_user` (devuelve `False`) en los que deban +retornar vacío/redirect silencioso (`load_eskaera_page`, `load_products_ajax`) y +`_validate_user_group_access` (lanza) en los que deban fallar duro. Mantener el bypass para usuarios +internos (`current_user.share == False`) igual que `eskaera_shop`. + +**D5 — Plantilla (H5): salida JSON segura.** Sustituir `t-raw` en `