Skip to content

Commit bdc4f32

Browse files
cbandybenjaminjbdsessler7
committed
Keep Postgres logs out of directories containing Postgres data
This is further protection against Postgres data loss due to a misconfigured or maliciously configured log directory. Co-authored-by: Benjamin Blattberg <[email protected]> Co-authored-by: Drew Sessler <[email protected]> Issue: PGO-2558
1 parent edb797c commit bdc4f32

File tree

4 files changed

+138
-1
lines changed

4 files changed

+138
-1
lines changed

internal/controller/postgrescluster/postgres.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,9 @@ func (*Reconciler) generatePostgresParameters(
179179
result.Add("shared_preload_libraries", preload)
180180
}
181181

182+
// Finally, remove or replace harmful values.
183+
postgres.SanitizeParameters(cluster, result)
184+
182185
return result
183186
}
184187

internal/controller/postgrescluster/postgres_test.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,15 +181,19 @@ func TestGeneratePostgresParameters(t *testing.T) {
181181
parameters: {
182182
something: str,
183183
another: 5,
184+
log_directory: pg_wal,
184185
},
185186
}`)
186187

187188
result := reconciler.generatePostgresParameters(ctx, cluster, false)
188189
assert.Assert(t, cmp.LenMap(result.AsMap(), len(builtin.AsMap())+2),
189-
"expected two parameters from the Config section")
190+
"expected two new parameters from the Config section")
190191

191192
assert.Equal(t, result.Value("another"), "5")
192193
assert.Equal(t, result.Value("something"), "str")
194+
195+
assert.Equal(t, result.Value("log_directory"), "/pgdata/logs/postgres",
196+
"expected sanitization")
193197
})
194198

195199
t.Run("Patroni", func(t *testing.T) {

internal/postgres/validation.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright 2021 - 2025 Crunchy Data Solutions, Inc.
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
package postgres
6+
7+
import (
8+
"path"
9+
"regexp"
10+
"strings"
11+
12+
"github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1"
13+
)
14+
15+
// SanitizeParameters transforms parameters so they are safe for Postgres in cluster.
16+
func SanitizeParameters(cluster *v1beta1.PostgresCluster, parameters *ParameterSet) {
17+
if v, ok := parameters.Get("log_directory"); ok {
18+
parameters.Add("log_directory", sanitizeLogDirectory(cluster, v))
19+
}
20+
}
21+
22+
// sensitiveAbsolutePath is used by [sanitizeLogDirectory].
23+
var sensitiveAbsolutePath = regexp.MustCompile(
24+
// Rooted in one of these volumes
25+
`^(` + dataMountPath + `|` + tmpMountPath + `|` + walMountPath + `)` +
26+
27+
// First subdirectory is a Postgres directory
28+
`/(` + `pg\d+` + // [DataDirectory]
29+
`|` + `pgsql_tmp` + // https://www.postgresql.org/docs/current/storage-file-layout.html
30+
`|` + `pg\d+_wal` + // [WALDirectory]
31+
`)(/|$)`,
32+
)
33+
34+
// sensitiveRelativePath is used by [sanitizeLogDirectory].
35+
var sensitiveRelativePath = regexp.MustCompile(
36+
`^(archive|base|current|global|patroni|pg_|PG_|postgresql|postmaster|[[:xdigit:]]{24,})` +
37+
`|` + `[.](history|partial)$`,
38+
)
39+
40+
// sanitizeLogDirectory returns the absolute path to input when it is a safe "log_directory" for cluster.
41+
// Otherwise, it returns the absolute path to a good "log_directory" value.
42+
//
43+
// https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-LOG-DIRECTORY
44+
func sanitizeLogDirectory(cluster *v1beta1.PostgresCluster, input string) string {
45+
directory := path.Clean(input)
46+
47+
// [path.Clean] leaves leading parent directories. Eliminate these as a security measure.
48+
for strings.HasPrefix(directory, "../") {
49+
directory = directory[3:]
50+
}
51+
52+
switch {
53+
case directory == "log":
54+
// This the Postgres default and the only relative path allowed in v1 of PostgresCluster.
55+
// Postgres interprets the parameter relative to the data directory.
56+
return path.Join(DataDirectory(cluster), "log")
57+
58+
case directory == "", directory == ".", directory == "/",
59+
sensitiveAbsolutePath.MatchString(directory),
60+
sensitiveRelativePath.MatchString(directory):
61+
// When the value is empty after cleaning or disallowed, choose one instead.
62+
// Keep it on the same volume, if possible.
63+
if strings.HasPrefix(directory, tmpMountPath) {
64+
return path.Join(tmpMountPath, "logs/postgres")
65+
}
66+
if strings.HasPrefix(directory, walMountPath) {
67+
return path.Join(walMountPath, "logs/postgres")
68+
}
69+
70+
// There is always a data volume, so use that.
71+
return path.Join(dataMountPath, "logs/postgres")
72+
73+
case !path.IsAbs(directory):
74+
// Directory is relative. This is disallowed since v1 of PostgresCluster.
75+
// Expand it relative to the data directory like Postgres does.
76+
return path.Join(DataDirectory(cluster), directory)
77+
78+
default:
79+
// Directory is absolute and considered safe; use it.
80+
return directory
81+
}
82+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Copyright 2021 - 2025 Crunchy Data Solutions, Inc.
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
package postgres
6+
7+
import (
8+
"path"
9+
"testing"
10+
11+
"gotest.tools/v3/assert"
12+
13+
"github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1"
14+
)
15+
16+
func TestSanitizeLogDirectory(t *testing.T) {
17+
t.Parallel()
18+
19+
cluster := new(v1beta1.PostgresCluster)
20+
cluster.Spec.PostgresVersion = 18
21+
22+
for _, tt := range []struct{ expected, from string }{
23+
// User wants logs inside the data directory.
24+
{expected: "/pgdata/pg18/log", from: "log"},
25+
26+
// Postgres interprets blank to mean root.
27+
// That's no good, so we choose better.
28+
{expected: "/pgdata/logs/postgres", from: ""},
29+
{expected: "/pgdata/logs/postgres", from: "/"},
30+
31+
// Paths into Postgres directories are replaced on the same volume.
32+
{expected: "/pgdata/logs/postgres", from: "."},
33+
{expected: "/pgdata/logs/postgres", from: "global"},
34+
{expected: "/pgdata/logs/postgres", from: "postgresql.conf"},
35+
{expected: "/pgdata/logs/postgres", from: "postgresql.auto.conf"},
36+
{expected: "/pgdata/logs/postgres", from: "pg_wal"},
37+
{expected: "/pgdata/logs/postgres", from: "/pgdata/pg99/any"},
38+
{expected: "/pgdata/logs/postgres", from: "/pgdata/pg18_wal"},
39+
{expected: "/pgdata/logs/postgres", from: "/pgdata/pgsql_tmp/1/2"},
40+
{expected: "/pgwal/logs/postgres", from: "/pgwal/pg18_wal"},
41+
{expected: "/pgtmp/logs/postgres", from: "/pgtmp/pg18_wal"},
42+
} {
43+
result := sanitizeLogDirectory(cluster, tt.from)
44+
45+
assert.Equal(t, tt.expected, result, "from: %s", tt.from)
46+
assert.Assert(t, path.IsAbs(result))
47+
}
48+
}

0 commit comments

Comments
 (0)