Skip to content

Commit 139e9e4

Browse files
committed
update to fallback to insecure on bootstrap
1. loginViaCLI (the pen-test finding) — Implemented try-secure-then-fallback: attempts a TLS-verified connection first, and only falls back to insecure if a TLS verification error occurs (matching the existing pattern in client.go:LoginWithAccessKey). This is the only function that sends credentials (admin password), so it gets the strongest treatment. 2. Readiness probes (port_forwarding.go, pingLoftRouter, IsLoftReachable bootstrap callers, docker.go) — Reverted to insecure: true with explicit comments documenting why: these are bootstrap-only health checks against just-installed platforms with self-signed certs, and no credentials are transmitted.
1 parent 2784b3e commit 139e9e4

5 files changed

Lines changed: 214 additions & 29 deletions

File tree

pkg/cli/start/docker.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,8 @@ func (l *LoftStarter) successDocker(ctx context.Context, containerID string) err
133133
return false, fmt.Errorf("container failed (status: %s):\n %s", containerDetails.State.Status, logs)
134134
}
135135

136-
return clihelper.IsLoftReachable(ctx, host, l.LoadedConfig(l.Log).Platform.Insecure)
136+
// Bootstrap reachability check: self-signed cert expected.
137+
return clihelper.IsLoftReachable(ctx, host, true)
137138
})
138139
if err != nil {
139140
return fmt.Errorf(product.Replace("error waiting for loft: %v%w"), err)

pkg/cli/start/login.go

Lines changed: 54 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package start
33
import (
44
"bytes"
55
"crypto/tls"
6+
"crypto/x509"
67
"encoding/json"
78
"errors"
89
"fmt"
@@ -66,51 +67,87 @@ func (l *LoftStarter) loginViaCLI(url string) error {
6667
return err
6768
}
6869

70+
// Try a secure connection first. During bootstrap the platform typically
71+
// serves a self-signed certificate, so fall back to insecure if the secure
72+
// attempt fails with a TLS error.
73+
accessKey, insecure, err := l.passwordLogin(url, loginRequestBytes, false)
74+
if err != nil {
75+
if !isTLSError(err) {
76+
return err
77+
}
78+
l.Log.Infof("TLS verification failed, retrying without verification (self-signed certificate expected during bootstrap)")
79+
accessKey, insecure, err = l.passwordLogin(url, loginRequestBytes, true)
80+
if err != nil {
81+
return err
82+
}
83+
}
84+
85+
// log into loft
6986
config := l.LoadedConfig(l.Log)
87+
loginClient := platform.NewLoginClientFromConfig(config)
88+
url = strings.TrimSuffix(url, "/")
89+
err = loginClient.LoginWithAccessKey(url, accessKey, insecure)
90+
if err != nil {
91+
return err
92+
}
93+
94+
l.Log.WriteString(logrus.InfoLevel, "\n")
95+
l.Log.Donef(product.Replace("Successfully logged in via CLI into Loft instance %s"), ansi.Color(url, "white+b"))
96+
97+
return nil
98+
}
99+
100+
// passwordLogin posts the admin credentials to the platform's password login
101+
// endpoint and returns the access key. The insecure parameter controls whether
102+
// TLS certificate verification is skipped.
103+
func (l *LoftStarter) passwordLogin(url string, loginRequestBytes []byte, insecure bool) (string, bool, error) {
70104
httpClient := &http.Client{Transport: &http.Transport{
71-
TLSClientConfig: &tls.Config{InsecureSkipVerify: config.Platform.Insecure},
105+
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure},
72106
}}
73107

74-
// try a couple of times to login
75108
accessKey := &types.AccessKey{}
76109
for i := 0; i < 3; i++ {
77110
resp, err := httpClient.Post(url+"/auth/password/login", "application/json", bytes.NewBuffer(loginRequestBytes))
78111
if err != nil {
79-
return err
112+
return "", insecure, err
80113
}
81114

82115
body, err := io.ReadAll(resp.Body)
83116
if err != nil {
84117
_ = resp.Body.Close()
85-
return err
118+
return "", insecure, err
86119
}
87120
_ = resp.Body.Close()
88121

89122
err = json.Unmarshal(body, accessKey)
90123
if err != nil {
91-
return err
124+
return "", insecure, err
92125
}
93126
if accessKey.AccessKey == "" {
94127
continue
95128
}
96129
break
97130
}
98131
if accessKey.AccessKey == "" {
99-
return fmt.Errorf("couldn't retrieve access key from platform to login")
100-
}
101-
102-
// log into loft
103-
loginClient := platform.NewLoginClientFromConfig(config)
104-
url = strings.TrimSuffix(url, "/")
105-
err = loginClient.LoginWithAccessKey(url, accessKey.AccessKey, config.Platform.Insecure)
106-
if err != nil {
107-
return err
132+
return "", insecure, fmt.Errorf("couldn't retrieve access key from platform to login")
108133
}
109134

110-
l.Log.WriteString(logrus.InfoLevel, "\n")
111-
l.Log.Donef(product.Replace("Successfully logged in via CLI into Loft instance %s"), ansi.Color(url, "white+b"))
135+
return accessKey.AccessKey, insecure, nil
136+
}
112137

113-
return nil
138+
// isTLSError returns true if the error is caused by a TLS certificate
139+
// verification failure.
140+
func isTLSError(err error) bool {
141+
var urlErr *netUrl.Error
142+
if !errors.As(err, &urlErr) {
143+
return false
144+
}
145+
var certErr *tls.CertificateVerificationError
146+
if errors.As(urlErr.Err, &certErr) {
147+
return true
148+
}
149+
var unknownAuthErr x509.UnknownAuthorityError
150+
return errors.As(urlErr.Err, &unknownAuthErr)
114151
}
115152

116153
func (l *LoftStarter) loginUI(url string) error {

pkg/cli/start/login_test.go

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package start
2+
3+
import (
4+
"crypto/tls"
5+
"crypto/x509"
6+
"encoding/json"
7+
"net/http"
8+
"net/http/httptest"
9+
netUrl "net/url"
10+
"strings"
11+
"testing"
12+
13+
types "github.com/loft-sh/api/v4/pkg/auth"
14+
"gotest.tools/v3/assert"
15+
)
16+
17+
func TestIsTLSError(t *testing.T) {
18+
tests := []struct {
19+
name string
20+
err error
21+
expected bool
22+
}{
23+
{
24+
name: "nil error",
25+
err: nil,
26+
expected: false,
27+
},
28+
{
29+
name: "URL error with certificate verification error",
30+
err: &netUrl.Error{
31+
Op: "Get",
32+
URL: "https://localhost:9898",
33+
Err: &tls.CertificateVerificationError{
34+
UnverifiedCertificates: []*x509.Certificate{},
35+
Err: x509.UnknownAuthorityError{},
36+
},
37+
},
38+
expected: true,
39+
},
40+
{
41+
name: "URL error with unknown authority",
42+
err: &netUrl.Error{
43+
Op: "Get",
44+
URL: "https://localhost:9898",
45+
Err: x509.UnknownAuthorityError{},
46+
},
47+
expected: true,
48+
},
49+
{
50+
name: "URL error with non-TLS error",
51+
err: &netUrl.Error{
52+
Op: "Get",
53+
URL: "https://localhost:9898",
54+
Err: &netUrl.Error{Op: "dial", URL: "localhost", Err: nil},
55+
},
56+
expected: false,
57+
},
58+
}
59+
60+
for _, tt := range tests {
61+
t.Run(tt.name, func(t *testing.T) {
62+
if tt.err == nil {
63+
assert.Assert(t, !isTLSError(nil))
64+
return
65+
}
66+
assert.Equal(t, isTLSError(tt.err), tt.expected)
67+
})
68+
}
69+
}
70+
71+
func TestPasswordLogin_SecureSuccess(t *testing.T) {
72+
expectedKey := "test-access-key-123"
73+
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
74+
if r.URL.Path == "/auth/password/login" {
75+
resp := types.AccessKey{AccessKey: expectedKey}
76+
w.Header().Set("Content-Type", "application/json")
77+
_ = json.NewEncoder(w).Encode(resp)
78+
return
79+
}
80+
w.WriteHeader(http.StatusNotFound)
81+
}))
82+
defer server.Close()
83+
84+
// passwordLogin with insecure=true should succeed against self-signed cert.
85+
l := &LoftStarter{}
86+
key, insecure, err := l.passwordLogin(server.URL, []byte(`{}`), true)
87+
assert.NilError(t, err)
88+
assert.Equal(t, key, expectedKey)
89+
assert.Assert(t, insecure)
90+
}
91+
92+
func TestPasswordLogin_InsecureFalseFailsAgainstSelfSigned(t *testing.T) {
93+
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
94+
resp := types.AccessKey{AccessKey: "key"}
95+
_ = json.NewEncoder(w).Encode(resp)
96+
}))
97+
defer server.Close()
98+
99+
// passwordLogin with insecure=false should fail against self-signed cert.
100+
l := &LoftStarter{}
101+
_, _, err := l.passwordLogin(server.URL, []byte(`{}`), false)
102+
assert.Assert(t, err != nil, "expected TLS error")
103+
assert.Assert(t, isTLSError(err), "error should be a TLS error, got: %v", err)
104+
}
105+
106+
func TestPasswordLogin_FallbackBehavior(t *testing.T) {
107+
expectedKey := "fallback-key"
108+
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
109+
if r.URL.Path == "/auth/password/login" {
110+
resp := types.AccessKey{AccessKey: expectedKey}
111+
w.Header().Set("Content-Type", "application/json")
112+
_ = json.NewEncoder(w).Encode(resp)
113+
return
114+
}
115+
w.WriteHeader(http.StatusNotFound)
116+
}))
117+
defer server.Close()
118+
119+
l := &LoftStarter{}
120+
121+
// Simulate the try-secure-then-fallback pattern from loginViaCLI.
122+
key, insecure, err := l.passwordLogin(server.URL, []byte(`{}`), false)
123+
if err != nil && isTLSError(err) {
124+
// Fall back to insecure — this is the expected path for self-signed certs.
125+
key, insecure, err = l.passwordLogin(server.URL, []byte(`{}`), true)
126+
}
127+
assert.NilError(t, err)
128+
assert.Equal(t, key, expectedKey)
129+
assert.Assert(t, insecure, "should have fallen back to insecure")
130+
}
131+
132+
func TestPasswordLogin_EmptyAccessKey(t *testing.T) {
133+
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
134+
resp := types.AccessKey{AccessKey: ""}
135+
_ = json.NewEncoder(w).Encode(resp)
136+
}))
137+
defer server.Close()
138+
139+
l := &LoftStarter{}
140+
_, _, err := l.passwordLogin(server.URL, []byte(`{}`), true)
141+
assert.Assert(t, err != nil, "expected error for empty access key")
142+
assert.Assert(t, strings.Contains(err.Error(), "couldn't retrieve access key"))
143+
}

pkg/cli/start/port_forwarding.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@ func (l *LoftStarter) startPortForwarding(ctx context.Context, loftPod *corev1.P
1919
}
2020
go l.restartPortForwarding(ctx, stopChan)
2121

22-
// wait until loft is reachable at the given url
22+
// Bootstrap readiness probe over localhost port-forward: the just-installed
23+
// platform always serves a self-signed certificate, so TLS verification is
24+
// skipped. No credentials are sent in this request.
2325
httpClient := &http.Client{
24-
Transport: utilhttp.Transport(l.LoadedConfig(l.Log).Platform.Insecure),
26+
Transport: utilhttp.InsecureTransport(),
2527
}
2628
l.Log.Infof(product.Replace("Waiting until loft is reachable at https://localhost:%s"), l.LocalPort)
2729
err = wait.PollUntilContextTimeout(ctx, time.Second, clihelper.Timeout(), true, func(ctx context.Context) (bool, error) {

pkg/cli/start/success.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,9 @@ func (l *LoftStarter) success(ctx context.Context) error {
6767
return err
6868
}
6969

70-
// check if loft is reachable
71-
insecure := l.LoadedConfig(l.Log).Platform.Insecure
72-
reachable, err := clihelper.IsLoftReachable(ctx, host, insecure)
70+
// Bootstrap reachability check: the just-installed platform typically serves
71+
// a self-signed certificate, so TLS verification is skipped.
72+
reachable, err := clihelper.IsLoftReachable(ctx, host, true)
7373
if !reachable || err != nil {
7474
const (
7575
YesOption = "Yes"
@@ -123,12 +123,13 @@ func (l *LoftStarter) pingLoftRouter(ctx context.Context, loftPod *corev1.Pod) (
123123
// get the domain from secret
124124
loftRouterDomain := string(loftRouterSecret.Data["domain"])
125125

126-
// wait until loft is reachable at the given url
127-
insecure := l.LoadedConfig(l.Log).Platform.Insecure
126+
// Bootstrap readiness probe: the just-installed platform always serves a
127+
// self-signed certificate, so TLS verification is skipped. No credentials
128+
// are sent in this request.
128129
httpClient := &http.Client{
129130
Transport: &http.Transport{
130131
TLSClientConfig: &tls.Config{
131-
InsecureSkipVerify: insecure,
132+
InsecureSkipVerify: true,
132133
},
133134
},
134135
}
@@ -192,8 +193,9 @@ func (l *LoftStarter) isLoggedIn(url string) bool {
192193
}
193194

194195
func (l *LoftStarter) successRemote(ctx context.Context, host string) error {
195-
insecure := l.LoadedConfig(l.Log).Platform.Insecure
196-
ready, err := clihelper.IsLoftReachable(ctx, host, insecure)
196+
// Bootstrap reachability check: the just-installed platform typically serves
197+
// a self-signed certificate, so TLS verification is skipped.
198+
ready, err := clihelper.IsLoftReachable(ctx, host, true)
197199
if err != nil {
198200
return err
199201
} else if ready {
@@ -206,7 +208,7 @@ func (l *LoftStarter) successRemote(ctx context.Context, host string) error {
206208

207209
l.Log.Info("Waiting for you to configure DNS, so loft can be reached on https://" + host)
208210
err = wait.PollUntilContextTimeout(ctx, 5*time.Second, clihelper.Timeout(), true, func(ctx context.Context) (done bool, err error) {
209-
return clihelper.IsLoftReachable(ctx, host, insecure)
211+
return clihelper.IsLoftReachable(ctx, host, true)
210212
})
211213
if err != nil {
212214
return err

0 commit comments

Comments
 (0)