Skip to content

Commit 634d866

Browse files
committed
feat(ui): add opt-in protection for the DevSpace UI
- add token-based auth for sensitive UI routes and browser handoff - wire the UI client to persist and send the auth token on protected requests - add `--protect-ui` so auth and sensitive var redaction are opt-in and the default UI behavior stays unchanged - expose the UI protection mode in server discovery and reuse only matching local UI servers - cover protected and unprotected server behavior with tests Signed-off-by: Ryan Swanson <ryan.swanson@loft.sh>
1 parent c79950b commit 634d866

13 files changed

Lines changed: 693 additions & 54 deletions

File tree

assets/assets.go

Lines changed: 5 additions & 5 deletions
Large diffs are not rendered by default.

cmd/flags/flags.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ type GlobalFlags struct {
1111
NoWarn bool
1212
NoColors bool
1313
Debug bool
14+
ProtectUI bool
1415
DisableProfileActivation bool
1516
SwitchContext bool
1617
InactivityTimeout int
@@ -48,6 +49,7 @@ func SetGlobalFlags(flags *flag.FlagSet) *GlobalFlags {
4849
flags.BoolVar(&globalFlags.NoWarn, "no-warn", false, "If true does not show any warning when deploying into a different namespace or kube-context than before")
4950
flags.BoolVar(&globalFlags.NoColors, "no-colors", false, "Do not show color highlighting in log output. This avoids invisible output with different terminal background colors")
5051
flags.BoolVar(&globalFlags.Debug, "debug", false, "Prints the stack trace if an error occurs")
52+
flags.BoolVar(&globalFlags.ProtectUI, "protect-ui", false, "Enable UI protections such as auth checks for sensitive routes and redaction of sensitive config values")
5153
flags.BoolVar(&globalFlags.Silent, "silent", false, "Run in silent mode and prevents any devspace log output except panics & fatals")
5254

5355
flags.StringSliceVarP(&globalFlags.Profiles, "profile", "p", []string{}, "The DevSpace profiles to apply. Multiple profiles are applied in the order they are specified")

cmd/run_pipeline.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -459,7 +459,7 @@ func runPipeline(ctx devspacecontext.Context, args []string, options *CommandOpt
459459
})
460460

461461
// start ui & open
462-
serv, err := dev.UI(ctx, options.UIPort, options.ShowUI, pipe)
462+
serv, err := dev.UI(ctx, options.UIPort, options.ShowUI, options.ProtectUI, pipe)
463463
if err != nil {
464464
return err
465465
}

cmd/ui.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -121,9 +121,9 @@ func (cmd *UICmd) RunUI(f factory.Factory) error {
121121
continue
122122
}
123123

124-
if serverVersion.DevSpace {
124+
if serverVersion.DevSpace && serverVersion.Protected == cmd.ProtectUI {
125125
cmd.log.Infof("Found running UI server at %s", domain)
126-
_ = open.Start(domain)
126+
_ = open.Start(server.BrowserURL(fmt.Sprintf("%s:%d", cmd.Host, checkPort)))
127127
return nil
128128
}
129129

@@ -190,17 +190,17 @@ func (cmd *UICmd) RunUI(f factory.Factory) error {
190190
}
191191

192192
// Create server
193-
server, err := server.NewServer(ctx, cmd.Host, cmd.Dev, forcePort, nil)
193+
server, err := server.NewServer(ctx, cmd.Host, cmd.Dev, forcePort, nil, cmd.ProtectUI)
194194
if err != nil {
195195
return err
196196
}
197197

198198
// Open the browser
199199
if !cmd.Dev {
200-
go func(domain string) {
200+
go func(url string) {
201201
time.Sleep(time.Second * 2)
202-
_ = open.Start("http://" + domain)
203-
}(server.Server.Addr)
202+
_ = open.Start(url)
203+
}(server.BrowserURL())
204204
}
205205

206206
cmd.log.Infof("Start listening on http://%s", server.Server.Addr)

pkg/devspace/dev/dev.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,15 @@ import (
99
"github.com/sirupsen/logrus"
1010
)
1111

12-
func UI(ctx devspacecontext.Context, port int, showUI bool, pipeline types.Pipeline) (*server.Server, error) {
12+
func UI(ctx devspacecontext.Context, port int, showUI bool, protectUI bool, pipeline types.Pipeline) (*server.Server, error) {
1313
var defaultPort *int
1414
if port != 0 {
1515
defaultPort = &port
1616
}
1717

1818
// Create server
1919
uiLogger := log.GetFileLogger("ui")
20-
serv, err := server.NewServer(ctx.WithLogger(uiLogger), "localhost", false, defaultPort, pipeline)
20+
serv, err := server.NewServer(ctx.WithLogger(uiLogger), "localhost", false, defaultPort, pipeline, protectUI)
2121
if err != nil {
2222
ctx.Log().Warnf("Couldn't start UI server: %v", err)
2323
} else {
@@ -33,7 +33,7 @@ func UI(ctx devspacecontext.Context, port int, showUI bool, pipeline types.Pipel
3333

3434
if showUI {
3535
ctx.Log().WriteString(logrus.InfoLevel, "\n#########################################################\n")
36-
ctx.Log().Infof("DevSpace UI available at: %s", ansi.Color("http://"+serv.Server.Addr, "white+b"))
36+
ctx.Log().Infof("DevSpace UI available at: %s", ansi.Color(serv.BrowserURL(), "white+b"))
3737
ctx.Log().WriteString(logrus.InfoLevel, "#########################################################\n\n")
3838
}
3939
}

pkg/devspace/server/auth.go

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
package server
2+
3+
import (
4+
"crypto/rand"
5+
"crypto/subtle"
6+
"encoding/hex"
7+
"net/http"
8+
"net/url"
9+
"os"
10+
"path/filepath"
11+
"strings"
12+
13+
"github.com/loft-sh/devspace/pkg/devspace/config/constants"
14+
15+
"github.com/mitchellh/go-homedir"
16+
)
17+
18+
const (
19+
authCookieName = "devspace-ui-session"
20+
authHeaderName = "X-DevSpace-UI-Token"
21+
authQueryParam = "devspace-ui-token"
22+
authTokenFolder = "sessions"
23+
)
24+
25+
func generateAuthToken() (string, error) {
26+
token := make([]byte, 32)
27+
_, err := rand.Read(token)
28+
if err != nil {
29+
return "", err
30+
}
31+
32+
return hex.EncodeToString(token), nil
33+
}
34+
35+
func authMiddleware(h *handler, next http.Handler) http.Handler {
36+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
37+
if !h.isAuthorized(r) {
38+
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
39+
return
40+
}
41+
42+
next.ServeHTTP(w, r)
43+
})
44+
}
45+
46+
func (h *handler) index(w http.ResponseWriter, r *http.Request) {
47+
if !h.authorizeIndexRequest(w, r) {
48+
return
49+
}
50+
51+
http.ServeFile(w, r, filepath.Join(h.path, "index.html"))
52+
}
53+
54+
func (h *handler) authorizeIndexRequest(w http.ResponseWriter, r *http.Request) bool {
55+
if !h.protectUI {
56+
return true
57+
}
58+
59+
if token := r.URL.Query().Get(authQueryParam); token != "" {
60+
if !tokensEqual(token, h.authToken) {
61+
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
62+
return false
63+
}
64+
65+
h.setAuthCookie(w)
66+
67+
redirectURL := *r.URL
68+
query := redirectURL.Query()
69+
query.Del(authQueryParam)
70+
redirectURL.RawQuery = query.Encode()
71+
if redirectURL.Path == "" {
72+
redirectURL.Path = "/"
73+
}
74+
75+
http.Redirect(w, r, redirectURL.String(), http.StatusTemporaryRedirect)
76+
return false
77+
}
78+
79+
if h.isAuthorized(r) {
80+
return true
81+
}
82+
83+
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
84+
return false
85+
}
86+
87+
func (h *handler) setAuthCookie(w http.ResponseWriter) {
88+
http.SetCookie(w, &http.Cookie{
89+
Name: authCookieName,
90+
Value: h.authToken,
91+
HttpOnly: true,
92+
Path: "/",
93+
SameSite: http.SameSiteStrictMode,
94+
})
95+
}
96+
97+
func (h *handler) isAuthorized(r *http.Request) bool {
98+
if cookie, err := r.Cookie(authCookieName); err == nil && tokensEqual(cookie.Value, h.authToken) {
99+
return true
100+
}
101+
102+
if token := r.Header.Get(authHeaderName); tokensEqual(token, h.authToken) {
103+
return true
104+
}
105+
106+
authorization := r.Header.Get("Authorization")
107+
if strings.HasPrefix(strings.ToLower(authorization), "bearer ") && tokensEqual(strings.TrimSpace(authorization[7:]), h.authToken) {
108+
return true
109+
}
110+
111+
return tokensEqual(r.URL.Query().Get(authQueryParam), h.authToken)
112+
}
113+
114+
func tokensEqual(left, right string) bool {
115+
if left == "" || right == "" {
116+
return false
117+
}
118+
119+
return subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1
120+
}
121+
122+
func (s *Server) BrowserURL() string {
123+
return browserURL(s.Server.Addr, s.authToken)
124+
}
125+
126+
func BrowserURL(addr string) string {
127+
token, err := readAuthToken(addr)
128+
if err != nil {
129+
return browserURL(addr, "")
130+
}
131+
132+
return browserURL(addr, token)
133+
}
134+
135+
func browserURL(addr, token string) string {
136+
u := &url.URL{
137+
Scheme: "http",
138+
Host: addr,
139+
Path: "/",
140+
}
141+
if token != "" {
142+
query := u.Query()
143+
query.Set(authQueryParam, token)
144+
u.RawQuery = query.Encode()
145+
}
146+
147+
return u.String()
148+
}
149+
150+
func persistAuthToken(addr, token string) error {
151+
path, err := authTokenPath(addr)
152+
if err != nil {
153+
return err
154+
}
155+
156+
err = os.MkdirAll(filepath.Dir(path), 0700)
157+
if err != nil {
158+
return err
159+
}
160+
161+
return os.WriteFile(path, []byte(token), 0600)
162+
}
163+
164+
func readAuthToken(addr string) (string, error) {
165+
path, err := authTokenPath(addr)
166+
if err != nil {
167+
return "", err
168+
}
169+
170+
token, err := os.ReadFile(path)
171+
if err != nil {
172+
return "", err
173+
}
174+
175+
return strings.TrimSpace(string(token)), nil
176+
}
177+
178+
func removeAuthToken(addr string) error {
179+
path, err := authTokenPath(addr)
180+
if err != nil {
181+
return err
182+
}
183+
184+
err = os.Remove(path)
185+
if err != nil && !os.IsNotExist(err) {
186+
return err
187+
}
188+
189+
return nil
190+
}
191+
192+
func authTokenPath(addr string) (string, error) {
193+
home, err := homedir.Dir()
194+
if err != nil {
195+
return "", err
196+
}
197+
198+
replacer := strings.NewReplacer(":", "_", "/", "_", "\\", "_")
199+
return filepath.Join(home, constants.DefaultHomeDevSpaceFolder, UITempFolder, authTokenFolder, replacer.Replace(addr)+".token"), nil
200+
}

0 commit comments

Comments
 (0)