Skip to content

Commit cd57381

Browse files
authored
fix: Make dry-run=server optional (#499)
This intends to fix a potential security issue introduced via #458 before cutting the next helm-diff release. Since #458 (unreleased), we had forced helm-diff to use `helm template --dry-run=server` for Helm 3.13 or greater. I think this can create an unintended security hole, where any users, who can run helm-diff via CI or any automation with an arbitrary chart and values, is able to view cluster resources via helm template's `lookup` functions. Previously this was impossible because `helm template` run by `helm diff` had no access to the `lookup` function. To fix this, we need to make `--dry-run=server` optional. And we do so by changing helm-diff's `--dry-run` flag to accept only only booleans but also `client` and `server`. The updated flag usage is `--dry-run[=[|true|false|client|server]]`. See the updated README and the updated helm-diff help message for more details.
1 parent 857da53 commit cd57381

File tree

5 files changed

+339
-19
lines changed

5 files changed

+339
-19
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,14 @@ Examples:
149149
# See https://github.com/databus23/helm-diff/issues/278 for more information.
150150
HELM_DIFF_IGNORE_UNKNOWN_FLAGS=true helm diff upgrade my-release stable/postgres --wait
151151
152+
# helm-diff disallows the use of the `lookup` function by default.
153+
# To enable it, you must set HELM_DIFF_USE_INSECURE_SERVER_SIDE_DRY_RUN=true to
154+
# use `helm template --dry-run=server` or
155+
# `helm upgrade --dry-run=server` (in case you also set `HELM_DIFF_USE_UPGRADE_DRY_RUN`)
156+
# See https://github.com/databus23/helm-diff/pull/458
157+
# for more information.
158+
HELM_DIFF_USE_INSECURE_SERVER_SIDE_DRY_RUN=true helm diff upgrade my-release datadog/datadog
159+
152160
# Set HELM_DIFF_USE_UPGRADE_DRY_RUN=true to
153161
# use `helm upgrade --dry-run` instead of `helm template` to render manifests from the chart.
154162
# See https://github.com/databus23/helm-diff/issues/253 for more information.

cmd/helm3.go

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ import (
1414
)
1515

1616
var (
17-
helmVersionRE = regexp.MustCompile(`Version:\s*"([^"]+)"`)
18-
minHelmVersion = semver.MustParse("v3.1.0-rc.1")
17+
helmVersionRE = regexp.MustCompile(`Version:\s*"([^"]+)"`)
18+
minHelmVersion = semver.MustParse("v3.1.0-rc.1")
19+
// See https://github.com/helm/helm/pull/9426
1920
minHelmVersionWithDryRunLookupSupport = semver.MustParse("v3.13.0")
2021
)
2122

@@ -131,7 +132,7 @@ func (d *diffCmd) template(isUpgrade bool) ([]byte, error) {
131132
// Let's simulate that in helm-diff.
132133
// See https://medium.com/@kcatstack/understand-helm-upgrade-flags-reset-values-reuse-values-6e58ac8f127e
133134
shouldDefaultReusingValues := isUpgrade && len(d.values) == 0 && len(d.stringValues) == 0 && len(d.jsonValues) == 0 && len(d.valueFiles) == 0 && len(d.fileValues) == 0
134-
if (d.reuseValues || shouldDefaultReusingValues) && !d.resetValues && !d.dryRun {
135+
if (d.reuseValues || shouldDefaultReusingValues) && !d.resetValues && d.clusterAccessAllowed() {
135136
tmpfile, err := os.CreateTemp("", "existing-values")
136137
if err != nil {
137138
return nil, err
@@ -195,17 +196,28 @@ func (d *diffCmd) template(isUpgrade bool) ([]byte, error) {
195196
filter func([]byte) []byte
196197
)
197198

199+
// `--dry-run=client` or `--dry-run=server`?
200+
//
201+
// Or what's the relationoship between helm-diff's --dry-run flag,
202+
// HELM_DIFF_UPGRADE_DRY_RUN env var and the helm upgrade --dry-run flag?
203+
//
204+
// Read on to find out.
198205
if d.useUpgradeDryRun {
199-
if d.dryRun {
200-
return nil, fmt.Errorf("`diff upgrade --dry-run` conflicts with HELM_DIFF_USE_UPGRADE_DRY_RUN_AS_TEMPLATE. Either remove --dry-run to enable cluster access, or unset HELM_DIFF_USE_UPGRADE_DRY_RUN_AS_TEMPLATE to make cluster access unnecessary")
201-
}
202-
203206
if d.isAllowUnreleased() {
204207
// Otherwise you get the following error when this is a diff for a new install
205208
// Error: UPGRADE FAILED: "$RELEASE_NAME" has no deployed releases
206209
flags = append(flags, "--install")
207210
}
208211

212+
// If the program reaches here,
213+
// we are sure that the user wants to user the `helm upgrade --dry-run` command
214+
// for generating the manifests to be diffed.
215+
//
216+
// So the question is only whether to use `--dry-run=client` or `--dry-run=server`.
217+
//
218+
// As HELM_DIFF_UPGRADE_DRY_RUN is there for producing more complete and correct diff results,
219+
// we use --dry-run=server if the version of helm supports it.
220+
// Otherwise, we use --dry-run=client, as that's the best we can do.
209221
if useDryRunService, err := isHelmVersionAtLeast(minHelmVersionWithDryRunLookupSupport); err == nil && useDryRunService {
210222
flags = append(flags, "--dry-run=server")
211223
} else {
@@ -216,7 +228,7 @@ func (d *diffCmd) template(isUpgrade bool) ([]byte, error) {
216228
return extractManifestFromHelmUpgradeDryRunOutput(s, d.noHooks)
217229
}
218230
} else {
219-
if !d.disableValidation && !d.dryRun {
231+
if !d.disableValidation && d.clusterAccessAllowed() {
220232
flags = append(flags, "--validate")
221233
}
222234

@@ -232,8 +244,46 @@ func (d *diffCmd) template(isUpgrade bool) ([]byte, error) {
232244
flags = append(flags, "--kube-version", d.kubeVersion)
233245
}
234246

247+
// To keep the full compatibility with older helm-diff versions,
248+
// we pass --dry-run to `helm template` only if Helm is greater than v3.13.0.
235249
if useDryRunService, err := isHelmVersionAtLeast(minHelmVersionWithDryRunLookupSupport); err == nil && useDryRunService {
236-
flags = append(flags, "--dry-run=server")
250+
// However, which dry-run mode to use is still not clear.
251+
//
252+
// For compatibility with the old and new helm-diff options,
253+
// old and new helm, we assume that the user wants to use the older `helm template --dry-run=client` mode
254+
// if helm-diff has been invoked with any of the following flags:
255+
//
256+
// * no dry-run flags (to be consistent with helm-template)
257+
// * --dry-run
258+
// * --dry-run=""
259+
// * --dry-run=client
260+
//
261+
// and the newer `helm template --dry-run=server` mode when invoked with:
262+
//
263+
// * --dry-run=server
264+
//
265+
// Any other values should result in errors.
266+
//
267+
// See the fllowing link for more details:
268+
// - https://github.com/databus23/helm-diff/pull/458
269+
// - https://github.com/helm/helm/pull/9426#issuecomment-1501005666
270+
if d.dryRunMode == "server" {
271+
// This is for security reasons!
272+
//
273+
// We give helm-template the additional cluster access for the helm `lookup` function
274+
// only if the user has explicitly requested it by --dry-run=server,
275+
//
276+
// In other words, although helm-diff-upgrade implies limited cluster access by default,
277+
// helm-diff-upgrade without a --dry-run flag does NOT imply
278+
// full cluster-access via helm-template --dry-run=server!
279+
flags = append(flags, "--dry-run=server")
280+
} else {
281+
// Since helm-diff 3.9.0 and helm 3.13.0, we pass --dry-run=client to `helm template` by default.
282+
// This doesn't make any difference for helm-diff itself,
283+
// because helm-template w/o flags is equivalent to helm-template --dry-run=client.
284+
// See https://github.com/helm/helm/pull/9426#discussion_r1181397259
285+
flags = append(flags, "--dry-run=client")
286+
}
237287
}
238288

239289
subcmd = "template"

cmd/upgrade.go

Lines changed: 102 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
jsoniterator "github.com/json-iterator/go"
1414
"github.com/pkg/errors"
1515
"github.com/spf13/cobra"
16+
"github.com/spf13/pflag"
1617
"helm.sh/helm/v3/pkg/action"
1718
"helm.sh/helm/v3/pkg/cli"
1819
"helm.sh/helm/v3/pkg/kube"
@@ -37,7 +38,8 @@ type diffCmd struct {
3738
devel bool
3839
disableValidation bool
3940
disableOpenAPIValidation bool
40-
dryRun bool
41+
dryRunMode string
42+
dryRunModeSpecified bool
4143
namespace string // namespace to assume the release to be installed into. Defaults to the current kube config namespace.
4244
valueFiles valueFiles
4345
values []string
@@ -68,6 +70,28 @@ func (d *diffCmd) isAllowUnreleased() bool {
6870
return d.allowUnreleased || d.install
6971
}
7072

73+
// clusterAccessAllowed returns true if the diff command is allowed to access the cluster at some degree.
74+
//
75+
// helm-diff basically have 3 modes of operation:
76+
// 1. no cluster access at all when --dry-run, --dry-run=client, or --dry-run=true is specified.
77+
// 2. basic cluster access when --dry-run is not specified.
78+
// 3. extra cluster access when --dry-run=server is specified.
79+
//
80+
// clusterAccessAllowed returns true when the mode is either 2 or 3.
81+
//
82+
// If false, helm-diff should not access the cluster at all.
83+
// More concretely:
84+
// - It shouldn't pass --validate to helm-template because it requires cluster access.
85+
// - It shouldn't get the current release manifest using helm-get-manifest because it requires cluster access.
86+
// - It shouldn't get the current release hooks using helm-get-hooks because it requires cluster access.
87+
// - It shouldn't get the current release values using helm-get-values because it requires cluster access.
88+
//
89+
// See also https://github.com/helm/helm/pull/9426#discussion_r1181397259
90+
func (d *diffCmd) clusterAccessAllowed() bool {
91+
clientOnly := (d.dryRunModeSpecified && d.dryRunMode == "") || d.dryRunMode == "true" || d.dryRunMode == "client"
92+
return !clientOnly
93+
}
94+
7195
const globalUsage = `Show a diff explaining what a helm upgrade would change.
7296
7397
This fetches the currently deployed version of a release
@@ -85,9 +109,10 @@ func newChartCommand() *cobra.Command {
85109
}
86110

87111
cmd := &cobra.Command{
88-
Use: "upgrade [flags] [RELEASE] [CHART]",
89-
Short: "Show a diff explaining what a helm upgrade would change.",
90-
Long: globalUsage,
112+
Use: "upgrade [flags] [RELEASE] [CHART]",
113+
Short: "Show a diff explaining what a helm upgrade would change.",
114+
Long: globalUsage,
115+
DisableFlagParsing: true,
91116
Example: strings.Join([]string{
92117
" helm diff upgrade my-release stable/postgresql --values values.yaml",
93118
"",
@@ -96,6 +121,14 @@ func newChartCommand() *cobra.Command {
96121
" # See https://github.com/databus23/helm-diff/issues/278 for more information.",
97122
" HELM_DIFF_IGNORE_UNKNOWN_FLAGS=true helm diff upgrade my-release stable/postgres --wait",
98123
"",
124+
" # helm-diff disallows the use of the `lookup` function by default.",
125+
" # To enable it, you must set HELM_DIFF_USE_INSECURE_SERVER_SIDE_DRY_RUN=true to",
126+
" # use `helm template --dry-run=server` or",
127+
" # `helm upgrade --dry-run=server` (in case you also set `HELM_DIFF_USE_UPGRADE_DRY_RUN`)",
128+
" # See https://github.com/databus23/helm-diff/pull/458",
129+
" # for more information.",
130+
" HELM_DIFF_USE_INSECURE_SERVER_SIDE_DRY_RUN=true helm diff upgrade my-release datadog/datadog",
131+
"",
99132
" # Set HELM_DIFF_USE_UPGRADE_DRY_RUN=true to",
100133
" # use `helm upgrade --dry-run` instead of `helm template` to render manifests from the chart.",
101134
" # See https://github.com/databus23/helm-diff/issues/253 for more information.",
@@ -118,10 +151,70 @@ func newChartCommand() *cobra.Command {
118151
"# Read the flag usage below for more information on --context.",
119152
"HELM_DIFF_OUTPUT_CONTEXT=5 helm diff upgrade my-release datadog/datadog",
120153
}, "\n"),
121-
Args: func(cmd *cobra.Command, args []string) error {
122-
return checkArgsLength(len(args), "release name", "chart path")
123-
},
124154
RunE: func(cmd *cobra.Command, args []string) error {
155+
// Note that we can't just move this block to PersistentPreRunE,
156+
// because cmd.SetArgs(args) does not persist between PersistentPreRunE and RunE.
157+
// The choice is between:
158+
// 1. Doing this in RunE
159+
// 2. Doing this in PersistentPreRunE, saving args somewhere, and calling cmd.SetArgs(args) again in RunE
160+
// 2 is more complicated without much benefit, so we choose 1.
161+
{
162+
const (
163+
dryRunUsage = "--dry-run, --dry-run=client, or --dry-run=true disables cluster access and show diff as if it was install. Implies --install, --reset-values, and --disable-validation." +
164+
" --dry-run=server enables the cluster access with helm-get and the lookup template function."
165+
)
166+
167+
legacyDryRunFlagSet := pflag.NewFlagSet("upgrade", pflag.ContinueOnError)
168+
legacyDryRun := legacyDryRunFlagSet.Bool("dry-run", false, dryRunUsage)
169+
if err := legacyDryRunFlagSet.Parse(args); err == nil && *legacyDryRun {
170+
diff.dryRunModeSpecified = true
171+
args = legacyDryRunFlagSet.Args()
172+
} else {
173+
cmd.Flags().StringVar(&diff.dryRunMode, "dry-run", "", dryRunUsage)
174+
}
175+
176+
fmt.Fprintf(os.Stderr, "args after legacy dry-run parsing: %v\n", args)
177+
178+
// Here we parse the flags ourselves so that we can support
179+
// both --dry-run and --dry-run=ARG syntaxes.
180+
//
181+
// If you don't do this, then cobra will treat --dry-run as
182+
// a "flag needs an argument: --dry-run" error.
183+
//
184+
// This works becase we have `DisableFlagParsing: true`` above.
185+
// Never turn that off, or you'll get the error again.
186+
if err := cmd.Flags().Parse(args); err != nil {
187+
return err
188+
}
189+
190+
args = cmd.Flags().Args()
191+
192+
if !diff.dryRunModeSpecified {
193+
dryRunModeSpecified := cmd.Flags().Changed("dry-run")
194+
diff.dryRunModeSpecified = dryRunModeSpecified
195+
196+
if dryRunModeSpecified && !(diff.dryRunMode == "client" || diff.dryRunMode == "server") {
197+
return fmt.Errorf("flag %q must take an argument of %q or %q but got %q", "dry-run", "client", "server", diff.dryRunMode)
198+
}
199+
}
200+
201+
cmd.SetArgs(args)
202+
203+
// We have to do this here because we have to sort out the
204+
// --dry-run flag ambiguity to determine the args to be checked.
205+
//
206+
// In other words, we can't just do:
207+
//
208+
// cmd.Args = func(cmd *cobra.Command, args []string) error {
209+
// return checkArgsLength(len(args), "release name", "chart path")
210+
// }
211+
//
212+
// Because it seems to take precedence over the PersistentPreRunE
213+
if err := checkArgsLength(len(args), "release name", "chart path"); err != nil {
214+
return err
215+
}
216+
}
217+
125218
// Suppress the command usage on error. See #77 for more info
126219
cmd.SilenceUsage = true
127220

@@ -195,7 +288,6 @@ func newChartCommand() *cobra.Command {
195288
f.BoolVar(&diff.devel, "devel", false, "use development versions, too. Equivalent to version '>0.0.0-0'. If --version is set, this is ignored.")
196289
f.BoolVar(&diff.disableValidation, "disable-validation", false, "disables rendered templates validation against the Kubernetes cluster you are currently pointing to. This is the same validation performed on an install")
197290
f.BoolVar(&diff.disableOpenAPIValidation, "disable-openapi-validation", false, "disables rendered templates validation against the Kubernetes OpenAPI Schema")
198-
f.BoolVar(&diff.dryRun, "dry-run", false, "disables cluster access and show diff as if it was install. Implies --install, --reset-values, and --disable-validation")
199291
f.StringVar(&diff.postRenderer, "post-renderer", "", "the path to an executable to be used for post rendering. If it exists in $PATH, the binary will be used, otherwise it will try to look for the executable at the given path")
200292
f.StringArrayVar(&diff.postRendererArgs, "post-renderer-args", []string{}, "an argument to the post-renderer (can specify multiple)")
201293
f.BoolVar(&diff.insecureSkipTLSVerify, "insecure-skip-tls-verify", false, "skip tls certificate checks for the chart download")
@@ -215,7 +307,7 @@ func (d *diffCmd) runHelm3() error {
215307

216308
var err error
217309

218-
if !d.dryRun {
310+
if d.clusterAccessAllowed() {
219311
releaseManifest, err = getRelease(d.release, d.namespace)
220312
}
221313

@@ -262,7 +354,7 @@ func (d *diffCmd) runHelm3() error {
262354
}
263355

264356
currentSpecs := make(map[string]*manifest.MappingResult)
265-
if !newInstall && !d.dryRun {
357+
if !newInstall && d.clusterAccessAllowed() {
266358
if !d.noHooks && !d.threeWayMerge {
267359
hooks, err := getHooks(d.release, d.namespace)
268360
if err != nil {

cmd/upgrade_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package cmd
2+
3+
import "testing"
4+
5+
func TestIsRemoteAccessAllowed(t *testing.T) {
6+
cases := []struct {
7+
name string
8+
cmd diffCmd
9+
expected bool
10+
}{
11+
{
12+
name: "no flags",
13+
cmd: diffCmd{},
14+
expected: true,
15+
},
16+
{
17+
name: "legacy explicit dry-run flag",
18+
cmd: diffCmd{
19+
dryRunModeSpecified: true,
20+
dryRunMode: "true",
21+
},
22+
expected: false,
23+
},
24+
{
25+
name: "legacy empty dry-run flag",
26+
cmd: diffCmd{
27+
dryRunModeSpecified: true,
28+
dryRunMode: "",
29+
},
30+
expected: false,
31+
},
32+
{
33+
name: "server-side dry-run flag",
34+
cmd: diffCmd{
35+
dryRunModeSpecified: true,
36+
dryRunMode: "server",
37+
},
38+
expected: true,
39+
},
40+
{
41+
name: "client-side dry-run flag",
42+
cmd: diffCmd{
43+
dryRunModeSpecified: true,
44+
dryRunMode: "client",
45+
},
46+
expected: false,
47+
},
48+
}
49+
50+
for _, tc := range cases {
51+
t.Run(tc.name, func(t *testing.T) {
52+
actual := tc.cmd.clusterAccessAllowed()
53+
if actual != tc.expected {
54+
t.Errorf("Expected %v, got %v", tc.expected, actual)
55+
}
56+
})
57+
}
58+
}

0 commit comments

Comments
 (0)