Skip to content

Commit b8ce0a0

Browse files
committed
Validate configured hosts before use
Read complete environment credentials without touching the config directory. Reject non-HTTP hosts from environment variables or stored profiles before request setup.
1 parent 0c25135 commit b8ce0a0

2 files changed

Lines changed: 99 additions & 17 deletions

File tree

components/configuration/hardening_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,66 @@ func TestReadConfig_rejectsInvalidHost(t *testing.T) {
9999
t.Errorf("error should mention invalid host, got: %v", err)
100100
}
101101
}
102+
103+
func TestReadConfig_EnvironmentDoesNotRequireConfigDirectory(t *testing.T) {
104+
configRoot := filepath.Join(t.TempDir(), "not-a-directory")
105+
if err := os.WriteFile(configRoot, []byte("occupied"), 0600); err != nil {
106+
t.Fatal(err)
107+
}
108+
t.Setenv("XDG_CONFIG_HOME", configRoot)
109+
t.Setenv("OP_CLI_HOST", "https://env.example.com")
110+
t.Setenv("OP_CLI_TOKEN", "env-token")
111+
112+
host, token, err := configuration.ReadConfig("default")
113+
if err != nil {
114+
t.Fatalf("ReadConfig with environment credentials: %v", err)
115+
}
116+
if host != "https://env.example.com" || token != "env-token" {
117+
t.Errorf("got host %q token %q", host, token)
118+
}
119+
}
120+
121+
func TestReadConfig_rejectsUnsupportedEnvironmentScheme(t *testing.T) {
122+
setupTempConfig(t)
123+
t.Setenv("OP_CLI_HOST", "ftp://env.example.com")
124+
t.Setenv("OP_CLI_TOKEN", "env-token")
125+
126+
_, _, err := configuration.ReadConfig("default")
127+
if err == nil {
128+
t.Fatal("expected error for unsupported environment host scheme")
129+
}
130+
if !strings.Contains(err.Error(), "only http and https") {
131+
t.Errorf("error should describe supported schemes, got: %v", err)
132+
}
133+
if !strings.Contains(err.Error(), "OP_CLI_HOST") {
134+
t.Errorf("error should name OP_CLI_HOST as the source, got: %v", err)
135+
}
136+
}
137+
138+
// Scheme comparison must be case-insensitive: URL schemes are defined as
139+
// case-insensitive, and hosts pasted from other tools may be upper-cased.
140+
func TestReadConfig_acceptsMixedCaseScheme(t *testing.T) {
141+
setupTempConfig(t)
142+
writeRaw(t, "[default]\nhost = HTTPS://file.example.com\ntoken = file-token\n")
143+
144+
host, token, err := configuration.ReadConfig("default")
145+
if err != nil {
146+
t.Fatalf("ReadConfig with mixed-case scheme: %v", err)
147+
}
148+
if host != "HTTPS://file.example.com" || token != "file-token" {
149+
t.Errorf("got host %q token %q", host, token)
150+
}
151+
}
152+
153+
func TestReadConfig_rejectsUnsupportedProfileScheme(t *testing.T) {
154+
setupTempConfig(t)
155+
writeRaw(t, "[default]\nhost = ftp://file.example.com\ntoken = file-token\n")
156+
157+
_, _, err := configuration.ReadConfig("default")
158+
if err == nil {
159+
t.Fatal("expected error for unsupported profile host scheme")
160+
}
161+
if !strings.Contains(err.Error(), "only http and https") {
162+
t.Errorf("error should describe supported schemes, got: %v", err)
163+
}
164+
}

components/configuration/profiles.go

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -58,15 +58,21 @@ func ValidateProfileName(name string) error {
5858
return nil
5959
}
6060

61-
// ReadConfig returns host and token for profile.
62-
// OP_CLI_HOST and OP_CLI_TOKEN always take precedence over the file.
61+
// ReadConfig returns the host and token for profile. Environment credentials
62+
// take precedence and do not require the config directory to be accessible.
6363
func ReadConfig(profile string) (host, token string, err error) {
64-
if err = ensureConfigDir(); err != nil {
65-
return "", "", err
66-
}
6764
if ok, h, t := readEnvironment(); ok {
65+
if err := validateHost(h); err != nil {
66+
return "", "", errors.Custom(fmt.Sprintf(
67+
"environment variable OP_CLI_HOST has an invalid host %q: %v",
68+
h, err,
69+
))
70+
}
6871
return h, t, nil
6972
}
73+
if err = ensureConfigDir(); err != nil {
74+
return "", "", err
75+
}
7076
return readConfigForProfile(profile)
7177
}
7278

@@ -245,12 +251,26 @@ func (f *iniFile) marshal() []byte {
245251
return []byte(sb.String())
246252
}
247253

248-
// looksLikeHost reports whether s parses as an absolute URL with a scheme and
249-
// host. Used to distinguish a genuine old-format "host token" line from a
250-
// corrupt file, so garbage is not silently migrated into bogus credentials.
254+
// looksLikeHost reports whether s is an absolute HTTP(S) URL. It distinguishes
255+
// the legacy "host token" format from corrupt configuration content.
251256
func looksLikeHost(s string) bool {
257+
return validateHost(s) == nil
258+
}
259+
260+
// validateHost rejects relative URLs and schemes the HTTP client cannot use.
261+
func validateHost(s string) error {
252262
u, err := url.Parse(s)
253-
return err == nil && u.Scheme != "" && u.Host != ""
263+
if err != nil {
264+
return fmt.Errorf("invalid host URL: %w", err)
265+
}
266+
if u.Scheme == "" || u.Host == "" {
267+
return fmt.Errorf("expected an absolute URL like https://example.openproject.com")
268+
}
269+
scheme := strings.ToLower(u.Scheme)
270+
if scheme != "http" && scheme != "https" {
271+
return fmt.Errorf("unsupported protocol scheme %q: only http and https are allowed", u.Scheme)
272+
}
273+
return nil
254274
}
255275

256276
// readOrMigrate reads the config file and migrates it from the old
@@ -360,14 +380,13 @@ func readConfigForProfile(profile string) (host, token string, err error) {
360380
if f.hasSection(profile) && (host == "" || token == "") {
361381
return "", "", invalidConfigError()
362382
}
363-
// Reject junk hosts here with a clear message; url.Parse alone accepts
364-
// almost anything as a relative URL, which would surface later as a
365-
// confusing request failure.
366-
if host != "" && !looksLikeHost(host) {
367-
return "", "", errors.Custom(fmt.Sprintf(
368-
"profile %q has an invalid host %q: expected an absolute URL like https://example.openproject.com",
369-
profile, host,
370-
))
383+
if host != "" {
384+
if err := validateHost(host); err != nil {
385+
return "", "", errors.Custom(fmt.Sprintf(
386+
"profile %q has an invalid host %q: %v",
387+
profile, host, err,
388+
))
389+
}
371390
}
372391
return host, token, nil
373392
}

0 commit comments

Comments
 (0)