Skip to content

Commit ed882c2

Browse files
armruleonardocemnencia
authored
fix(restore): shell-quote restore_command arguments (cloudnative-pg#10518)
getRestoreWalConfig joined user-controlled backup status fields (EndpointURL, DestinationPath, ServerName) with spaces into a single restore_command string. PostgreSQL runs restore_command via system(), and the POSIX shell word-splits unquoted whitespace, so a DestinationPath like "s3://my bucket/wal" would be passed to barman-cloud-wal-restore as separate arguments. This bug predates cloudnative-pg#10506 but was unreachable for inputs containing single quotes or backslashes because such values corrupted the config file first. Now that the config file is well-formed, the shell layer needs its own quoting. Wrap each cmd element via github.com/kballard/go-shellquote before passing the joined value through configfile.RenderPostgresConfiguration. The result is two layered quoting passes: shell-safe single quotes around any argument with whitespace or shell metacharacters, and the standard config-file literal escape applied by the helper. Related cloudnative-pg#10515 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: Leonardo Cecchi <leonardo.cecchi@enterprisedb.com> Co-authored-by: Marco Nenciarini <marco.nenciarini@enterprisedb.com>
1 parent 78dc751 commit ed882c2

2 files changed

Lines changed: 58 additions & 6 deletions

File tree

pkg/management/postgres/restore.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import (
4747
"github.com/cloudnative-pg/machinery/pkg/fileutils"
4848
"github.com/cloudnative-pg/machinery/pkg/log"
4949
"github.com/cloudnative-pg/machinery/pkg/stringset"
50+
"github.com/kballard/go-shellquote"
5051
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
5152
"k8s.io/apimachinery/pkg/util/wait"
5253
"k8s.io/client-go/util/retry"
@@ -145,6 +146,10 @@ func (info InitInfo) RestoreSnapshot(ctx context.Context, cli client.Client, imm
145146
}
146147

147148
var envs []string
149+
// Operator-controlled command: LogPath and LogFileName are operator
150+
// constants, so no shell-quoting layer is needed here. The config-file
151+
// literal escaping is still applied for consistency with the WAL-restore
152+
// path in getRestoreWalConfig.
148153
restoreCmd := fmt.Sprintf(
149154
"/controller/manager wal-restore --log-destination %s/%s.json %%f %%p",
150155
postgresSpec.LogPath, postgresSpec.LogFileName)
@@ -641,7 +646,7 @@ func getRestoreWalConfig(ctx context.Context, backup *apiv1.Backup) (string, err
641646

642647
return configfile.RenderPostgresConfiguration(map[string]string{
643648
"recovery_target_action": "promote",
644-
"restore_command": strings.Join(cmd, " "),
649+
"restore_command": shellquote.Join(cmd...),
645650
}), nil
646651
}
647652

pkg/management/postgres/restore_test.go

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ var _ = Describe("testing restore InitInfo methods", func() {
209209
})
210210

211211
var _ = Describe("getRestoreWalConfig", func() {
212-
It("escapes quotes and backslashes so user-controlled backup fields cannot corrupt custom.conf",
212+
It("escapes quotes and backslashes across both shell and config layers",
213213
func(ctx SpecContext) {
214214
backup := &apiv1.Backup{
215215
Status: apiv1.BackupStatus{
@@ -219,12 +219,59 @@ var _ = Describe("getRestoreWalConfig", func() {
219219
}
220220
out, err := getRestoreWalConfig(ctx, backup)
221221
Expect(err).ToNot(HaveOccurred())
222-
// Embedded single quote must be doubled; backslash must be doubled.
223-
Expect(out).To(ContainSubstring("s3://bucket/has''quote"))
224-
Expect(out).To(ContainSubstring(`server\\name`))
225-
// And the raw unescaped form must NOT appear anywhere in the output.
222+
223+
// shellQuote wraps the value as has\'quote (escape).
224+
// configfile.EscapePostgresConfLiteral then doubles every ' and \,
225+
// yielding the exact form below at the config-file layer.
226+
Expect(out).To(ContainSubstring(`s3://bucket/has\\''quote`))
227+
// shellQuote quotes `\` to `\\` — config layer doubles the
228+
// backslash.
229+
Expect(out).To(ContainSubstring(`server\\\\name`))
230+
231+
// Raw unescaped forms must not appear — either would let the user
232+
// break out of the config-file string literal.
226233
Expect(out).NotTo(ContainSubstring("has'quote"))
227234
Expect(out).NotTo(MatchRegexp(`server\\[^\\]name`),
228235
"backslash in server name must be doubled")
229236
})
237+
238+
It("shell-quotes cmd args so whitespace in user-controlled fields does not word-split",
239+
func(ctx SpecContext) {
240+
backup := &apiv1.Backup{
241+
Status: apiv1.BackupStatus{
242+
DestinationPath: "s3://my bucket/wal",
243+
ServerName: "server-a",
244+
},
245+
}
246+
out, err := getRestoreWalConfig(ctx, backup)
247+
Expect(err).ToNot(HaveOccurred())
248+
// After shell-quoting, the DestinationPath is wrapped in single quotes.
249+
// The outer configfile.EscapePostgresConfLiteral layer then doubles
250+
// each of those single quotes, so the value appears as ''...'' in
251+
// the config file.
252+
Expect(out).To(ContainSubstring("''s3://my bucket/wal''"))
253+
254+
// PostgreSQL will replace %f %p with proper quoting when needed.
255+
Expect(out).To(ContainSubstring("%f"))
256+
Expect(out).To(ContainSubstring("%p"))
257+
})
258+
259+
It("nests an obvious shell-injection payload inside both quoting layers",
260+
func(ctx SpecContext) {
261+
backup := &apiv1.Backup{
262+
Status: apiv1.BackupStatus{
263+
DestinationPath: `s3://bucket"; rm -rf /; "`,
264+
ServerName: "server-a",
265+
},
266+
}
267+
out, err := getRestoreWalConfig(ctx, backup)
268+
Expect(err).ToNot(HaveOccurred())
269+
270+
// shellquote.Join wraps the whole arg in single quotes (because
271+
// of the embedded `;` and spaces); EscapePostgresConfLiteral
272+
// then doubles each of those quotes. The payload must arrive at
273+
// the shell as one argument, not as a `;`-separated command.
274+
Expect(out).To(ContainSubstring(
275+
`''s3://bucket"; rm -rf /; "''`))
276+
})
230277
})

0 commit comments

Comments
 (0)