Skip to content

Commit 04e8c19

Browse files
gbartolinimnenciaarmruleonardoce
authored
Merge commit from fork
The metrics exporter authenticated as the `postgres` superuser and demoted the session with `SET ROLE pg_monitor`. A database user owning a schema on the scrape session's `search_path` could subvert that by running `RESET ROLE` to recover superuser privileges and `COPY ... TO PROGRAM` to execute OS commands inside the primary pod (`CVE-2026-44477` / `GHSA-423p-g724-fr39`). Replace the postgres-superuser connection used by the metrics exporter with a dedicated `cnpg_metrics_exporter` role, granted only `pg_monitor`, mapped via `pg_ident.conf` for peer authentication on the local Unix socket. The metrics exporter's `session_user` is therefore never a superuser, so `RESET ROLE` and `SET SESSION AUTHORIZATION` cannot recover superuser, and `COPY ... TO PROGRAM` (which requires `pg_execute_server_program`, not granted by `pg_monitor`) becomes unavailable. As defense in depth, the monitoring transaction prepends `pg_catalog` to the connection's `search_path` so unqualified catalog identifiers cannot resolve to user-planted shadow objects, and the `target_databases: '*'` expansion filters by `pg_catalog.has_database_privilege(datname, 'CONNECT')`. The role is created at bootstrap, alongside `streaming_replica` in `configureInstancePermissions`, so the first scrape after a cluster comes up has the role available. The Reconcile loop additionally enforces drift correction on every iteration, and clears any password set out-of-band so the role cannot be authenticated by password regardless of operator pre-creation. The role is excluded from `bootstrap.initdb.import` so it is created cleanly per cluster. Databases skipped during `target_databases: '*'` expansion for missing `CONNECT` are logged at Debug level, surfacing what would otherwise be silent drops. Documentation covers the role's privilege model, the `GRANT` statements required for custom queries that read user-owned tables or target databases where `PUBLIC CONNECT` has been revoked, manual recovery for replica clusters upgraded before their source primary, and the upgrade impact in `installation_upgrade.md`. Reported-by: Mehmet Ince Assisted-by: Claude Signed-off-by: Gabriele Bartolini <gabriele.bartolini@enterprisedb.com> Signed-off-by: Armando Ruocco <armando.ruocco@enterprisedb.com> Signed-off-by: Leonardo Cecchi <leonardo.cecchi@enterprisedb.com> Signed-off-by: Marco Nenciarini <marco.nenciarini@enterprisedb.com> Co-authored-by: Marco Nenciarini <marco.nenciarini@enterprisedb.com> Co-authored-by: Armando Ruocco <armando.ruocco@enterprisedb.com> Co-authored-by: Leonardo Cecchi <leonardo.cecchi@enterprisedb.com>
1 parent 9173c44 commit 04e8c19

18 files changed

Lines changed: 595 additions & 37 deletions

File tree

api/v1/cluster_types.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,12 @@ const (
110110
// PGBouncerPoolerUserName is the name of the role to be used for
111111
PGBouncerPoolerUserName = "cnpg_pooler_pgbouncer"
112112

113+
// MetricsExporterUserName is the name of the dedicated role used by the
114+
// metrics exporter to connect to PostgreSQL. This role has pg_monitor
115+
// granted and is never a superuser, so session_user never escalates via
116+
// RESET ROLE.
117+
MetricsExporterUserName = "cnpg_metrics_exporter"
118+
113119
// MissingWALDiskSpaceExitCode is the exit code the instance manager
114120
// will use to signal that there's no more WAL disk space
115121
MissingWALDiskSpaceExitCode = 4

docs/src/installation_upgrade.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,22 @@ removed before installing the new one. This won't affect user data but
267267
only the operator itself.
268268

269269

270+
### Upgrading to 1.29.1 or 1.28.3
271+
272+
Version 1.29.1 and 1.28.3 ship the fix for `CVE-2026-44477` /
273+
`GHSA-423p-g724-fr39`. The metrics exporter now authenticates as a
274+
dedicated `cnpg_metrics_exporter` role with `pg_monitor` privileges
275+
only, instead of the `postgres` superuser.
276+
277+
Custom monitoring queries that read user-owned tables, or use
278+
`target_databases: '*'` against databases where `PUBLIC` `CONNECT`
279+
has been revoked, need explicit `GRANT` statements to
280+
`cnpg_metrics_exporter`. See ["Custom query privileges and
281+
safety"](monitoring.md#custom-query-privileges-and-safety) and ["Manually creating
282+
the metrics exporter
283+
role"](monitoring.md#manually-creating-the-metrics-exporter-role) in
284+
the monitoring documentation.
285+
270286
### Upgrading to 1.29.0 or 1.28.x
271287

272288
:::info[Important]

docs/src/monitoring.md

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,17 @@ more `ConfigMap` or `Secret` resources (see the
3636

3737
All monitoring queries that are performed on PostgreSQL are:
3838

39-
- atomic (one transaction per query)
40-
- executed with the `pg_monitor` role
39+
- atomic (one read-only transaction per query)
40+
- executed as the `cnpg_metrics_exporter` role (a member of `pg_monitor`)
4141
- executed with `application_name` set to `cnpg_metrics_exporter`
42-
- executed as user `postgres`
42+
43+
The connection uses peer authentication on the pod-local Unix socket;
44+
because `session_user` is never a superuser, the monitoring session
45+
cannot escalate via `RESET ROLE` or `RESET SESSION AUTHORIZATION`. Do
46+
not grant additional privileges or role memberships to
47+
`cnpg_metrics_exporter` beyond `pg_monitor` and the table-level grants
48+
required by your custom queries: any extra membership flows into the
49+
scrape session via inheritance and weakens this property.
4350

4451
Please refer to the "Predefined Roles" section in PostgreSQL
4552
[documentation](https://www.postgresql.org/docs/current/predefined-roles.html)
@@ -494,6 +501,42 @@ Take care that the referred resources have to be created **in the same namespace
494501
and a key `queryName` containing the overwritten query name.
495502
:::
496503

504+
#### Custom query privileges and safety
505+
506+
:::warning
507+
Custom queries run as the `cnpg_metrics_exporter` role, which inherits
508+
`pg_monitor`. Queries within `pg_monitor`'s scope (catalog reads,
509+
`pg_stat_*` views, configuration parameters) work without modification.
510+
Queries that read user-owned tables or superuser-only catalogs (e.g.
511+
`pg_authid`, `pg_subscription`) need explicit grants. Reading a table
512+
also requires USAGE on its schema:
513+
514+
```sql
515+
GRANT USAGE ON SCHEMA myschema TO cnpg_metrics_exporter;
516+
GRANT SELECT ON TABLE myschema.mytable TO cnpg_metrics_exporter;
517+
```
518+
519+
Every database in `target_databases` must allow
520+
`cnpg_metrics_exporter` to `CONNECT`. On clusters that have
521+
revoked `CONNECT` from `PUBLIC` for a database, grant it
522+
explicitly to that role:
523+
524+
```sql
525+
GRANT CONNECT ON DATABASE domainapp TO cnpg_metrics_exporter;
526+
```
527+
528+
Prefer an explicit list of trusted databases (e.g.
529+
`target_databases: ["domainapp"]`) over the `"*"` wildcard. The
530+
wildcard scrapes every database the role can connect to and
531+
silently skips the rest, so an explicit list makes a missing grant
532+
easier to notice. Use `"*"` only when the query is meant to
533+
collect per-database metrics across the whole cluster.
534+
535+
Schema-qualify catalog references (`pg_catalog.now()`,
536+
`pg_catalog.current_database()`) to prevent `search_path` shadowing
537+
by user-owned objects.
538+
:::
539+
497540
#### Example of a user defined metric
498541

499542
Here you can see an example of a `ConfigMap` containing a single custom query,
@@ -570,17 +613,22 @@ Database auto-discovery can be enabled for a specific query by specifying a
570613
*shell-like pattern* (i.e., containing `*`, `?` or `[]`) in the list of
571614
`target_databases`. If provided, the operator will expand the list of target
572615
databases by adding all the databases returned by the execution of `SELECT
573-
datname FROM pg_database WHERE datallowconn AND NOT datistemplate` and matching
574-
the pattern according to [path.Match()](https://pkg.go.dev/path#Match) rules.
616+
datname FROM pg_catalog.pg_database WHERE datallowconn AND NOT datistemplate
617+
AND pg_catalog.has_database_privilege(datname, 'CONNECT')` and matching the
618+
pattern according to [path.Match()](https://pkg.go.dev/path#Match) rules.
619+
Databases on which `cnpg_metrics_exporter` lacks the `CONNECT` privilege are
620+
silently skipped; if you want a database with revoked `PUBLIC` access to be
621+
scraped, grant `CONNECT` explicitly (see "Custom query privileges and safety"
622+
above).
575623

576624
:::note
577625
The `*` character has a [special meaning](https://yaml.org/spec/1.2/spec.html#id2786448) in yaml,
578626
so you need to quote (`"*"`) the `target_databases` value when it includes such a pattern.
579627
:::
580628

581629
It is recommended that you always include the name of the database
582-
in the returned labels, for example using the `current_database()` function
583-
as in the following example:
630+
in the returned labels, for example using the `pg_catalog.current_database()`
631+
function as in the following example:
584632

585633
```yaml
586634
some_query: |
@@ -757,6 +805,33 @@ CloudNativePG is inspired by the PostgreSQL Prometheus Exporter, but
757805
presents some differences. In particular, the `cache_seconds` field is not implemented
758806
in CloudNativePG's exporter.
759807

808+
### Manually creating the metrics exporter role
809+
810+
The operator creates the `cnpg_metrics_exporter` PostgreSQL role on the
811+
primary during reconciliation; it then propagates to standbys and
812+
replica clusters via streaming replication.
813+
814+
If the role is missing (replica cluster upgraded before its primary,
815+
restore from a backup that predates the role, accidental removal),
816+
recreate it as a superuser on the writable primary of the replication
817+
chain (the source primary, not a designated primary of a replica
818+
cluster):
819+
820+
```sql
821+
CREATE ROLE cnpg_metrics_exporter WITH LOGIN NOSUPERUSER NOCREATEDB
822+
NOCREATEROLE NOREPLICATION NOBYPASSRLS INHERIT;
823+
GRANT pg_monitor TO cnpg_metrics_exporter;
824+
```
825+
826+
If your custom monitoring queries need access to objects outside
827+
`pg_monitor`'s scope, grant the necessary privileges explicitly. SELECT
828+
on a table also requires USAGE on its schema:
829+
830+
```sql
831+
GRANT USAGE ON SCHEMA myschema TO cnpg_metrics_exporter;
832+
GRANT SELECT ON TABLE myschema.mytable TO cnpg_metrics_exporter;
833+
```
834+
760835
## Monitoring the CloudNativePG operator
761836
762837
The operator internally exposes [Prometheus](https://prometheus.io/) metrics

docs/src/release_notes/v1.28.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,29 @@ on the release branch in GitHub.
1717

1818
### Security and Supply Chain
1919

20+
- **`CVE-2026-44477` / `GHSA-423p-g724-fr39`: metrics exporter privilege
21+
escalation**: the metrics exporter no longer authenticates as the
22+
`postgres` superuser. It now uses a dedicated `cnpg_metrics_exporter`
23+
role with `pg_monitor` privileges only, closing a chain that let a
24+
low-privilege database user gain PostgreSQL superuser.
25+
([`GHSA-423p-g724-fr39`](https://github.com/cloudnative-pg/cloudnative-pg/security/advisories/GHSA-423p-g724-fr39)) <!-- 1.29 1.28 1.25 -->
26+
27+
Upgrade impact: custom monitoring queries that read user-owned tables,
28+
or use `target_databases: '*'` against databases where
29+
`PUBLIC CONNECT` has been revoked, need explicit `GRANT` statements to
30+
`cnpg_metrics_exporter`. See ["Custom query privileges and
31+
safety"](../monitoring.md#custom-query-privileges-and-safety) and ["Manually
32+
creating the metrics exporter
33+
role"](../monitoring.md#manually-creating-the-metrics-exporter-role)
34+
in the monitoring documentation.
35+
36+
For replica clusters, upgrade the source primary cluster before any
37+
replica clusters that consume from it. The `cnpg_metrics_exporter`
38+
role is created on the source primary and replicates downstream; a
39+
replica cluster upgraded first will scrape against a missing role
40+
until the source primary upgrades. The manual-recovery section
41+
linked above also covers replica clusters.
42+
2043
- **Schema-qualified catalog references in default monitoring queries**:
2144
hardened the shipped monitoring configuration and documentation samples by
2245
qualifying every `pg_catalog` object explicitly. Unqualified references

docs/src/release_notes/v1.29.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,29 @@ on the release branch in GitHub.
1717

1818
### Security and Supply Chain
1919

20+
- **`CVE-2026-44477` / `GHSA-423p-g724-fr39`: metrics exporter privilege
21+
escalation**: the metrics exporter no longer authenticates as the
22+
`postgres` superuser. It now uses a dedicated `cnpg_metrics_exporter`
23+
role with `pg_monitor` privileges only, closing a chain that let a
24+
low-privilege database user gain PostgreSQL superuser.
25+
([`GHSA-423p-g724-fr39`](https://github.com/cloudnative-pg/cloudnative-pg/security/advisories/GHSA-423p-g724-fr39)) <!-- 1.29 1.28 1.25 -->
26+
27+
Upgrade impact: custom monitoring queries that read user-owned tables,
28+
or use `target_databases: '*'` against databases where
29+
`PUBLIC CONNECT` has been revoked, need explicit `GRANT` statements to
30+
`cnpg_metrics_exporter`. See ["Custom query privileges and
31+
safety"](../monitoring.md#custom-query-privileges-and-safety) and ["Manually
32+
creating the metrics exporter
33+
role"](../monitoring.md#manually-creating-the-metrics-exporter-role)
34+
in the monitoring documentation.
35+
36+
For replica clusters, upgrade the source primary cluster before any
37+
replica clusters that consume from it. The `cnpg_metrics_exporter`
38+
role is created on the source primary and replicates downstream; a
39+
replica cluster upgraded first will scrape against a missing role
40+
until the source primary upgrades. The manual-recovery section
41+
linked above also covers replica clusters.
42+
2043
- **Schema-qualified catalog references in default monitoring queries**:
2144
hardened the shipped monitoring configuration and documentation samples by
2245
qualifying every `pg_catalog` object explicitly. Unqualified references

internal/cmd/manager/instance/run/lifecycle/run.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,11 @@ func configureInstancePermissions(ctx context.Context, instance *postgres.Instan
194194
return err
195195
}
196196

197+
if err = postgres.SetupMetricsExporterRole(ctx, tx); err != nil {
198+
_ = tx.Rollback()
199+
return err
200+
}
201+
197202
return tx.Commit()
198203
}
199204

internal/management/controller/instance_controller.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,10 @@ func (r *InstanceReconciler) Reconcile(
320320
return reconcile.Result{}, fmt.Errorf("cannot reconcile pgbouncer integration: %w", err)
321321
}
322322

323+
if err := r.reconcileMetricsExporterAuthUser(ctx, postgresDB); err != nil {
324+
return reconcile.Result{}, fmt.Errorf("cannot reconcile metrics exporter integration: %w", err)
325+
}
326+
323327
// Reconcile postgresql.auto.conf file permissions (< PG 17)
324328
// IMPORTANT: this needs a database connection to determine
325329
// the PostgreSQL major version
@@ -841,6 +845,43 @@ func (r *InstanceReconciler) reconcilePgbouncerAuthUser(
841845
return tx.Commit()
842846
}
843847

848+
// reconcileMetricsExporterAuthUser ensures the dedicated cnpg_metrics_exporter
849+
// PostgreSQL role exists and has pg_monitor granted. This role is used by the
850+
// metrics exporter instead of the postgres superuser so that session_user is
851+
// never a superuser and RESET ROLE has no escalation effect.
852+
func (r *InstanceReconciler) reconcileMetricsExporterAuthUser(
853+
ctx context.Context,
854+
db *sql.DB,
855+
) error {
856+
ok, err := r.instance.IsPrimary()
857+
if err != nil {
858+
return fmt.Errorf("unable to check if instance is primary: %w", err)
859+
}
860+
if !ok {
861+
return nil
862+
}
863+
return setupMetricsExporterRole(ctx, db)
864+
}
865+
866+
// setupMetricsExporterRole opens a transaction and delegates to the shared
867+
// helper that creates or repairs the cnpg_metrics_exporter role.
868+
func setupMetricsExporterRole(ctx context.Context, db *sql.DB) error {
869+
tx, err := db.BeginTx(ctx, nil)
870+
if err != nil {
871+
return err
872+
}
873+
defer func() {
874+
// This is a no-op when the transaction is committed
875+
_ = tx.Rollback()
876+
}()
877+
878+
if err := postgresManagement.SetupMetricsExporterRole(ctx, tx); err != nil {
879+
return err
880+
}
881+
882+
return tx.Commit()
883+
}
884+
844885
// reconcileClusterRoleWithoutDB updates this instance's configuration files
845886
// according to the role written in the cluster status
846887
func (r *InstanceReconciler) reconcileClusterRoleWithoutDB(

0 commit comments

Comments
 (0)