Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ jobs:
- name: Verify secrets stay out of process arguments, previews and errors
run: python tools/verify_secrets.py

- name: Verify what reaches root or a superuser (certificates, downloads, venvs, neutralisation)
run: python tools/verify_privilege.py

- name: Verify nginx and fail2ban accept the generated configuration
run: |
sudo apt-get update -qq
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -178,4 +178,6 @@ cython_debug/
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
# refer to https://docs.cursor.com/context/ignore-files
.cursorignore
.cursorindexingignore
.cursorindexingignore
# Exported reports (they name databases, users and paths)
reports/
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,31 @@ All notable changes to this project are documented here. The format is based on

### Fixed

- **Root shell and superuser exposure.**
- The certificate paths and the venv package names reached a root shell unescaped, so a file or package named
`$(…)` ran as root.
- The server report ran the instance's own Python interpreter as root.
- Neutralisation ran as the PostgreSQL superuser on a database copied from another instance, where a trigger
could raise its own role.

Now:
- every operator value is quoted;
- pip, tar and git run as the instance user;
- the report reads `pyvenv.cfg` instead of running the interpreter;
- neutralisation runs as the copy's owner role;
- a key that does not match its certificate no longer replaces the live files, which would have broken the
next nginx restart for every instance.
- **Fixed `/tmp` names written as root** (the venv replication's requirements, the wkhtmltopdf package) are now
private temporary directories. The venv replication fails when pip fails, keeps only `name==version` lines
and shows them.
- Escape sequences in database names, file names or command output are printed inert, and typed values with
control characters are refused.
- A replica now detects its source's branch: git refused to run as root in the instance's checkout.
- Smaller fixes:
- a secret escaped inside SQL is masked in the preview;
- a password containing `$$` no longer breaks the role SQL;
- exports are new private files that never follow a link.

- **Installing over an existing instance removed it.** The install never checked the name was free, and its
cleanup ran on a failure — or on Ctrl+C at the confirmation prompt — removing that instance's home, config,
unit and vhosts. A new instance now needs a free name (no system account or service such as `backup`, whose
Expand Down
10 changes: 9 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,22 @@ python3 tools/verify_install_runtime.py --clone 14=/path/odoo-14.0 --clone 16=/p
python3 tools/verify_neutralisation.py --odoo 18=/path/venv/bin/python:/path/odoo-18.0
python3 tools/verify_ops_configs.py # nginx -t and fail2ban on the generated config
python3 tools/verify_secrets.py # secrets in no process argument, preview or error
python3 tools/verify_privilege.py # certificates, downloads, venv replication, neutralisation as owner
```

`tools/verify_data_safety.py`, `tools/verify_secrets.py` and `tools/verify_ops_configs.py` also run in CI.
`tools/verify_data_safety.py`, `tools/verify_secrets.py`, `tools/verify_privilege.py` and
`tools/verify_ops_configs.py` also run in CI.
`tools/verify_data_safety.py` executes the generated scheduled-backup script, the
retention prune and the delete step against stub binaries, and the template copy, forced drop and purge
discovery against a PostgreSQL cluster of its own (it needs `initdb` and refuses to run as root). Run it after
changing any of those.

`tools/verify_privilege.py` runs the certificate steps with real `openssl` (a key that does not match must leave
the live files untouched, a hostile file name must stay a name), the wkhtmltopdf and venv-replication steps
against stubs, and the neutralisation as the copy's owner against a PostgreSQL cluster of its own, where a
trigger in the copy tries to make its owner a superuser. Run it after changing those planners or
`_post_db_mode_commands`.

`tools/verify_install_runtime.py` is not part of CI: it downloads the pinned uv (and refuses a wrong
checksum), installs the interpreter the matrix picks, builds a real venv for each Odoo checkout you pass and
starts `odoo-bin --version`; for Odoo <= 16 it also shows the unpinned setuptools breaks it. It needs network
Expand Down
6 changes: 4 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ A reproduction against a disposable VM is ideal.

## Handling credentials

- Database and admin passwords are entered interactively and passed to `psql`/`pg_dump` via `PGPASSWORD` in
the generated commands. Treat shell history and process listings on the host as sensitive.
- Database and admin passwords are entered interactively without echo. They reach `psql`/`pg_dump` through
the step's environment (`PGPASSWORD`), never its command line, and the plan preview masks them; a file holding
a secret is written from the environment too. Treat the host's root account as sensitive: root can read any
process's environment.
- Do not commit real credentials, dumps, or filestore archives to the repository.
- The recommended permissions baseline in [`docs/security/safe-controls.md`](docs/security/safe-controls.md) denies reads of
common secret files (`.env`, `*.pem`, SSH keys).
Expand Down
2 changes: 1 addition & 1 deletion docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ For HTTPS you pick a certificate strategy:
| **Leave certificates untouched** | Adds no certificate commands. |
| **Self-signed** | Reuses an existing key/fullchain or generates a 2048-bit self-signed cert for the domain. |
| **Let's Encrypt (managed externally)** | Adds no certificate commands — you manage LE outside the tool. |
| **Copy your own certificates** | Installs your CRT/KEY (+ optional intermediate), builds the fullchain, and **validates that the key matches the certificate** before Nginx is reconfigured. |
| **Copy your own certificates** | Copies your CRT/KEY (+ optional intermediate) beside the live files, builds the fullchain, and **checks that the key matches the certificate** before they replace the current ones (kept as `.previous`); a wrong file never reaches Nginx. |

## The instance name must be free

Expand Down
7 changes: 5 additions & 2 deletions docs/operations/instance-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ To go back, copy the files from the `config_preupdate/<timestamp>` directory and
(`www-data:adm`, mode `640`) and reopens Nginx's logs.
- **Log rotation** — see [Log rotation](log-rotation.md).
- **Install Python packages in the venv** installs into the instance's virtualenv from a requirements file or an
inline package list, running pip as the instance user.
inline package list, running pip as the instance user. Separate packages with commas (`requests, lxml`); a
version range keeps its own comma (`babel>=2.14,<3`), and pip options (`--index-url …`) are refused.

## Backups & duplication

Expand Down Expand Up @@ -109,7 +110,9 @@ for a refresh in place):
- SII, TicketBAI, EDI proxy and Peppol in test mode;
- website domain and CDN cleared, `web.base.url` pointing at the target, the "neutralised" banner and flag.

The rules, each with its source, are in `instance_manager/neutralise.py`.
The rules, each with its source, are in `instance_manager/neutralise.py`. They run as the copy's owner role,
not as the PostgreSQL superuser, so code the source database carries (a trigger, a function) gets no more
rights than its own role has.

A copy is never visible to a running Odoo before it is neutralised. An Odoo's cron worker lists every database
its role owns, whatever the `dbfilter`, so a duplication goes this way:
Expand Down
5 changes: 5 additions & 0 deletions instance_manager/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -969,4 +969,9 @@ def tf(template: str, *args: object, **kwargs: object) -> str:
'That name is a system account or service name. Choose another instance name.': 'Ese nombre es de una cuenta o un servicio del sistema. Elige otro nombre de instancia.',
'Data dirs': 'Data dirs',
'Filestore folders': 'Carpetas de filestore',
'Install the certificate files, kept only if the key and chain check out': 'Instalar los certificados, solo si la clave y la cadena son correctas',
'Download, verify (SHA-256) and install patched wkhtmltopdf ({})': 'Descargar, verificar (SHA-256) e instalar wkhtmltopdf parcheado ({})',
'Control characters are not allowed.': 'No se admiten caracteres de control.',
'Ignored (not a package): {}': 'Ignorado (no es un paquete): {}',
'Invalid service name. Use letters, digits and @ . _ : -': 'Nombre de servicio no válido. Usa letras, dígitos y @ . _ : -',
}
19 changes: 14 additions & 5 deletions instance_manager/neutralise.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,11 +231,13 @@ def apply_sql(local_url: str) -> str:
+ f" WHERE ({rule.where}) AND ({_armed(rule, local_url)}); END IF; "
for rule in CATALOGUE
)
# Nested, not `AND`: PL/pgSQL plans the whole condition, so a query on a table
# that is not there fails even behind a false `to_regclass` test.
flag = (
"IF to_regclass('ir_config_parameter') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM "
"IF to_regclass('ir_config_parameter') IS NOT NULL THEN IF NOT EXISTS (SELECT 1 FROM "
"ir_config_parameter WHERE key = 'database.is_neutralized') THEN "
"INSERT INTO ir_config_parameter (key, value) VALUES ('database.is_neutralized', 'True'); "
"END IF; "
"END IF; END IF; "
)
return f"DO $$ BEGIN {blocks}{_mail_sink_block()}{flag}END $$;"

Expand All @@ -250,13 +252,20 @@ def guard_sql(local_url: str) -> str:
for rule in CATALOGUE
)
sink = (
"IF to_regclass('ir_mail_server') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ir_mail_server "
"IF to_regclass('ir_mail_server') IS NOT NULL THEN IF NOT EXISTS (SELECT 1 FROM ir_mail_server "
f"WHERE active AND name = {_lit(MAIL_SINK_NAME)} AND smtp_host = {_lit(MAIL_SINK_HOST)}) THEN "
"found := found || 'mail-sink (missing) '; END IF; "
"found := found || 'mail-sink (missing) '; END IF; END IF; "
)
# Every rule looks in the current schema: if Odoo's tables are not there, no rule
# applied and the check above saw nothing, so it must not pass.
odoo = (
"IF NOT EXISTS (SELECT 1 FROM pg_class c JOIN pg_namespace s ON s.oid = c.relnamespace "
"WHERE s.nspname = current_schema() AND c.relname = 'ir_cron') THEN "
"found := found || 'no Odoo tables in schema ' || current_schema() || ' '; END IF; "
)
return (
"DO $$ DECLARE n integer; found text := ''; BEGIN "
f"{blocks}{sink}"
f"{odoo}{blocks}{sink}"
"IF found <> '' THEN RAISE EXCEPTION 'the copy can still act on the outside: %', found; "
"END IF; END $$;"
)
Expand Down
Loading
Loading