Skip to content

Commit a2e37b2

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 <ben.blattberg@crunchydata.com> Co-authored-by: Drew Sessler <drew.sessler@crunchydata.com> Issue: PGO-2558
1 parent bb75c08 commit a2e37b2

File tree

4 files changed

+149
-1
lines changed

4 files changed

+149
-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: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
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 matches absolute paths that Postgres expects to control.
23+
// User input should not direct tools to write to these directories.
24+
//
25+
// See [sanitizeLogDirectory].
26+
var sensitiveAbsolutePath = regexp.MustCompile(
27+
// Rooted in one of these volumes
28+
`^(` + dataMountPath + `|` + tmpMountPath + `|` + walMountPath + `)` +
29+
30+
// First subdirectory is a Postgres directory
31+
`/(` + `pg\d+` + // [DataDirectory]
32+
`|` + `pgsql_tmp` + // https://www.postgresql.org/docs/current/storage-file-layout.html
33+
`|` + `pg\d+_wal` + // [WALDirectory]
34+
`)(/|$)`,
35+
)
36+
37+
// sensitiveRelativePath matches paths relative to the Postgres "data_directory" that Postgres expects to control.
38+
// Arguably, everything inside the data directory is sensitve, but this is here because
39+
// Postges interprets some of its parameters relative to its data directory.
40+
//
41+
// User input should not direct tools to write to these directories.
42+
//
43+
// NOTE: This is not an exhaustive list! New code should use an allowlist rather than this denylist.
44+
//
45+
// See [sanitizeLogDirectory].
46+
var sensitiveRelativePath = regexp.MustCompile(
47+
`^(archive|base|current|global|patroni|pg_|PG_|postgresql|postmaster|[[:xdigit:]]{24,})` +
48+
`|` + `[.](history|partial)$`,
49+
)
50+
51+
// sanitizeLogDirectory returns the absolute path to input when it is a safe "log_directory" for cluster.
52+
// Otherwise, it returns the absolute path to a good "log_directory" value.
53+
//
54+
// https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-LOG-DIRECTORY
55+
func sanitizeLogDirectory(cluster *v1beta1.PostgresCluster, input string) string {
56+
directory := path.Clean(input)
57+
58+
// [path.Clean] leaves leading parent directories. Eliminate these as a security measure.
59+
for strings.HasPrefix(directory, "../") {
60+
directory = directory[3:]
61+
}
62+
63+
switch {
64+
case directory == "log":
65+
// This the Postgres default and the only relative path allowed in v1 of PostgresCluster.
66+
// Expand it relative to the data directory like Postgres does.
67+
return path.Join(DataDirectory(cluster), "log")
68+
69+
case directory == "", directory == ".", directory == "/",
70+
sensitiveAbsolutePath.MatchString(directory),
71+
sensitiveRelativePath.MatchString(directory):
72+
// When the value is empty after cleaning or disallowed, choose one instead.
73+
// Keep it on the same volume, if possible.
74+
if strings.HasPrefix(directory, tmpMountPath) {
75+
return path.Join(tmpMountPath, "logs/postgres")
76+
}
77+
if strings.HasPrefix(directory, walMountPath) {
78+
return path.Join(walMountPath, "logs/postgres")
79+
}
80+
81+
// There is always a data volume, so use that.
82+
return path.Join(dataMountPath, "logs/postgres")
83+
84+
case !path.IsAbs(directory):
85+
// Directory is relative. This is disallowed since v1 of PostgresCluster.
86+
// Expand it relative to the data directory like Postgres does.
87+
return path.Join(DataDirectory(cluster), directory)
88+
89+
default:
90+
// Directory is absolute and considered safe; use it.
91+
return directory
92+
}
93+
}
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)