Skip to content

Commit deb5305

Browse files
authored
Merge pull request #87 from OpenVPN/fix/baseurl-validation-require-https
Implement base URL validation
2 parents a10437f + e69dfa4 commit deb5305

File tree

4 files changed

+237
-6
lines changed

4 files changed

+237
-6
lines changed

cloudconnexa/cloudconnexa.go

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"encoding/json"
77
"fmt"
88
"io"
9+
"net"
910
"net/http"
1011
"net/url"
1112
"strconv"
@@ -27,6 +28,96 @@ const (
2728
DefaultMaxTokenResponseSize int64 = 1 << 20
2829
)
2930

31+
// ClientOptions provides optional configuration for the API client.
32+
type ClientOptions struct {
33+
// AllowInsecureHTTP permits HTTP connections for loopback addresses only
34+
// (localhost, 127.0.0.1, ::1). This is intended for local development and testing.
35+
// WARNING: HTTP connections to non-loopback addresses are always rejected.
36+
AllowInsecureHTTP bool
37+
}
38+
39+
// validateBaseURL validates the base URL for the API client.
40+
// It ensures the URL is well-formed and uses HTTPS unless allowHTTP is true
41+
// and the host is a loopback address (per RFC 9700 OAuth security best practices).
42+
func validateBaseURL(rawURL string, allowHTTP bool) (string, error) {
43+
if rawURL == "" {
44+
return "", fmt.Errorf("%w: URL cannot be empty", ErrInvalidBaseURL)
45+
}
46+
47+
parsed, err := url.Parse(rawURL)
48+
if err != nil {
49+
return "", fmt.Errorf("%w: %v", ErrInvalidBaseURL, err)
50+
}
51+
52+
// Validate scheme is present
53+
if parsed.Scheme == "" {
54+
return "", fmt.Errorf("%w: URL must include scheme (e.g., https://)", ErrInvalidBaseURL)
55+
}
56+
57+
// Normalize scheme to lowercase
58+
scheme := strings.ToLower(parsed.Scheme)
59+
60+
// Validate host is present
61+
if parsed.Host == "" {
62+
return "", fmt.Errorf("%w: URL must include a host", ErrInvalidBaseURL)
63+
}
64+
65+
// Reject URLs with embedded credentials (security: prevents logging leaks)
66+
if parsed.User != nil {
67+
return "", fmt.Errorf("%w: URL must not contain credentials", ErrInvalidBaseURL)
68+
}
69+
70+
// Check scheme - only allow http or https
71+
switch scheme {
72+
case "https":
73+
// HTTPS is always allowed
74+
case "http":
75+
if !allowHTTP {
76+
return "", ErrHTTPSRequired
77+
}
78+
// Even with allowHTTP, only permit loopback addresses
79+
if !isLoopbackHost(parsed.Host) {
80+
return "", fmt.Errorf("%w: HTTP is only allowed for localhost/127.0.0.1/::1", ErrHTTPSRequired)
81+
}
82+
default:
83+
return "", fmt.Errorf("%w: unsupported scheme %q; only https is allowed", ErrInvalidBaseURL, scheme)
84+
}
85+
86+
// Normalize: strip path, query, fragment - only keep scheme://host
87+
normalized := fmt.Sprintf("%s://%s", scheme, parsed.Host)
88+
return normalized, nil
89+
}
90+
91+
// isLoopbackHost checks if the host is a loopback address.
92+
// This includes localhost, 127.0.0.0/8 range, and ::1.
93+
func isLoopbackHost(host string) bool {
94+
// Extract hostname without port using net.SplitHostPort for robustness
95+
hostname := host
96+
97+
// Try to split host and port
98+
if h, _, err := net.SplitHostPort(host); err == nil {
99+
hostname = h
100+
}
101+
102+
// Strip brackets from IPv6 (may remain if no port was present, e.g., "[::1]")
103+
hostname = strings.TrimPrefix(hostname, "[")
104+
hostname = strings.TrimSuffix(hostname, "]")
105+
hostname = strings.ToLower(hostname)
106+
107+
// Check common loopback names
108+
if hostname == "localhost" || hostname == "::1" {
109+
return true
110+
}
111+
112+
// Check 127.0.0.0/8 range
113+
ip := net.ParseIP(hostname)
114+
if ip != nil {
115+
return ip.IsLoopback()
116+
}
117+
118+
return false
119+
}
120+
30121
// Client represents a CloudConnexa API client with all service endpoints.
31122
type Client struct {
32123
client *http.Client
@@ -87,19 +178,36 @@ func (e ErrClientResponse) StatusCode() int { return e.status }
87178
func (e ErrClientResponse) Body() string { return e.body }
88179

89180
// NewClient creates a new CloudConnexa API client with the given credentials.
181+
// The baseURL must use HTTPS. For development with localhost HTTP, use NewClientWithOptions.
90182
// It authenticates using OAuth2 client credentials flow and returns a configured client.
91183
func NewClient(baseURL, clientID, clientSecret string) (*Client, error) {
184+
return NewClientWithOptions(baseURL, clientID, clientSecret, nil)
185+
}
186+
187+
// NewClientWithOptions creates a new CloudConnexa API client with custom options.
188+
// It authenticates using OAuth2 client credentials flow and returns a configured client.
189+
func NewClientWithOptions(baseURL, clientID, clientSecret string, opts *ClientOptions) (*Client, error) {
92190
if clientID == "" || clientSecret == "" {
93191
return nil, ErrCredentialsRequired
94192
}
95193

194+
allowHTTP := false
195+
if opts != nil {
196+
allowHTTP = opts.AllowInsecureHTTP
197+
}
198+
199+
normalizedURL, err := validateBaseURL(baseURL, allowHTTP)
200+
if err != nil {
201+
return nil, err
202+
}
203+
96204
values := map[string]string{"grant_type": "client_credentials", "scope": "default"}
97205
jsonData, err := json.Marshal(values)
98206
if err != nil {
99207
return nil, err
100208
}
101209

102-
tokenURL := fmt.Sprintf("%s/api/v1/oauth/token", strings.TrimRight(baseURL, "/"))
210+
tokenURL := fmt.Sprintf("%s/api/v1/oauth/token", normalizedURL)
103211
req, err := http.NewRequest(http.MethodPost, tokenURL, bytes.NewBuffer(jsonData))
104212

105213
if err != nil {
@@ -140,7 +248,7 @@ func NewClient(baseURL, clientID, clientSecret string) (*Client, error) {
140248

141249
c := &Client{
142250
client: httpClient,
143-
BaseURL: baseURL,
251+
BaseURL: normalizedURL,
144252
Token: credentials.AccessToken,
145253
UserAgent: userAgent,
146254
ReadRateLimiter: rate.NewLimiter(rate.Every(1*time.Second), 1),

cloudconnexa/cloudconnexa_test.go

Lines changed: 115 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,10 @@ func TestNewClient(t *testing.T) {
6060

6161
for _, tt := range tests {
6262
t.Run(tt.name, func(t *testing.T) {
63-
client, err := NewClient(tt.baseURL, tt.clientID, tt.clientSecret)
63+
// Use NewClientWithOptions with AllowInsecureHTTP for localhost test server
64+
client, err := NewClientWithOptions(tt.baseURL, tt.clientID, tt.clientSecret, &ClientOptions{
65+
AllowInsecureHTTP: true,
66+
})
6467

6568
if tt.wantErr {
6669
assert.Error(t, err, "NewClient should return an error for invalid credentials")
@@ -267,8 +270,118 @@ func TestNewClient_TokenResponseOverLimit(t *testing.T) {
267270
}))
268271
defer server.Close()
269272

270-
_, err := NewClient(server.URL, "client-id", "client-secret")
273+
_, err := NewClientWithOptions(server.URL, "client-id", "client-secret", &ClientOptions{
274+
AllowInsecureHTTP: true,
275+
})
271276

272277
assert.Error(t, err, "NewClient should fail for oversized OAuth response")
273278
assert.True(t, errors.Is(err, ErrResponseTooLarge), "Error should be ErrResponseTooLarge, got: %v", err)
274279
}
280+
281+
// TestValidateBaseURL tests the validateBaseURL function that validates base URL parameters.
282+
func TestValidateBaseURL(t *testing.T) {
283+
tests := []struct {
284+
name string
285+
url string
286+
allowHTTP bool
287+
wantErr error
288+
wantURL string
289+
}{
290+
// Valid HTTPS URLs
291+
{"valid https", "https://api.example.com", false, nil, "https://api.example.com"},
292+
{"https with port", "https://api.example.com:443", false, nil, "https://api.example.com:443"},
293+
{"https with path stripped", "https://api.example.com/v1/", false, nil, "https://api.example.com"},
294+
{"https trailing slash stripped", "https://api.example.com/", false, nil, "https://api.example.com"},
295+
296+
// Invalid: HTTP without allowHTTP
297+
{"http not allowed", "http://api.example.com", false, ErrHTTPSRequired, ""},
298+
299+
// Valid: HTTP with allowHTTP for loopback
300+
{"http localhost allowed", "http://localhost:8080", true, nil, "http://localhost:8080"},
301+
{"http 127.0.0.1 allowed", "http://127.0.0.1:8080", true, nil, "http://127.0.0.1:8080"},
302+
{"http 127.0.0.2 allowed", "http://127.0.0.2:9999", true, nil, "http://127.0.0.2:9999"},
303+
{"http ::1 allowed", "http://[::1]:8080", true, nil, "http://[::1]:8080"},
304+
305+
// Invalid: HTTP with allowHTTP for non-loopback
306+
{"http remote rejected even with allowHTTP", "http://api.example.com", true, ErrHTTPSRequired, ""},
307+
308+
// Invalid URLs
309+
{"empty url", "", false, ErrInvalidBaseURL, ""},
310+
{"missing scheme", "api.example.com", false, ErrInvalidBaseURL, ""},
311+
{"ftp scheme", "ftp://api.example.com", false, ErrInvalidBaseURL, ""},
312+
{"missing host", "https://", false, ErrInvalidBaseURL, ""},
313+
314+
// Security: credentials in URL rejected
315+
{"url with userinfo", "https://user:pass@api.example.com", false, ErrInvalidBaseURL, ""},
316+
}
317+
318+
for _, tt := range tests {
319+
t.Run(tt.name, func(t *testing.T) {
320+
result, err := validateBaseURL(tt.url, tt.allowHTTP)
321+
if tt.wantErr != nil {
322+
assert.Error(t, err)
323+
assert.True(t, errors.Is(err, tt.wantErr), "expected error %v, got %v", tt.wantErr, err)
324+
} else {
325+
assert.NoError(t, err)
326+
assert.Equal(t, tt.wantURL, result)
327+
}
328+
})
329+
}
330+
}
331+
332+
// TestIsLoopbackHost tests the isLoopbackHost function that checks for loopback addresses.
333+
func TestIsLoopbackHost(t *testing.T) {
334+
tests := []struct {
335+
host string
336+
expected bool
337+
}{
338+
{"localhost", true},
339+
{"localhost:8080", true},
340+
{"LOCALHOST", true},
341+
{"127.0.0.1", true},
342+
{"127.0.0.1:8080", true},
343+
{"127.0.0.2", true},
344+
{"127.255.255.255", true},
345+
{"[::1]", true},
346+
{"[::1]:8080", true},
347+
{"::1", true},
348+
{"api.example.com", false},
349+
{"192.168.1.1", false},
350+
{"10.0.0.1", false},
351+
}
352+
353+
for _, tt := range tests {
354+
t.Run(tt.host, func(t *testing.T) {
355+
result := isLoopbackHost(tt.host)
356+
assert.Equal(t, tt.expected, result)
357+
})
358+
}
359+
}
360+
361+
// TestNewClient_HTTPSRequired tests that NewClient rejects HTTP URLs by default.
362+
func TestNewClient_HTTPSRequired(t *testing.T) {
363+
_, err := NewClient("http://api.example.com", "id", "secret")
364+
assert.Error(t, err)
365+
assert.True(t, errors.Is(err, ErrHTTPSRequired), "expected ErrHTTPSRequired, got: %v", err)
366+
}
367+
368+
// TestNewClientWithOptions_AllowHTTPForLocalhost tests that NewClientWithOptions allows HTTP for localhost.
369+
func TestNewClientWithOptions_AllowHTTPForLocalhost(t *testing.T) {
370+
server := setupMockServer()
371+
defer server.Close()
372+
373+
client, err := NewClientWithOptions(server.URL, "test-id", "test-secret", &ClientOptions{
374+
AllowInsecureHTTP: true,
375+
})
376+
assert.NoError(t, err)
377+
assert.NotNil(t, client)
378+
}
379+
380+
// TestNewClientWithOptions_HTTPRemoteRejected tests that HTTP to non-localhost is always rejected.
381+
func TestNewClientWithOptions_HTTPRemoteRejected(t *testing.T) {
382+
_, err := NewClientWithOptions("http://api.example.com", "test-id", "test-secret", &ClientOptions{
383+
AllowInsecureHTTP: true,
384+
})
385+
assert.Error(t, err)
386+
assert.True(t, errors.Is(err, ErrHTTPSRequired), "expected ErrHTTPSRequired, got: %v", err)
387+
}

cloudconnexa/errors.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,9 @@ var ErrEmptyID = errors.New("id cannot be empty")
1010

1111
// ErrResponseTooLarge is returned when a response body exceeds the configured size limit.
1212
var ErrResponseTooLarge = errors.New("response body exceeds maximum allowed size")
13+
14+
// ErrInvalidBaseURL is returned when the base URL is malformed or cannot be parsed.
15+
var ErrInvalidBaseURL = errors.New("invalid base URL")
16+
17+
// ErrHTTPSRequired is returned when HTTP is used but HTTPS is required for security.
18+
var ErrHTTPSRequired = errors.New("HTTPS required: HTTP is not allowed for OAuth credentials")

cloudconnexa/vpn_regions_test.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ func TestVPNRegionsService_List(t *testing.T) {
4242
}))
4343
defer server.Close()
4444

45-
client, err := NewClient(server.URL, "test", "test")
45+
client, err := NewClientWithOptions(server.URL, "test", "test", &ClientOptions{
46+
AllowInsecureHTTP: true,
47+
})
4648
assert.NoError(t, err)
4749
regions, err := client.VPNRegions.List()
4850

@@ -73,7 +75,9 @@ func TestVPNRegionsService_GetByID(t *testing.T) {
7375
}))
7476
defer server.Close()
7577

76-
client, err := NewClient(server.URL, "test", "test")
78+
client, err := NewClientWithOptions(server.URL, "test", "test", &ClientOptions{
79+
AllowInsecureHTTP: true,
80+
})
7781
assert.NoError(t, err)
7882

7983
// Test existing region

0 commit comments

Comments
 (0)