Skip to content

Commit bb0d0f3

Browse files
edgarbellotclaude
andcommitted
fix: tighten GitLab host matching and request handling in helm values fetch
Make fetchFromGitlabIfNecessary match the configured GitLab host (from --utilities-git-url) by exact hostname instead of a "git" prefix, require HTTPS for outbound calls to that host, send the token using the standard PRIVATE-TOKEN header, and route requests through a dedicated http.Client that does not follow redirects and applies a request timeout. Add unit tests covering the host match, scheme requirement, header propagation and redirect behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 06adb2a commit bb0d0f3

2 files changed

Lines changed: 241 additions & 10 deletions

File tree

internal/provisioner/utility/helm_utils.go

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,24 @@ import (
1313
"net/url"
1414
"os"
1515
"strings"
16+
"time"
1617

1718
"github.com/mattermost/mattermost-cloud/internal/tools/helm"
1819
"github.com/mattermost/mattermost-cloud/model"
1920
"github.com/pkg/errors"
2021
log "github.com/sirupsen/logrus"
2122
)
2223

24+
// gitlabFetchClient is the HTTP client used to download Helm values files
25+
// from the configured GitLab instance. Redirects are not followed and a
26+
// timeout is applied to keep the supervisor goroutine responsive.
27+
var gitlabFetchClient = &http.Client{
28+
Timeout: 30 * time.Second,
29+
CheckRedirect: func(req *http.Request, via []*http.Request) error {
30+
return http.ErrUseLastResponse
31+
},
32+
}
33+
2334
const (
2435
defaultKubeConfigPath = ""
2536
defaultHelmDeploymentSetArgument = ""
@@ -264,13 +275,28 @@ type gitlabValuesFileResponse struct {
264275
Content string `json:"content"`
265276
}
266277

278+
// isConfiguredGitlabHost reports whether valPathURL points at the GitLab
279+
// instance configured via --utilities-git-url. The hostname comparison is
280+
// exact and case-insensitive.
281+
func isConfiguredGitlabHost(valPathURL *url.URL) bool {
282+
configured := model.GetGitopsRepoURL()
283+
if configured == "" {
284+
return false
285+
}
286+
configuredURL, err := url.Parse(configured)
287+
if err != nil || configuredURL.Hostname() == "" {
288+
return false
289+
}
290+
return strings.EqualFold(valPathURL.Hostname(), configuredURL.Hostname())
291+
}
292+
267293
// fetchFromGitlabIfNecessary returns the path of the values file. If
268294
// this is a local path or a non-Gitlab URL, the path is simply
269-
// returned unchanged. If a Gitlab URL is provided, the values file is
270-
// fetched and stored in the OS's temp dir and the filename of the
271-
// file is returned. If a temp file is created, a cleanup routine will
272-
// be returned as the second return value, otherwise that value will
273-
// be nil
295+
// returned unchanged. If the configured Gitlab host is provided over
296+
// HTTPS, the values file is fetched and stored in the OS's temp dir and
297+
// the filename of the file is returned. If a temp file is created, a
298+
// cleanup routine will be returned as the second return value,
299+
// otherwise that value will be nil
274300
func fetchFromGitlabIfNecessary(path string) (string, func(string), error) {
275301
gitlabKey := model.GetGitlabToken()
276302
if gitlabKey == "" {
@@ -283,18 +309,26 @@ func fetchFromGitlabIfNecessary(path string) (string, func(string), error) {
283309
}
284310

285311
// silently allow other public non-Gitlab URLs
286-
if !strings.HasPrefix(valPathURL.Host, "git") {
312+
if !isConfiguredGitlabHost(valPathURL) {
287313
return path, nil, nil
288314
}
289315

290-
// if Gitlab, fetch the file using the API
291-
path = fmt.Sprintf("%s&private_token=%s", path, gitlabKey)
316+
if !strings.EqualFold(valPathURL.Scheme, "https") {
317+
return "", nil, errors.New("Gitlab values file URL must use HTTPS")
318+
}
319+
320+
req, err := http.NewRequest(http.MethodGet, path, nil)
321+
if err != nil {
322+
return "", nil, errors.Wrap(err, "failed to build Gitlab request")
323+
}
324+
req.Header.Set("PRIVATE-TOKEN", gitlabKey)
292325

293-
resp, err := http.Get(path)
326+
resp, err := gitlabFetchClient.Do(req)
294327
if err != nil {
295328
return "", nil, errors.Wrap(err, "failed to request the values file from Gitlab")
296329
}
297-
if resp.StatusCode >= 400 {
330+
defer resp.Body.Close()
331+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
298332
return "", nil, errors.Errorf("request to Gitlab failed with status: %s", resp.Status)
299333
}
300334

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
2+
// See LICENSE.txt for license information.
3+
//
4+
5+
package utility
6+
7+
import (
8+
"crypto/tls"
9+
"encoding/base64"
10+
"encoding/json"
11+
"net/http"
12+
"net/http/httptest"
13+
"net/url"
14+
"os"
15+
"strings"
16+
"sync/atomic"
17+
"testing"
18+
"time"
19+
20+
"github.com/mattermost/mattermost-cloud/model"
21+
"github.com/stretchr/testify/assert"
22+
"github.com/stretchr/testify/require"
23+
)
24+
25+
// withGitlabConfig temporarily sets the package-level GitLab token and
26+
// gitops repo URL used by fetchFromGitlabIfNecessary, restoring previous
27+
// values on cleanup.
28+
func withGitlabConfig(t *testing.T, token, repoURL string) {
29+
t.Helper()
30+
prevToken := model.GetGitlabToken()
31+
prevURL := model.GetGitopsRepoURL()
32+
model.SetGitlabToken(token)
33+
model.SetGitopsRepoURL(repoURL)
34+
t.Cleanup(func() {
35+
model.SetGitlabToken(prevToken)
36+
model.SetGitopsRepoURL(prevURL)
37+
})
38+
}
39+
40+
// withFetchClient swaps the package-level gitlabFetchClient for a test
41+
// client that trusts httptest's TLS server and counts outbound calls.
42+
func withFetchClient(t *testing.T) *atomic.Int64 {
43+
t.Helper()
44+
prev := gitlabFetchClient
45+
var hits atomic.Int64
46+
gitlabFetchClient = &http.Client{
47+
Timeout: 5 * time.Second,
48+
CheckRedirect: func(req *http.Request, via []*http.Request) error {
49+
return http.ErrUseLastResponse
50+
},
51+
Transport: &countingTransport{
52+
counter: &hits,
53+
next: &http.Transport{
54+
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
55+
},
56+
},
57+
}
58+
t.Cleanup(func() { gitlabFetchClient = prev })
59+
return &hits
60+
}
61+
62+
type countingTransport struct {
63+
counter *atomic.Int64
64+
next http.RoundTripper
65+
}
66+
67+
func (c *countingTransport) RoundTrip(r *http.Request) (*http.Response, error) {
68+
c.counter.Add(1)
69+
return c.next.RoundTrip(r)
70+
}
71+
72+
func TestFetchFromGitlabIfNecessary_NoTokenReturnsPathUnchanged(t *testing.T) {
73+
withGitlabConfig(t, "", "https://gitlab.example.com")
74+
hits := withFetchClient(t)
75+
76+
in := "https://gitlab.example.com/api/v4/projects/1/repository/files/values.yaml?ref=main"
77+
out, cleanup, err := fetchFromGitlabIfNecessary(in)
78+
require.NoError(t, err)
79+
assert.Equal(t, in, out)
80+
assert.Nil(t, cleanup)
81+
assert.Zero(t, hits.Load())
82+
}
83+
84+
func TestFetchFromGitlabIfNecessary_NonConfiguredHostReturnsPathUnchanged(t *testing.T) {
85+
withGitlabConfig(t, "secret-token", "https://gitlab.example.com")
86+
hits := withFetchClient(t)
87+
88+
in := "https://example.org/values.yaml"
89+
out, cleanup, err := fetchFromGitlabIfNecessary(in)
90+
require.NoError(t, err)
91+
assert.Equal(t, in, out)
92+
assert.Nil(t, cleanup)
93+
assert.Zero(t, hits.Load())
94+
}
95+
96+
// TestFetchFromGitlabIfNecessary_HostMatchIsExact covers a range of URL
97+
// shapes whose host is not equal to the configured GitLab host. All of
98+
// them must be treated as non-GitLab URLs and returned unchanged.
99+
func TestFetchFromGitlabIfNecessary_HostMatchIsExact(t *testing.T) {
100+
withGitlabConfig(t, "secret-token", "https://gitlab.example.com")
101+
hits := withFetchClient(t)
102+
103+
nonConfiguredURLs := []string{
104+
"https://git.example.com/values.yaml?ref=main",
105+
"https://github.com/x?a=1",
106+
"https://gitea.example.org/y?z=1",
107+
"https://gitlab.example.com.other.org/values.yaml?ref=main",
108+
"https://other.org/gitlab.example.com/values.yaml",
109+
"https://gitlab.example.com@other.org/values.yaml",
110+
}
111+
112+
for _, u := range nonConfiguredURLs {
113+
out, cleanup, err := fetchFromGitlabIfNecessary(u)
114+
require.NoError(t, err, "url=%s", u)
115+
assert.Equal(t, u, out, "url=%s should be returned unchanged", u)
116+
assert.Nil(t, cleanup, "url=%s should not produce a cleanup", u)
117+
}
118+
assert.Zero(t, hits.Load())
119+
}
120+
121+
func TestFetchFromGitlabIfNecessary_RejectsHTTPForConfiguredHost(t *testing.T) {
122+
withGitlabConfig(t, "secret-token", "https://gitlab.example.com")
123+
hits := withFetchClient(t)
124+
125+
in := "http://gitlab.example.com/api/v4/projects/1/repository/files/values.yaml?ref=main"
126+
_, _, err := fetchFromGitlabIfNecessary(in)
127+
require.Error(t, err)
128+
assert.Contains(t, err.Error(), "HTTPS")
129+
assert.Zero(t, hits.Load())
130+
}
131+
132+
func TestFetchFromGitlabIfNecessary_SendsTokenViaPrivateTokenHeader(t *testing.T) {
133+
var (
134+
seenPrivateTokenHeader string
135+
seenRawQuery string
136+
)
137+
138+
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
139+
seenPrivateTokenHeader = r.Header.Get("PRIVATE-TOKEN")
140+
seenRawQuery = r.URL.RawQuery
141+
body, _ := json.Marshal(gitlabValuesFileResponse{
142+
Content: base64.StdEncoding.EncodeToString([]byte("key: value\n")),
143+
})
144+
w.Header().Set("Content-Type", "application/json")
145+
_, _ = w.Write(body)
146+
}))
147+
t.Cleanup(srv.Close)
148+
149+
srvURL, err := url.Parse(srv.URL)
150+
require.NoError(t, err)
151+
152+
withGitlabConfig(t, "secret-token", "https://"+srvURL.Host)
153+
_ = withFetchClient(t)
154+
155+
in := "https://" + srvURL.Host + "/api/v4/projects/1/repository/files/values.yaml?ref=main"
156+
out, cleanup, err := fetchFromGitlabIfNecessary(in)
157+
require.NoError(t, err)
158+
require.NotNil(t, cleanup)
159+
t.Cleanup(func() { cleanup(out) })
160+
161+
assert.Equal(t, "secret-token", seenPrivateTokenHeader)
162+
assert.NotContains(t, seenRawQuery, "private_token")
163+
assert.NotContains(t, seenRawQuery, "secret-token")
164+
165+
assert.True(t, strings.HasPrefix(out, os.TempDir()))
166+
content, err := os.ReadFile(out)
167+
require.NoError(t, err)
168+
assert.Equal(t, "key: value\n", string(content))
169+
}
170+
171+
// TestFetchFromGitlabIfNecessary_DoesNotFollowRedirects ensures the
172+
// fetch client surfaces a redirect response as an error rather than
173+
// silently following it.
174+
func TestFetchFromGitlabIfNecessary_DoesNotFollowRedirects(t *testing.T) {
175+
var downstreamHit atomic.Bool
176+
downstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
177+
downstreamHit.Store(true)
178+
w.WriteHeader(http.StatusOK)
179+
}))
180+
t.Cleanup(downstream.Close)
181+
182+
gitlab := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
183+
http.Redirect(w, r, downstream.URL+"/v", http.StatusFound)
184+
}))
185+
t.Cleanup(gitlab.Close)
186+
187+
gitlabURL, err := url.Parse(gitlab.URL)
188+
require.NoError(t, err)
189+
190+
withGitlabConfig(t, "secret-token", "https://"+gitlabURL.Host)
191+
_ = withFetchClient(t)
192+
193+
in := "https://" + gitlabURL.Host + "/api/v4/projects/1/repository/files/values.yaml?ref=main"
194+
_, _, err = fetchFromGitlabIfNecessary(in)
195+
require.Error(t, err)
196+
assert.False(t, downstreamHit.Load())
197+
}

0 commit comments

Comments
 (0)