movidas openspec a /docs
This commit is contained in:
parent
0ef0c9951c
commit
aae6b6096e
9 changed files with 486 additions and 0 deletions
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-07-15
|
||||
112
docs/openspec/changes/security-audit-hardening/design.md
Normal file
112
docs/openspec/changes/security-audit-hardening/design.md
Normal file
|
|
@ -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 `<script>` de
|
||||
`load_from_history_templates.xml` por `<script type="application/json">` leído por el JS, evitando
|
||||
inyección raw en contexto de script.
|
||||
|
||||
**D6 — Proceso de auditoría: docs + scripts parametrizados.** Checklist, runbook y findings register
|
||||
en `docs/`; scripts en `scripts/security/` que toman URL y credenciales por parámetro/env (sin
|
||||
secretos versionados) y son no destructivos. El harness de fuerza bruta y el fuzzing corren contra
|
||||
staging o en ventana, nunca contra cuentas reales.
|
||||
|
||||
**D7 — Hardening de infra: requisitos documentados + cambio versionable puntual.** La config de
|
||||
`odoo.conf`, nginx/traefik y fail2ban vive en el servidor (no versionable aquí); se documenta como
|
||||
requisitos verificables en el checklist. El único cambio versionable de este bloque es añadir
|
||||
`groups_id` a la server action de mandatos SEPA en `account_banking_mandate_batch`.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Convertir endpoints a `type="json"` rompe el carrito/checkout] → Tests de endpoint + verificación
|
||||
end-to-end del flujo de portal antes de mergear; cambios de JS limitados a transporte.
|
||||
- [Recortar `supplierinfo` rompe la visualización de precios] → Confirmar que el pricing se computa
|
||||
vía `sudo()`; test que verifica que el shop sigue mostrando precios.
|
||||
- [Endurecer ACL bloquea a un usuario interno legítimo que dependía del permiso global] → La record
|
||||
rule interna de lectura ya existe; el test cubre lectura interna y escritura de manager.
|
||||
- [Auditoría activa afecta a producción] → Snapshot previo, staging preferente, ventana de
|
||||
mantenimiento, cuentas de test dedicadas, monitorización en vivo.
|
||||
- [`proxy_mode=True` mal configurado falsea la IP de origen] → Verificar cabeceras `X-Forwarded-For`
|
||||
en nginx y comprobar en la Fase 1 que fail2ban ve la IP real de Kali.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Rama aparte para los fixes de código; la auditoría activa se ejecuta contra staging.
|
||||
2. Aplicar D1–D5 en `website_sale_aplicoop` + tests; bump de `__manifest__.py`.
|
||||
3. Actualizar el addon: `docker-compose run odoo odoo -d odoo --stop-after-init -u website_sale_aplicoop`.
|
||||
Rollback: revertir la rama y re-actualizar el addon (las ACL/record rules se recargan al -u).
|
||||
4. Aplicar D7 (config de servidor) en ventana; rollback = restaurar los ficheros de config previos y
|
||||
recargar servicios.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- ¿El portal lee `group.order.slot` y `product.supplierinfo` directamente en algún punto, o todo va
|
||||
por `sudo()`? Resolver leyendo los controladores antes de eliminar ACLs (afecta a D1/D2).
|
||||
- ¿Se usa traefik o nginx en prod? Ajusta la sintaxis de rate-limit/cabeceras del runbook (no el
|
||||
requisito).
|
||||
- ¿Externalizar secretos a `.env`/secrets del orquestador en este change o en uno posterior de infra?
|
||||
106
docs/openspec/changes/security-audit-hardening/proposal.md
Normal file
106
docs/openspec/changes/security-audit-hardening/proposal.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# Proposal — Auditoría de seguridad y hardening (Odoo 18.0)
|
||||
|
||||
## Why
|
||||
|
||||
Detectamos ataques automatizados cada vez más sofisticados que la heurística de fail2ban no frena,
|
||||
y somos responsables de la seguridad de nuestra infra y la de nuestros clientes. Necesitamos (a) un
|
||||
proceso de auditoría **reproducible** tipo "checklist de prevuelo" para verificar la resiliencia
|
||||
ante fuerza bruta, acceso indebido a datos vía API y vectores inadvertidos; y (b) corregir cinco
|
||||
hallazgos ya confirmados leyendo el propio código de `website_sale_aplicoop`.
|
||||
|
||||
## Objetivo
|
||||
|
||||
Dejar la infra Odoo con una postura de seguridad auditable y repetible, y cerrar los hallazgos
|
||||
H1–H5 con tests que impidan su regresión.
|
||||
|
||||
## Alcance
|
||||
|
||||
- **Solo nuestra infra** (autorización directa, sin terceros). Producción está tras nginx/traefik
|
||||
con TLS.
|
||||
- Auditoría **activa autorizada** (fuerza bruta controlada contra cuenta de test y fuzzing de
|
||||
endpoints) desde la máquina Kali de la red y desde `localhost` vía SSH, preferentemente contra un
|
||||
clon de staging o en ventana de mantenimiento con snapshot previo.
|
||||
- Remediación de los hallazgos confirmados en `website_sale_aplicoop` (+ `groups_id` en la server
|
||||
action de mandatos SEPA de `account_banking_mandate_batch`).
|
||||
|
||||
## No-objetivos
|
||||
|
||||
- No se audita ni escanea infra de terceros ni hosting ajeno.
|
||||
- No se corrige aquí ningún hallazgo **nuevo** que surja durante la auditoría activa: se registra en
|
||||
el findings register y se aborda en un change posterior (salvo severidad crítica, que se escalará).
|
||||
- No se modifica `ocb/` ni los addons OCA originales.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Proceso de auditoría**: se añaden entregables de documentación (`docs/SECURITY_AUDIT_CHECKLIST.md`,
|
||||
`docs/SECURITY_AUDIT_RUNBOOK.md`, `docs/SECURITY_FINDINGS.md`) y scripts no destructivos en
|
||||
`scripts/security/` (probe de ACL vía API, probe de IDOR, harness de fuerza bruta, PoC de CSRF,
|
||||
recon web) parametrizados con cuentas de test.
|
||||
- **Fix H1 (Alta)** — ACL con `group_id` vacío que concede write+create global sobre `group.order` y
|
||||
`group.order.slot`: se reemplaza por ACLs de mínimo privilegio (lectura interna/portal explícitas;
|
||||
escritura solo manager). Los writes de los controladores usan `sudo()` y no dependen de estas ACLs.
|
||||
- **Fix H2 (Alta)** — record rule de `product.supplierinfo` con `domain=[(1,'=',1)]` que expone todos
|
||||
los precios de proveedor al portal. El portal usa `supplierinfo` para pintar el **origen** del
|
||||
producto y el **proveedor principal** en la web, así que no se elimina sin más: o se preparan esos
|
||||
valores server-side (vía `sudo()` en el controlador, según la regla "sin lógica en QWeb") o se acota
|
||||
el dominio a los productos accesibles por el usuario, sin exponer campos de coste/precio.
|
||||
- **Fix H3 (Media)** — endpoints de cambio de estado con `csrf=False`: se convierten a `type="json"`
|
||||
(bloquea el POST de formulario cross-site), tomando como plantilla `confirm_order_from_portal`.
|
||||
- **Fix H4 (Media)** — endpoints de lectura/ajax que solo comprueban `exists()`+`state`: se añade
|
||||
comprobación de pertenencia al grupo reutilizando `_validate_user_group_access` /
|
||||
`_get_consumer_group_for_user`.
|
||||
- **Fix H5 (Baja)** — `t-raw` inyectado en `<script>`: se sustituye por un patrón de salida seguro.
|
||||
- **Hardening de config**: `admin_passwd`, `list_db=False`, `proxy_mode=True`, password de BD fuerte,
|
||||
rate-limit/cabeceras en nginx, jails de fail2ban (web + API). Documentado como requisitos; los
|
||||
cambios de infra no versionable se ejecutan en el servidor, los versionables (`groups_id` en la
|
||||
server action) en el repo.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `eskaera-access-control`: cómo `website_sale_aplicoop` restringe acceso a datos y acciones —
|
||||
ACL de mínimo privilegio, alcance de `product.supplierinfo`, autorización por pertenencia al grupo
|
||||
de consumo en todos los endpoints, protección CSRF en endpoints de estado, y escapado de salida en
|
||||
plantillas. Cubre H1–H5.
|
||||
- `security-audit-process`: proceso repetible de auditoría — reglas de compromiso/autorización,
|
||||
checklist de prevuelo, runbook de auditoría activa, scripts de prueba y registro de hallazgos.
|
||||
- `infra-hardening`: requisitos de endurecimiento de producción — `odoo.conf`, reverse proxy
|
||||
(nginx/traefik) con TLS/rate-limit/cabeceras, fail2ban (web + API), y gestión de secretos/BD.
|
||||
|
||||
### Modified Capabilities
|
||||
- (ninguna — no existen specs previas en `openspec/specs/`)
|
||||
|
||||
## Impact
|
||||
|
||||
- **Addons afectados**: `website_sale_aplicoop` (security CSV + record rules, controladores,
|
||||
plantilla, tests, bump de `__manifest__.py`); `account_banking_mandate_batch` (`groups_id` en la
|
||||
server action).
|
||||
- **Addons NO afectados**: `ocb/`, los addons OCA originales, y el resto de custom (solo se auditan;
|
||||
cualquier hallazgo se registra, no se modifica en este change).
|
||||
- **Nuevas rutas en el repo**: `docs/SECURITY_*`, `scripts/security/`.
|
||||
- **Impacto UI/website/QWeb**: al convertir endpoints a `type="json"` hay que ajustar el transporte
|
||||
en el JS de `website_sale_aplicoop` (solo transporte, sin lógica). La plantilla
|
||||
`load_from_history_templates.xml` cambia su forma de inyectar JSON. Sin cambios visibles de UX.
|
||||
- **Infra (no versionable)**: `odoo.conf`, config de nginx/traefik y fail2ban en el servidor de prod.
|
||||
|
||||
## Riesgos
|
||||
|
||||
- La auditoría activa (fuerza bruta/fuzzing) puede degradar o afectar a producción: mitigado con
|
||||
clon de staging o ventana de mantenimiento, snapshot previo y cuentas de test dedicadas (nunca
|
||||
contra cuentas reales, por riesgo de lockout/DoS de usuarios legítimos).
|
||||
- Convertir endpoints a `type="json"` cambia el contrato de request/response: riesgo de regresión en
|
||||
el carrito/checkout del portal; mitigado con tests y verificación end-to-end.
|
||||
- Recortar el acceso a `product.supplierinfo` podría romper la visualización de precios si algún
|
||||
punto del portal dependía de la ACL abierta: verificar que el pricing se computa vía `sudo()`.
|
||||
|
||||
## Criterios de aceptación
|
||||
|
||||
- Los probes `odoo_acl_probe.py` e `idor_probe.py` deniegan tras los fixes lo que permitían antes
|
||||
(write/create de `group.order`, lectura de `supplierinfo` ajeno, datos de grupos ajenos).
|
||||
- El PoC de CSRF funciona antes del fix y falla después.
|
||||
- El harness de fuerza bruta acaba con la IP de Kali baneada por fail2ban en `/web/login` y en la
|
||||
ruta API.
|
||||
- Tests de `website_sale_aplicoop` en verde:
|
||||
`docker-compose run odoo odoo -d odoo --test-enable --stop-after-init -u website_sale_aplicoop`.
|
||||
- El checklist de prevuelo cubre los tres frentes + infra, con criterio pass/fail por ítem, y el
|
||||
findings register queda con H1–H5 en estado resuelto/verificado.
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Least-privilege ACLs for group order models
|
||||
|
||||
`group.order` and `group.order.slot` SHALL NOT grant write, create or unlink to portal, public
|
||||
or unassigned internal users. Write/create/unlink SHALL be limited to the group-order manager
|
||||
group (`website_sale_aplicoop.group_group_order_manager`) and ERP admins. Read MAY be granted to
|
||||
internal users and portal users through explicit, group-scoped ACLs (no empty `group_id` rows).
|
||||
Controller mutations continue to run via `sudo()` and MUST NOT depend on broad ACLs.
|
||||
|
||||
#### Scenario: Portal user cannot mutate group orders
|
||||
- **WHEN** a portal user calls the external API to `create`, `write` or `unlink` on `group.order`
|
||||
or `group.order.slot`
|
||||
- **THEN** the operation is denied with an `AccessError`
|
||||
|
||||
#### Scenario: Manager retains full access
|
||||
- **WHEN** a user in `group_group_order_manager` creates or edits a `group.order` of their company
|
||||
- **THEN** the operation succeeds
|
||||
|
||||
#### Scenario: Controllers still operate
|
||||
- **WHEN** an authenticated portal member confirms a cart through the eskaera controller (which
|
||||
uses `sudo()`)
|
||||
- **THEN** the underlying `sale.order`/`group.order` writes succeed despite the tighter ACLs
|
||||
|
||||
### Requirement: Supplier cost not broadly exposed to portal
|
||||
|
||||
Portal users SHALL NOT read `product.supplierinfo` cost/price rows for products or vendors
|
||||
unrelated to them. The unrestricted record rule (`domain=[(1,'=',1)]`) granting portal read on
|
||||
`product.supplierinfo` SHALL NOT exist. The product **origin** and **main seller** shown in the
|
||||
eskaera shop — currently painted from `supplierinfo` — MUST keep working, either by preparing those
|
||||
values server-side (via `sudo()` in the controller, per the repo's "no logic in QWeb" rule) or via
|
||||
a record rule scoped to the products the user can access, exposing no supplier cost/price fields.
|
||||
|
||||
#### Scenario: Portal user cannot list unrelated supplier cost
|
||||
- **WHEN** a portal user calls `search_read` on `product.supplierinfo` via the external API
|
||||
- **THEN** the result does not include supplier cost/price rows of products/vendors unrelated to
|
||||
the user
|
||||
|
||||
#### Scenario: Origin and main seller still render
|
||||
- **WHEN** a portal member opens the eskaera shop
|
||||
- **THEN** each product's origin and main seller display correctly, without exposing supplier
|
||||
cost/price across all vendors
|
||||
|
||||
### Requirement: CSRF protection on state-changing eskaera endpoints
|
||||
|
||||
State-changing eskaera endpoints SHALL be protected against cross-site request forgery. The
|
||||
endpoints `/eskaera/save-order`, `/eskaera/confirm`, `/eskaera/clear-cart` and `/eskaera/save-cart`
|
||||
(which create, confirm, merge or cancel `sale.order` records) SHALL NOT be `type="http"` with
|
||||
`csrf=False`; they SHALL be `type="json"` (or otherwise validate a CSRF token) so that a cross-site
|
||||
HTML form POST cannot trigger them.
|
||||
|
||||
#### Scenario: Cross-site form POST is rejected
|
||||
- **WHEN** a logged-in user's browser is induced to submit a cross-site form POST to
|
||||
`/eskaera/clear-cart` or `/eskaera/confirm`
|
||||
- **THEN** the request is rejected and no order is cancelled/confirmed
|
||||
|
||||
#### Scenario: Legitimate same-origin call works
|
||||
- **WHEN** the eskaera frontend submits the JSON-RPC request from the same origin
|
||||
- **THEN** the order is created/confirmed as before
|
||||
|
||||
### Requirement: Consumer-group membership enforced on group-order endpoints
|
||||
|
||||
Every eskaera endpoint that resolves a `group.order` from a request id SHALL verify that the
|
||||
requesting user belongs to a consumer group of that order before returning data. This applies to
|
||||
`load_eskaera_page`, `load_products_ajax`, `add_to_eskaera_cart`, `eskaera_checkout` and
|
||||
`check_group_order_status`, reusing `_validate_user_group_access` / `_get_consumer_group_for_user`.
|
||||
Internal users MAY be exempt (matching `eskaera_shop`).
|
||||
|
||||
#### Scenario: Non-member gets no cross-group data
|
||||
- **WHEN** a portal user who is not a member of a group order requests
|
||||
`/eskaera/<order_id>/load-products-ajax` for that order
|
||||
- **THEN** the endpoint returns empty/redirect and discloses no catalog or pricing of that order
|
||||
|
||||
#### Scenario: Member accesses own group order
|
||||
- **WHEN** a portal member of the order's consumer group requests the same endpoint
|
||||
- **THEN** the products and pricing are returned normally
|
||||
|
||||
### Requirement: Safe template output in inline scripts
|
||||
|
||||
QWeb templates SHALL NOT inject data into inline `<script>` blocks via `t-raw`. JSON payloads for
|
||||
the client SHALL be emitted through a safe pattern (e.g. `<script type="application/json">` read by
|
||||
the JS layer).
|
||||
|
||||
#### Scenario: Rendered history payload is not raw-injected
|
||||
- **WHEN** the load-from-history template renders its JSON payload
|
||||
- **THEN** the payload is emitted through a safe (escaped/typed) mechanism, not `t-raw` inside a
|
||||
`<script>` tag
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Hardened Odoo configuration
|
||||
|
||||
Production `odoo.conf` SHALL set a strong `admin_passwd` (not the default `admin`), `list_db =
|
||||
False`, a `dbfilter` scoping databases per host, `proxy_mode = True`, `workers > 0`, request/time
|
||||
limits, `without_demo = True`, and a strong database password (not the literal `odoo`). The
|
||||
database manager SHALL NOT be reachable publicly.
|
||||
|
||||
#### Scenario: Database manager not exposed
|
||||
- **WHEN** an anonymous client requests `/web/database/manager` on production
|
||||
- **THEN** it is not served (blocked at the proxy and/or `list_db=False`)
|
||||
|
||||
#### Scenario: Real client IP reaches Odoo logs
|
||||
- **WHEN** a login fails behind the reverse proxy
|
||||
- **THEN** with `proxy_mode=True` the Odoo log records the real client IP (from `X-Forwarded-For`),
|
||||
not the proxy IP
|
||||
|
||||
### Requirement: Reverse proxy enforces TLS, rate limiting and security headers
|
||||
|
||||
The nginx/traefik layer SHALL terminate TLS with strong protocols/ciphers, apply `limit_req` to
|
||||
`/web/login`, `/web/session/authenticate`, `/jsonrpc` and `/xmlrpc`, and set security headers
|
||||
(HSTS, X-Frame-Options/CSP, X-Content-Type-Options, Referrer-Policy). The `session_id` cookie
|
||||
SHALL be `Secure`, `HttpOnly` and `SameSite`.
|
||||
|
||||
#### Scenario: Login rate limited
|
||||
- **WHEN** a client exceeds the configured request rate on `/web/login`
|
||||
- **THEN** the proxy returns 429 and further attempts are throttled
|
||||
|
||||
#### Scenario: Security headers present
|
||||
- **WHEN** a response is returned from production
|
||||
- **THEN** it includes HSTS, anti-clickjacking and content-type-options headers, and the session
|
||||
cookie carries Secure/HttpOnly/SameSite
|
||||
|
||||
### Requirement: Brute-force sources banned on web and API
|
||||
|
||||
fail2ban SHALL ban source IPs that exceed a failed-authentication threshold, covering both the web
|
||||
login route and the API authentication routes (`/web/session/authenticate`, `/xmlrpc/2/common`),
|
||||
using the real client IP.
|
||||
|
||||
#### Scenario: Repeated web login failures banned
|
||||
- **WHEN** a source exceeds the failed-login threshold on `/web/login`
|
||||
- **THEN** fail2ban bans that IP for the configured duration
|
||||
|
||||
#### Scenario: API auth brute force banned
|
||||
- **WHEN** a source brute-forces `/web/session/authenticate` or `/xmlrpc/2/common`
|
||||
- **THEN** the same fail2ban jail bans it (attacks via API do not bypass protection)
|
||||
|
||||
### Requirement: Secrets and database access hardened
|
||||
|
||||
Database and master secrets SHALL be strong and SHALL NOT be committed in plaintext in versioned
|
||||
files. The PostgreSQL port SHALL NOT be reachable from outside the host/network (firewalled), and
|
||||
the database user SHALL have least privilege.
|
||||
|
||||
#### Scenario: DB port not externally reachable
|
||||
- **WHEN** an external client attempts to connect to PostgreSQL (5432) on production
|
||||
- **THEN** the connection is refused/filtered by the firewall
|
||||
|
||||
#### Scenario: No plaintext secrets in repo
|
||||
- **WHEN** the repository is inspected
|
||||
- **THEN** production database/master passwords are not present in plaintext in versioned files
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Authorization precedes active testing
|
||||
|
||||
Active security testing (brute force, fuzzing) SHALL NOT begin until a written authorization
|
||||
recording scope, source IPs, time window and emergency contact exists, a tested backup/snapshot is
|
||||
taken, and dedicated test accounts are provisioned. Active tests SHALL run against a staging clone
|
||||
or within a maintenance window, and SHALL NOT target real user accounts.
|
||||
|
||||
#### Scenario: Test blocked without authorization
|
||||
- **WHEN** an operator attempts an active test without the recorded authorization and snapshot
|
||||
- **THEN** the runbook procedure halts at the pre-flight gate and the test does not run
|
||||
|
||||
#### Scenario: Test uses dedicated accounts on staging
|
||||
- **WHEN** a brute-force test runs
|
||||
- **THEN** it targets a dedicated test account on staging (or in a maintenance window), never a
|
||||
real user
|
||||
|
||||
### Requirement: Pre-flight security checklist
|
||||
|
||||
The repo SHALL ship a pre-flight checklist (`docs/SECURITY_AUDIT_CHECKLIST.md`) covering the three
|
||||
fronts — brute force, API/data access, inadvertent vectors — plus infrastructure. Each item SHALL
|
||||
have an explicit pass/fail criterion.
|
||||
|
||||
#### Scenario: Checklist covers all fronts with criteria
|
||||
- **WHEN** an operator opens the checklist
|
||||
- **THEN** it contains markable items for each of the three fronts and infra, each with a defined
|
||||
pass/fail criterion
|
||||
|
||||
### Requirement: Non-destructive audit scripts
|
||||
|
||||
The repo SHALL provide audit scripts under `scripts/security/` (ACL probe, IDOR probe, brute-force
|
||||
harness, CSRF PoC, web recon) that are non-destructive and parameterized with test-account
|
||||
credentials rather than hard-coded production secrets.
|
||||
|
||||
#### Scenario: ACL probe reports access matrix
|
||||
- **WHEN** the ACL probe runs with a portal test account
|
||||
- **THEN** it reports, per model, whether read/write/create/unlink is allowed or denied, without
|
||||
modifying production data
|
||||
|
||||
#### Scenario: Scripts take credentials as parameters
|
||||
- **WHEN** a script is invoked
|
||||
- **THEN** target URL and credentials are supplied as parameters/env, with no secrets committed to
|
||||
the repo
|
||||
|
||||
### Requirement: Findings register tracks resolution
|
||||
|
||||
Findings SHALL be recorded in `docs/SECURITY_FINDINGS.md` with severity, evidence and status, and
|
||||
each SHALL be tracked until resolved and verified.
|
||||
|
||||
#### Scenario: Confirmed findings tracked to closure
|
||||
- **WHEN** the audit completes
|
||||
- **THEN** the register lists H1–H5 (and any new findings) with severity and a status of
|
||||
resolved/verified or an owner and plan
|
||||
63
docs/openspec/changes/security-audit-hardening/tasks.md
Normal file
63
docs/openspec/changes/security-audit-hardening/tasks.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
## 1. Pre-flight y preparación (reglas de compromiso)
|
||||
|
||||
- [ ] 1.1 Redactar y firmar la autorización (alcance, IPs origen Kali+localhost, ventana, contacto) y guardarla como cabecera de `docs/SECURITY_FINDINGS.md`.
|
||||
- [ ] 1.2 Tomar snapshot/backup de BD y volumen; probar la restauración. Preparar clon de staging.
|
||||
- [ ] 1.3 Crear cuentas de test dedicadas (`sec_test_portal`, `sec_test_user`, opcional manager); anotar grupos y partners.
|
||||
- [ ] 1.4 Confirmar topología de prod vía SSH (`ss -tlnp`, `ps aux | grep odoo`, config nginx/traefik, `odoo.conf`, fail2ban, firewall, versión exacta de Odoo).
|
||||
|
||||
## 2. Entregables de documentación (`docs/`)
|
||||
|
||||
- [ ] 2.1 Crear `docs/SECURITY_AUDIT_CHECKLIST.md`: checklist de prevuelo con ítems markables por los 3 frentes + infra, cada uno con criterio pass/fail.
|
||||
- [ ] 2.2 Crear `docs/SECURITY_AUDIT_RUNBOOK.md`: comandos paso a paso de la auditoría activa (nmap, hydra/patator, testssl.sh, nikto, ffuf, invocación de scripts) con las salvaguardas de la sección 1.
|
||||
- [ ] 2.3 Crear `docs/SECURITY_FINDINGS.md`: registro con severidad/evidencia/estado, sembrado con H1–H5 + hallazgos de infra.
|
||||
|
||||
## 3. Scripts de auditoría (`scripts/security/`, no destructivos, credenciales por parámetro/env)
|
||||
|
||||
- [ ] 3.1 `odoo_acl_probe.py`: autentica como portal/usuario de test vía API externa y prueba matriz read/write/create/unlink sobre `group.order`, `group.order.slot`, `product.supplierinfo`, `res.partner`, `res.users`, `account.move`, `sale.order` (de otro partner), `ir.config_parameter`, `ir.attachment`, `account.banking.mandate`, `res.partner.bank`.
|
||||
- [ ] 3.2 `idor_probe.py`: como portal de test, itera `order_id`/`group_order_id` en `load_eskaera_page`, `load_products_ajax`, `add_to_eskaera_cart`, `eskaera_checkout`, `check_group_order_status` y detecta fuga de catálogo/precios de grupos ajenos; incluir IDOR vertical sobre `/my/orders/<id>` y `/my/events/<id>`.
|
||||
- [ ] 3.3 `bruteforce_test.sh`: hydra/patator controlado contra cuenta de test en `/web/login` y contra la ruta API (`/web/session/authenticate`, `/xmlrpc/2/common`); verifica baneo de la IP de Kali por fail2ban.
|
||||
- [ ] 3.4 `csrf_poc.html`: formulario cross-site que fuerza `POST /eskaera/clear-cart` y `/eskaera/confirm` desde sesión logueada (para demostrar H3 antes del fix).
|
||||
- [ ] 3.5 `web_recon.sh`: nmap + testssl.sh + nikto + ffuf sobre rutas Odoo conocidas (`/web/database/manager`, `/web/database/selector`, `?debug=1`, `/web/webclient/version_info`, `.git/`) y chequeo de cabeceras/cookies.
|
||||
|
||||
## 4. Ejecución de la auditoría activa (registrar todo en el findings register)
|
||||
|
||||
- [ ] 4.1 Frente 1 (fuerza bruta): ejecutar 3.3; medir umbral/tiempo de baneo web y API; revisar política de contraseñas (`auth_password_policy`), 2FA (`auth_totp`), `auth_signup` no invitado, gestor de BD.
|
||||
- [ ] 4.2 Frente 2 (acceso a datos vía API): ejecutar 3.1 y 3.2 (baseline pre-fix); documentar accesos indebidos (valida H1, H2, H4).
|
||||
- [ ] 4.3 Frente 3 (vectores inadvertidos): ejecutar 3.4 (valida H3), 3.5 (TLS, cabeceras, superficie web, versión/CVEs) y revisar `t-raw`/XSS (valida H5).
|
||||
|
||||
## 5. Fixes de código — `website_sale_aplicoop`
|
||||
|
||||
- [ ] 5.1 (H1) En `security/ir.model.access.csv`: sustituir la fila `access_group_order_base` (grupo vacío) por lectura interna `base.group_user` `1,0,0,0`; idem `access_group_order_slot_base`; mantener filas de manager para escritura; añadir ACL de portal de solo lectura para el slot solo si el controlador lo lee sin `sudo()`.
|
||||
- [ ] 5.2 (H2) Identificar en plantillas/controladores qué campos de `product.supplierinfo` lee el portal (origen del producto, proveedor principal vía `product.seller_ids`) y confirmar que el pricing va por `sudo()` en `controllers/website_sale_pricing.py`.
|
||||
- [ ] 5.3 (H2, preferido) Preparar origen + proveedor principal en el controlador vía `sudo()` y pasarlos resueltos a la plantilla (sin lógica en QWeb); luego eliminar la ACL de portal de `product.supplierinfo` (fila 6 del CSV) y la record rule `rule_product_supplierinfo_portal_read`.
|
||||
- [ ] 5.4 (H2, fallback si el render no se puede mover al controlador) Sustituir `domain=[(1,'=',1)]` de `rule_product_supplierinfo_portal_read` por un dominio acotado a los productos de los grupos del usuario, sin exponer campos de coste/precio.
|
||||
- [ ] 5.5 (H3) En `controllers/website_sale.py`: convertir `save-order`, `confirm`, `clear-cart`, `save-cart` de `type="http"`+`csrf=False` a `type="json"` (plantilla: `confirm_order_from_portal`); devolver `dict`.
|
||||
- [ ] 5.6 (H3) Ajustar el JS de `static/src/js/` (solo transporte: fetch con envelope JSON-RPC y lectura de `result`), sin lógica de negocio en JS.
|
||||
- [ ] 5.7 (H4) En `controllers/website_sale.py`: añadir comprobación de pertenencia tras `exists()`/`state` en `load_eskaera_page`, `load_products_ajax`, `add_to_eskaera_cart`, `eskaera_checkout`, `check_group_order_status`, reutilizando `_get_consumer_group_for_user` (retorno vacío/redirect) o `_validate_user_group_access` (fallo duro); mantener bypass de usuario interno (`share == False`).
|
||||
- [ ] 5.8 (H5) En `views/load_from_history_templates.xml`: sustituir `t-raw` en `<script>` por `<script type="application/json">` leído por el JS.
|
||||
- [ ] 5.9 Bump de versión en `website_sale_aplicoop/__manifest__.py` (`18.0.X.Y.Z`).
|
||||
|
||||
## 6. Fix versionable de infra — `account_banking_mandate_batch`
|
||||
|
||||
- [ ] 6.1 Añadir `groups_id` a la server action de mandatos SEPA en `account_banking_mandate_batch/data/server_action.xml` para restringir quién puede lanzarla.
|
||||
|
||||
## 7. Tests (extender los existentes de `website_sale_aplicoop/tests/`)
|
||||
|
||||
- [ ] 7.1 `test_record_rules.py` / `test_multi_company.py`: aserciones de que el portal NO puede write/create `group.order` ni `group.order.slot` (H1) ni leer `product.supplierinfo` ajeno (H2).
|
||||
- [ ] 7.2 `test_group_order_status_endpoint.py`: un portal fuera del grupo NO obtiene datos de los endpoints de lectura/ajax (H4); un miembro sí.
|
||||
- [ ] 7.3 Test de que los endpoints de estado convertidos a `type="json"` funcionan same-origin y que el flujo de checkout/carrito sigue operando (H3).
|
||||
|
||||
## 8. Hardening de infra (servidor de prod; no versionable — verificar contra el checklist)
|
||||
|
||||
- [ ] 8.1 `odoo.conf`: `admin_passwd` fuerte, `list_db=False`, `dbfilter` por host, `proxy_mode=True`, `workers>0`, `limit_*`, `without_demo=True`, `db_password` fuerte; log parseable por fail2ban.
|
||||
- [ ] 8.2 nginx/traefik: `limit_req` en `/web/login`, `/web/session/authenticate`, `/jsonrpc`, `/xmlrpc`; bloquear/restringir `/web/database/*`; HSTS + cabeceras; TLS fuerte; cookie `session_id` Secure/HttpOnly/SameSite; upgrade de `/websocket`.
|
||||
- [ ] 8.3 fail2ban: jails para login web y API (IP real vía `proxy_mode`), jail de 429 de nginx y `recidive`; verificar baneo con 3.3.
|
||||
- [ ] 8.4 BD/secretos: confirmar 5432 no expuesto por firewall; usuario de BD con mínimos privilegios; externalizar secretos fuera de ficheros versionados.
|
||||
|
||||
## 9. Verificación y calidad
|
||||
|
||||
- [ ] 9.1 Re-ejecutar 3.1/3.2 (deben denegar lo que antes permitían) y 3.4 (debe fallar tras el fix); actualizar el findings register a resuelto/verificado.
|
||||
- [ ] 9.2 Tests del addon: `docker-compose run odoo odoo -d odoo --test-enable --stop-after-init -u website_sale_aplicoop` (usar `run`, no `exec`).
|
||||
- [ ] 9.3 Verificación end-to-end del portal (carrito, save-order, confirm, clear-cart, checkout) tras la conversión a `type="json"`.
|
||||
- [ ] 9.4 Calidad: `make format` / `make lint` (black línea 88 + isort + flake8 + pylint-odoo); sin `_()` en definiciones de campo; `pre-commit run --all-files`.
|
||||
- [ ] 9.5 Cerrar cada ítem del checklist de prevuelo con evidencia.
|
||||
Loading…
Add table
Add a link
Reference in a new issue