Emil Lundberg пре 2 година
родитељ
комит
8294870ffc

+ 46 - 12
gui/default/index.html

@@ -72,7 +72,7 @@
           <img class="logo hidden-xs" src="assets/img/logo-horizontal.svg" height="32" width="117" alt=""/>
           <img class="logo hidden visible-xs" src="assets/img/favicon-default.png" height="32" alt=""/>
         </span>
-        <p class="navbar-text hidden-xs" ng-class="{'hidden-sm':upgradeInfo && upgradeInfo.newer}">{{thisDeviceName()}}</p>
+        <p ng-if="authenticated" class="navbar-text hidden-xs" ng-class="{'hidden-sm':upgradeInfo && upgradeInfo.newer}">{{thisDeviceName()}}</p>
         <ul class="nav navbar-nav navbar-right">
           <li ng-if="upgradeInfo && upgradeInfo.newer" class="upgrade-newer">
             <button type="button" class="btn navbar-btn btn-primary btn-sm" data-toggle="modal" data-target="#upgrade">
@@ -109,21 +109,25 @@
               <li><a href="" ng-click="about.show()"><span class="fa fa-fw fa-heart"></span>&nbsp;<span translate>About</span></a></li>
             </ul>
           </li>
-          <li class="dropdown action-menu">
+          <li ng-if="authenticated || config.gui.debugging" class="dropdown action-menu">
             <a href="#" class="dropdown-toggle" data-toggle="dropdown" aria-expanded="false">
               <span class="fa fa-cog"></span>
               <span class="hidden-xs" translate>Actions</span>
               <span class="caret"></span>
             </a>
             <ul class="dropdown-menu">
-              <li><a href="" ng-click="showSettings()"><span class="fa fa-fw fa-cog"></span>&nbsp;<span translate>Settings</span></a></li>
-              <li><a href="" ng-click="showDeviceIdentification(thisDevice())"><span class="fa fa-fw fa-qrcode"></span>&nbsp;<span translate>Show ID</span></a></li>
-              <li class="divider" aria-hidden="true"></li>
-              <li><a href="" ng-click="shutdown()"><span class="fa fa-fw fa-power-off"></span>&nbsp;<span translate>Shutdown</span></a></li>
-              <li><a href="" ng-click="restart()"><span class="fa fa-fw fa-refresh"></span>&nbsp;<span translate>Restart</span></a></li>
-              <li class="divider" aria-hidden="true"></li>
-              <li><a href="" ng-click="advanced()"><span class="fa fa-fw fa-cogs"></span>&nbsp;<span translate>Advanced</span></a></li>
-              <li><a href="" ng-click="logging.show()"><span class="fa fa-fw fa-wrench"></span>&nbsp;<span translate>Logs</span></a></li>
+              <li ng-if="authenticated"><a href="" ng-click="showSettings()"><span class="fa fa-fw fa-cog"></span>&nbsp;<span translate>Settings</span></a></li>
+              <li ng-if="authenticated"><a href="" ng-click="showDeviceIdentification(thisDevice())"><span class="fa fa-fw fa-qrcode"></span>&nbsp;<span translate>Show ID</span></a></li>
+
+              <li ng-if="authenticated" class="divider" aria-hidden="true"></li>
+              <li ng-if="authenticated"><a href="" ng-click="shutdown()"><span class="fa fa-fw fa-power-off"></span>&nbsp;<span translate>Shutdown</span></a></li>
+              <li ng-if="authenticated"><a href="" ng-click="restart()"><span class="fa fa-fw fa-refresh"></span>&nbsp;<span translate>Restart</span></a></li>
+
+              <li ng-if="authenticated" class="divider" aria-hidden="true"></li>
+              <li ng-if="authenticated"><a href="" ng-click="advanced()"><span class="fa fa-fw fa-cogs"></span>&nbsp;<span translate>Advanced</span></a></li>
+              <li ng-if="authenticated"><a href="" ng-click="logging.show()"><span class="fa fa-fw fa-wrench"></span>&nbsp;<span translate>Logs</span></a></li>
+              <li ng-if="authenticated"><a href="" ng-click="logout()"><span class="far fa-fw fa-ban"></span>&nbsp;<span translate>Log Out</span></a></li>
+
               <li class="divider" aria-hidden="true" ng-if="config.gui.debugging"></li>
               <li><a href="/rest/debug/support" target="_blank" ng-if="config.gui.debugging"><span class="fa fa-fw fa-user-md"></span>&nbsp;<span translate>Support Bundle</span></a></li>
             </ul>
@@ -338,9 +342,39 @@
         </div>
       </div>
 
-      <!-- First regular row -->
+      <!-- Login form -->
+      <div ng-if="!authenticated" class="center-block">
+        <h3 translate>Authentication Required</h3>
 
-      <div class="row">
+        <form ng-submit="authenticatePassword()">
+          <div class="form-group">
+            <label for="user" translate>User</label>
+            <input id="user" class="form-control" type="text" name="user" ng-model="login.username" autofocus required autocomplete="username" />
+          </div>
+
+          <div class="form-group">
+            <label for="password" translate>Password</label>
+            <input id="password" class="form-control" type="password" name="password" ng-model="login.password" ng-trim="false" autocomplete="current-password" />
+          </div>
+
+          <div class="row">
+            <div class="col-md-9 login-form-messages">
+              <p ng-if="login.errors.badLogin" class="text-danger" translate>
+                Incorrect user name or password.
+              </p>
+              <p ng-if="login.errors.failed" class="text-danger" translate>
+                Login failed, see Syncthing logs for details.
+              </p>
+            </div>
+            <div class="col-md-3 text-right">
+              <button type="submit" class="btn btn-default" ng-disabled="login.inProgress" translate>Log In</button>
+            </div>
+          </div>
+        </form>
+      </div>
+
+      <!-- First regular row -->
+      <div ng-if="authenticated" class="row">
 
         <!-- Folder list (top left) -->
 

+ 13 - 5
gui/default/syncthing/app.js

@@ -16,13 +16,9 @@ var syncthing = angular.module('syncthing', [
 ]);
 
 var urlbase = 'rest';
+var authUrlbase = urlbase + '/noauth/auth';
 
 syncthing.config(function ($httpProvider, $translateProvider, LocaleServiceProvider) {
-    var deviceIDShort = metadata.deviceID.substr(0, 5);
-    $httpProvider.defaults.xsrfHeaderName = 'X-CSRF-Token-' + deviceIDShort;
-    $httpProvider.defaults.xsrfCookieName = 'CSRF-Token-' + deviceIDShort;
-    $httpProvider.useApplyAsync(true);
-
     // language and localisation
 
     $translateProvider.useSanitizeValueStrategy('escape');
@@ -33,6 +29,18 @@ syncthing.config(function ($httpProvider, $translateProvider, LocaleServiceProvi
 
     LocaleServiceProvider.setAvailableLocales(validLangs);
     LocaleServiceProvider.setDefaultLocale('en');
+
+    $httpProvider.useApplyAsync(true);
+
+    if (!window.metadata) {
+        // Most likely we're not authenticated yet, in which case we can't proceed with the rest of the setup.
+        // Do nothing and wait for the page reload on successful login.
+        return;
+    }
+
+    var deviceIDShort = metadata.deviceID.substr(0, 5);
+    $httpProvider.defaults.xsrfHeaderName = 'X-CSRF-Token-' + deviceIDShort;
+    $httpProvider.defaults.xsrfCookieName = 'CSRF-Token-' + deviceIDShort;
 });
 
 // @TODO: extract global level functions into separate service(s)

+ 10 - 3
gui/default/syncthing/core/aboutModalView.html

@@ -2,17 +2,21 @@
   <div class="modal-body">
     <h1 class="text-center">
       <img alt="Syncthing" src="assets/img/logo-horizontal.svg" style="max-width: 366px; vertical-align: -16px" />
-      <br />
+    </h1>
+    <h1 ng-if="version.version" class="text-center">
       <small>{{versionString()}}</small>
       <br />
       <small><i>"{{version.codename}}"</i></small>
     </h1>
-    <p class="text-center">
+    <p ng-if="version.version" class="text-center">
       Build {{version.date | date:"yyyy-MM-dd"}}
       <span ng-if="version.tags.length">({{version.tags.join(", ")}})</span>
       <br />
       Copyright &copy; 2014-{{version.date | date:"yyyy"}} the Syncthing Authors.
     </p>
+    <p ng-if="!version.version" class="text-center" translate>
+      Log in to see version information.
+    </p>
     <p class="text-center" translate>Syncthing is Free and Open Source Software licensed as MPL v2.0.</p>
 
     <ul class="nav nav-tabs">
@@ -81,7 +85,10 @@ Jakob Borg, Audrius Butkevicius, Jesse Lucas, Simon Frei, Tomasz Wilczyński, Al
       </div>
 
       <div id="about-paths" class="tab-pane">
-        <table class="table table-striped table-auto">
+        <p ng-if="!authenticated" translate>
+          Log in to see paths information.
+        </p>
+        <table ng-if="authenticated" class="table table-striped table-auto">
           <caption><label translate>Internally used paths:</label></caption>
           <tbody>
             <tr>

+ 7 - 1
gui/default/syncthing/core/eventService.js

@@ -38,7 +38,13 @@ angular.module('syncthing.core')
                 .error(errorFn);
         }
 
-        function errorFn(dummy) {
+        function errorFn(statusString, status) {
+            if (status === 403) {
+                // Auth error - reload login page
+                location.reload();
+                return;
+            }
+
             $rootScope.$broadcast(self.OFFLINE);
 
             $timeout(function () {

+ 49 - 0
gui/default/syncthing/core/syncthingController.js

@@ -14,12 +14,26 @@ angular.module('syncthing.core')
 
         function initController() {
             LocaleService.autoConfigLocale();
+
+            if (!$scope.authenticated) {
+                // Can't proceed yet - wait for the page reload after successful login.
+                return;
+            }
+
             setInterval($scope.refresh, 10000);
             Events.start();
         }
 
         // public/scope definitions
 
+        // window.metadata is set in /meta.js which requires authentication
+        $scope.authenticated = window.metadata && window.metadata.authenticated;
+
+        $scope.login = {
+            username: '',
+            password: '',
+            errors: {},
+        };
         $scope.completion = {};
         $scope.config = {};
         $scope.configInSync = true;
@@ -83,6 +97,35 @@ angular.module('syncthing.core')
             files: 0
         };
 
+        $scope.authenticatePassword = function () {
+            $scope.login.inProgress = true;
+            $scope.login.errors = {};
+            $http.post(authUrlbase + '/password', {
+              username: $scope.login.username,
+              password: $scope.login.password,
+            }).then(function () {
+                location.reload();
+            }).catch(function (response) {
+                if (response.status === 403) {
+                    $scope.login.errors.badLogin = true;
+                } else {
+                    $scope.login.errors.failed = true;
+                    console.log('Password authentication failed:', response);
+                }
+            }).finally(function () {
+                $scope.login.inProgress = false;
+            });
+        };
+
+        $scope.logout = function() {
+            $http.post(authUrlbase + '/logout', {})
+            .then(function () {
+                location.reload();
+            }).catch(function (response) {
+                console.log('Failed to log out:', response);
+            });
+        };
+
         $(window).bind('beforeunload', function () {
             navigatingAway = true;
         });
@@ -183,6 +226,9 @@ angular.module('syncthing.core')
                 if (arg.status === 0) {
                     // A network error, not an HTTP error
                     $scope.$emit(Events.OFFLINE);
+                } else if (arg.status === 403) {
+                    // Auth error - reload login page
+                    location.reload();
                 } else if (arg.status >= 400 && arg.status <= 599 && arg.status != 501) {
                     // A genuine HTTP error. 501/NotImplemented is considered intentional
                     // and not an error which we need to act upon.
@@ -3119,6 +3165,9 @@ angular.module('syncthing.core')
 
         $scope.docsURL = function (path) {
             var url = 'https://docs.syncthing.net';
+            if (!$scope.versionBase()) {
+                return url;
+            }
             if (!path) {
                 // Undefined or null should become a valid string.
                 path = '';

+ 4 - 4
gui/default/syncthing/settings/settingsModalView.html

@@ -128,14 +128,14 @@
           <div class="row">
             <div class="col-md-6">
               <div class="form-group">
-                <label translate for="User">GUI Authentication User</label>
-                <input id="User" class="form-control" type="text" ng-model="tmpGUI.user" autocomplete="off" />
+                <label translate for="user">GUI Authentication User</label>
+                <input id="user" class="form-control" type="text" name="user" ng-model="tmpGUI.user" autocomplete="username" />
               </div>
             </div>
             <div class="col-md-6">
               <div class="form-group">
-                <label translate for="Password">GUI Authentication Password</label>
-                <input id="Password" class="form-control" type="password" ng-model="tmpGUI.password" ng-trim="false" autocomplete="new-password" />
+                <label translate for="password">GUI Authentication Password</label>
+                <input id="password" class="form-control" type="password" name="password" ng-model="tmpGUI.password" ng-trim="false" autocomplete="new-password" />
               </div>
             </div>
           </div>

+ 11 - 4
lib/api/api.go

@@ -350,7 +350,7 @@ func (s *service) Serve(ctx context.Context) error {
 	mux.Handle("/", s.statics)
 
 	// Handle the special meta.js path
-	mux.HandleFunc("/meta.js", s.getJSMetadata)
+	mux.Handle("/meta.js", noCacheMiddleware(http.HandlerFunc(s.getJSMetadata)))
 
 	// Handle Prometheus metrics
 	promHttpHandler := promhttp.Handler()
@@ -372,7 +372,13 @@ func (s *service) Serve(ctx context.Context) error {
 
 	// Wrap everything in basic auth, if user/password is set.
 	if guiCfg.IsAuthEnabled() {
-		handler = basicAuthAndSessionMiddleware("sessionid-"+s.id.String()[:5], guiCfg, s.cfg.LDAP(), handler, s.evLogger)
+		sessionCookieName := "sessionid-" + s.id.String()[:5]
+		handler = basicAuthAndSessionMiddleware(sessionCookieName, guiCfg, s.cfg.LDAP(), handler, s.evLogger)
+		handlePasswordAuth := passwordAuthHandler(sessionCookieName, guiCfg, s.cfg.LDAP(), s.evLogger)
+		restMux.Handler(http.MethodPost, "/rest/noauth/auth/password", handlePasswordAuth)
+
+		// Logout is a no-op without a valid session cookie, so /noauth/ is fine here
+		restMux.Handler(http.MethodPost, "/rest/noauth/auth/logout", handleLogout(sessionCookieName))
 	}
 
 	// Redirect to HTTPS if we are supposed to
@@ -711,8 +717,9 @@ func (*service) getSystemPaths(w http.ResponseWriter, _ *http.Request) {
 }
 
 func (s *service) getJSMetadata(w http.ResponseWriter, _ *http.Request) {
-	meta, _ := json.Marshal(map[string]string{
-		"deviceID": s.id.String(),
+	meta, _ := json.Marshal(map[string]interface{}{
+		"deviceID":      s.id.String(),
+		"authenticated": true,
 	})
 	w.Header().Set("Content-Type", "application/javascript")
 	fmt.Fprintf(w, "var metadata = %s;\n", meta)

+ 141 - 42
lib/api/api_auth.go

@@ -19,6 +19,7 @@ import (
 	"github.com/syncthing/syncthing/lib/events"
 	"github.com/syncthing/syncthing/lib/rand"
 	"github.com/syncthing/syncthing/lib/sync"
+	"golang.org/x/exp/slices"
 )
 
 var (
@@ -37,6 +38,48 @@ func emitLoginAttempt(success bool, username, address string, evLogger events.Lo
 	}
 }
 
+func antiBruteForceSleep() {
+	time.Sleep(time.Duration(rand.Intn(100)+100) * time.Millisecond)
+}
+
+func unauthorized(w http.ResponseWriter) {
+	antiBruteForceSleep()
+	w.Header().Set("WWW-Authenticate", "Basic realm=\"Authorization Required\"")
+	http.Error(w, "Not Authorized", http.StatusUnauthorized)
+}
+
+func forbidden(w http.ResponseWriter) {
+	antiBruteForceSleep()
+	http.Error(w, "Forbidden", http.StatusForbidden)
+}
+
+func isNoAuthPath(path string) bool {
+	// Local variable instead of module var to prevent accidental mutation
+	noAuthPaths := []string{
+		"/",
+		"/index.html",
+		"/modal.html",
+		"/rest/svc/lang", // Required to load language settings on login page
+	}
+
+	// Local variable instead of module var to prevent accidental mutation
+	noAuthPrefixes := []string{
+		// Static assets
+		"/assets/",
+		"/syncthing/",
+		"/vendor/",
+		"/theme-assets/", // This leaks information from config, but probably not sensitive
+
+		// No-auth API endpoints
+		"/rest/noauth",
+	}
+
+	return slices.Contains(noAuthPaths, path) ||
+		slices.ContainsFunc(noAuthPrefixes, func(prefix string) bool {
+			return strings.HasPrefix(path, prefix)
+		})
+}
+
 func basicAuthAndSessionMiddleware(cookieName string, guiCfg config.GUIConfiguration, ldapCfg config.LDAPConfiguration, next http.Handler, evLogger events.Logger) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		if hasValidAPIKeyHeader(r, guiCfg) {
@@ -44,8 +87,8 @@ func basicAuthAndSessionMiddleware(cookieName string, guiCfg config.GUIConfigura
 			return
 		}
 
-		// Exception for REST calls that don't require authentication.
-		if strings.HasPrefix(r.URL.Path, "/rest/noauth") {
+		// Exception for static assets and REST calls that don't require authentication.
+		if isNoAuthPath(r.URL.Path) {
 			next.ServeHTTP(w, r)
 			return
 		}
@@ -61,60 +104,116 @@ func basicAuthAndSessionMiddleware(cookieName string, guiCfg config.GUIConfigura
 			}
 		}
 
-		l.Debugln("Sessionless HTTP request with authentication; this is expensive.")
-
-		error := func() {
-			time.Sleep(time.Duration(rand.Intn(100)+100) * time.Millisecond)
-			w.Header().Set("WWW-Authenticate", "Basic realm=\"Authorization Required\"")
-			http.Error(w, "Not Authorized", http.StatusUnauthorized)
+		// Fall back to Basic auth if provided
+		if username, ok := attemptBasicAuth(r, guiCfg, ldapCfg, evLogger); ok {
+			createSession(cookieName, username, guiCfg, evLogger, w, r)
+			next.ServeHTTP(w, r)
+			return
 		}
 
-		username, password, ok := r.BasicAuth()
-		if !ok {
-			error()
+		// Some browsers don't send the Authorization request header unless prompted by a 401 response.
+		// This enables https://user:pass@localhost style URLs to keep working.
+		if guiCfg.SendBasicAuthPrompt {
+			unauthorized(w)
 			return
 		}
 
-		authOk := auth(username, password, guiCfg, ldapCfg)
-		if !authOk {
-			usernameIso := string(iso88591ToUTF8([]byte(username)))
-			passwordIso := string(iso88591ToUTF8([]byte(password)))
-			authOk = auth(usernameIso, passwordIso, guiCfg, ldapCfg)
-			if authOk {
-				username = usernameIso
-			}
+		forbidden(w)
+	})
+}
+
+func passwordAuthHandler(cookieName string, guiCfg config.GUIConfiguration, ldapCfg config.LDAPConfiguration, evLogger events.Logger) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		var req struct {
+			Username string
+			Password string
+		}
+		if err := unmarshalTo(r.Body, &req); err != nil {
+			l.Debugln("Failed to parse username and password:", err)
+			http.Error(w, "Failed to parse username and password.", http.StatusBadRequest)
+			return
 		}
 
-		if !authOk {
-			emitLoginAttempt(false, username, r.RemoteAddr, evLogger)
-			error()
+		if auth(req.Username, req.Password, guiCfg, ldapCfg) {
+			createSession(cookieName, req.Username, guiCfg, evLogger, w, r)
+			w.WriteHeader(http.StatusNoContent)
 			return
 		}
 
-		sessionid := rand.String(32)
-		sessionsMut.Lock()
-		sessions[sessionid] = true
-		sessionsMut.Unlock()
-
-		// Best effort detection of whether the connection is HTTPS --
-		// either directly to us, or as used by the client towards a reverse
-		// proxy who sends us headers.
-		connectionIsHTTPS := r.TLS != nil ||
-			strings.ToLower(r.Header.Get("x-forwarded-proto")) == "https" ||
-			strings.Contains(strings.ToLower(r.Header.Get("forwarded")), "proto=https")
-		// If the connection is HTTPS, or *should* be HTTPS, set the Secure
-		// bit in cookies.
-		useSecureCookie := connectionIsHTTPS || guiCfg.UseTLS()
+		emitLoginAttempt(false, req.Username, r.RemoteAddr, evLogger)
+		forbidden(w)
+	})
+}
+
+func attemptBasicAuth(r *http.Request, guiCfg config.GUIConfiguration, ldapCfg config.LDAPConfiguration, evLogger events.Logger) (string, bool) {
+	username, password, ok := r.BasicAuth()
+	if !ok {
+		return "", false
+	}
+
+	l.Debugln("Sessionless HTTP request with authentication; this is expensive.")
+
+	if auth(username, password, guiCfg, ldapCfg) {
+		return username, true
+	}
+
+	usernameFromIso := string(iso88591ToUTF8([]byte(username)))
+	passwordFromIso := string(iso88591ToUTF8([]byte(password)))
+	if auth(usernameFromIso, passwordFromIso, guiCfg, ldapCfg) {
+		return usernameFromIso, true
+	}
+
+	emitLoginAttempt(false, username, r.RemoteAddr, evLogger)
+	return "", false
+}
+
+func createSession(cookieName string, username string, guiCfg config.GUIConfiguration, evLogger events.Logger, w http.ResponseWriter, r *http.Request) {
+	sessionid := rand.String(32)
+	sessionsMut.Lock()
+	sessions[sessionid] = true
+	sessionsMut.Unlock()
+
+	// Best effort detection of whether the connection is HTTPS --
+	// either directly to us, or as used by the client towards a reverse
+	// proxy who sends us headers.
+	connectionIsHTTPS := r.TLS != nil ||
+		strings.ToLower(r.Header.Get("x-forwarded-proto")) == "https" ||
+		strings.Contains(strings.ToLower(r.Header.Get("forwarded")), "proto=https")
+	// If the connection is HTTPS, or *should* be HTTPS, set the Secure
+	// bit in cookies.
+	useSecureCookie := connectionIsHTTPS || guiCfg.UseTLS()
+
+	http.SetCookie(w, &http.Cookie{
+		Name:  cookieName,
+		Value: sessionid,
+		// In HTTP spec Max-Age <= 0 means delete immediately,
+		// but in http.Cookie MaxAge = 0 means unspecified (session) and MaxAge < 0 means delete immediately
+		MaxAge: 0,
+		Secure: useSecureCookie,
+		Path:   "/",
+	})
+
+	emitLoginAttempt(true, username, r.RemoteAddr, evLogger)
+}
+
+func handleLogout(cookieName string) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		cookie, err := r.Cookie(cookieName)
+		if err == nil && cookie != nil {
+			sessionsMut.Lock()
+			delete(sessions, cookie.Value)
+			sessionsMut.Unlock()
+		}
+		// else: If there is no session cookie, that's also a successful logout in terms of user experience.
 
 		http.SetCookie(w, &http.Cookie{
 			Name:   cookieName,
-			Value:  sessionid,
-			MaxAge: 0,
-			Secure: useSecureCookie,
+			Value:  "",
+			MaxAge: -1,
+			Secure: true,
+			Path:   "/",
 		})
-
-		emitLoginAttempt(true, username, r.RemoteAddr, evLogger)
-		next.ServeHTTP(w, r)
+		w.WriteHeader(http.StatusNoContent)
 	})
 }
 

+ 7 - 7
lib/api/api_csrf.go

@@ -74,13 +74,6 @@ func (m *csrfManager) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	if strings.HasPrefix(r.URL.Path, "/rest/noauth") {
-		// REST calls that don't require authentication also do not
-		// need a CSRF token.
-		m.next.ServeHTTP(w, r)
-		return
-	}
-
 	// Allow requests for anything not under the protected path prefix,
 	// and set a CSRF cookie if there isn't already a valid one.
 	if !strings.HasPrefix(r.URL.Path, m.prefix) {
@@ -97,6 +90,13 @@ func (m *csrfManager) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	if isNoAuthPath(r.URL.Path) {
+		// REST calls that don't require authentication also do not
+		// need a CSRF token.
+		m.next.ServeHTTP(w, r)
+		return
+	}
+
 	// Verify the CSRF token
 	token := r.Header.Get("X-CSRF-Token-" + m.unique)
 	if !m.validToken(token) {

+ 272 - 81
lib/api/api_test.go

@@ -554,15 +554,184 @@ func testHTTPRequest(t *testing.T, baseURL string, tc httpTestCase, apikey strin
 	}
 }
 
+func hasSessionCookie(cookies []*http.Cookie) bool {
+	for _, cookie := range cookies {
+		if cookie.MaxAge >= 0 && strings.HasPrefix(cookie.Name, "sessionid") {
+			return true
+		}
+	}
+	return false
+}
+
+func httpGet(url string, basicAuthUsername string, basicAuthPassword string, xapikeyHeader string, authorizationBearer string, cookies []*http.Cookie, t *testing.T) *http.Response {
+	req, err := http.NewRequest("GET", url, nil)
+	for _, cookie := range cookies {
+		req.AddCookie(cookie)
+	}
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if basicAuthUsername != "" || basicAuthPassword != "" {
+		req.SetBasicAuth(basicAuthUsername, basicAuthPassword)
+	}
+
+	if xapikeyHeader != "" {
+		req.Header.Set("X-API-Key", xapikeyHeader)
+	}
+
+	if authorizationBearer != "" {
+		req.Header.Set("Authorization", "Bearer "+authorizationBearer)
+	}
+
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	return resp
+}
+
+func httpPost(url string, body map[string]string, t *testing.T) *http.Response {
+	bodyBytes, err := json.Marshal(body)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	req, err := http.NewRequest("POST", url, bytes.NewReader(bodyBytes))
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	return resp
+}
+
 func TestHTTPLogin(t *testing.T) {
 	t.Parallel()
 
+	httpGetBasicAuth := func(url string, username string, password string) *http.Response {
+		return httpGet(url, username, password, "", "", nil, t)
+	}
+
+	httpGetXapikey := func(url string, xapikeyHeader string) *http.Response {
+		return httpGet(url, "", "", xapikeyHeader, "", nil, t)
+	}
+
+	httpGetAuthorizationBearer := func(url string, bearer string) *http.Response {
+		return httpGet(url, "", "", "", bearer, nil, t)
+	}
+
+	testWith := func(sendBasicAuthPrompt bool, expectedOkStatus int, expectedFailStatus int, path string) {
+		cfg := newMockedConfig()
+		cfg.GUIReturns(config.GUIConfiguration{
+			User:                "üser",
+			Password:            "$2a$10$IdIZTxTg/dCNuNEGlmLynOjqg4B1FvDKuIV5e0BB3pnWVHNb8.GSq", // bcrypt of "räksmörgås" in UTF-8
+			RawAddress:          "127.0.0.1:0",
+			APIKey:              testAPIKey,
+			SendBasicAuthPrompt: sendBasicAuthPrompt,
+		})
+		baseURL, cancel, err := startHTTP(cfg)
+		if err != nil {
+			t.Fatal(err)
+		}
+		t.Cleanup(cancel)
+		url := baseURL + path
+
+		t.Run(fmt.Sprintf("%d path", expectedOkStatus), func(t *testing.T) {
+			t.Run("no auth is rejected", func(t *testing.T) {
+				t.Parallel()
+				resp := httpGetBasicAuth(url, "", "")
+				if resp.StatusCode != expectedFailStatus {
+					t.Errorf("Unexpected non-%d return code %d for unauthed request", expectedFailStatus, resp.StatusCode)
+				}
+			})
+
+			t.Run("incorrect password is rejected", func(t *testing.T) {
+				t.Parallel()
+				resp := httpGetBasicAuth(url, "üser", "rksmrgs")
+				if resp.StatusCode != expectedFailStatus {
+					t.Errorf("Unexpected non-%d return code %d for incorrect password", expectedFailStatus, resp.StatusCode)
+				}
+			})
+
+			t.Run("incorrect username is rejected", func(t *testing.T) {
+				t.Parallel()
+				resp := httpGetBasicAuth(url, "user", "räksmörgås") // string literals in Go source code are in UTF-8
+				if resp.StatusCode != expectedFailStatus {
+					t.Errorf("Unexpected non-%d return code %d for incorrect username", expectedFailStatus, resp.StatusCode)
+				}
+			})
+
+			t.Run("UTF-8 auth works", func(t *testing.T) {
+				t.Parallel()
+				resp := httpGetBasicAuth(url, "üser", "räksmörgås") // string literals in Go source code are in UTF-8
+				if resp.StatusCode != expectedOkStatus {
+					t.Errorf("Unexpected non-%d return code %d for authed request (UTF-8)", expectedOkStatus, resp.StatusCode)
+				}
+			})
+
+			t.Run("ISO-8859-1 auth works", func(t *testing.T) {
+				t.Parallel()
+				resp := httpGetBasicAuth(url, "\xfcser", "r\xe4ksm\xf6rg\xe5s") // escaped ISO-8859-1
+				if resp.StatusCode != expectedOkStatus {
+					t.Errorf("Unexpected non-%d return code %d for authed request (ISO-8859-1)", expectedOkStatus, resp.StatusCode)
+				}
+			})
+
+			t.Run("bad X-API-Key is rejected", func(t *testing.T) {
+				t.Parallel()
+				resp := httpGetXapikey(url, testAPIKey+"X")
+				if resp.StatusCode != expectedFailStatus {
+					t.Errorf("Unexpected non-%d return code %d for bad API key", expectedFailStatus, resp.StatusCode)
+				}
+			})
+
+			t.Run("good X-API-Key is accepted", func(t *testing.T) {
+				t.Parallel()
+				resp := httpGetXapikey(url, testAPIKey)
+				if resp.StatusCode != expectedOkStatus {
+					t.Errorf("Unexpected non-%d return code %d for API key", expectedOkStatus, resp.StatusCode)
+				}
+			})
+
+			t.Run("bad Bearer is rejected", func(t *testing.T) {
+				t.Parallel()
+				resp := httpGetAuthorizationBearer(url, testAPIKey+"X")
+				if resp.StatusCode != expectedFailStatus {
+					t.Errorf("Unexpected non-%d return code %d for bad API key", expectedFailStatus, resp.StatusCode)
+				}
+			})
+
+			t.Run("good Bearer is accepted", func(t *testing.T) {
+				t.Parallel()
+				resp := httpGetAuthorizationBearer(url, testAPIKey)
+				if resp.StatusCode != expectedOkStatus {
+					t.Errorf("Unexpected non-%d return code %d for API key", expectedOkStatus, resp.StatusCode)
+				}
+			})
+		})
+	}
+
+	testWith(true, http.StatusOK, http.StatusUnauthorized, "/meta.js")
+	testWith(true, http.StatusNotFound, http.StatusUnauthorized, "/any-path/that/does/nooooooot/match-any/noauth-pattern")
+
+	testWith(false, http.StatusOK, http.StatusForbidden, "/meta.js")
+	testWith(false, http.StatusNotFound, http.StatusForbidden, "/any-path/that/does/nooooooot/match-any/noauth-pattern")
+}
+
+func TestHtmlFormLogin(t *testing.T) {
+	t.Parallel()
+
 	cfg := newMockedConfig()
 	cfg.GUIReturns(config.GUIConfiguration{
-		User:       "üser",
-		Password:   "$2a$10$IdIZTxTg/dCNuNEGlmLynOjqg4B1FvDKuIV5e0BB3pnWVHNb8.GSq", // bcrypt of "räksmörgås" in UTF-8
-		RawAddress: "127.0.0.1:0",
-		APIKey:     testAPIKey,
+		User:                "üser",
+		Password:            "$2a$10$IdIZTxTg/dCNuNEGlmLynOjqg4B1FvDKuIV5e0BB3pnWVHNb8.GSq", // bcrypt of "räksmörgås" in UTF-8
+		SendBasicAuthPrompt: false,
 	})
 	baseURL, cancel, err := startHTTP(cfg)
 	if err != nil {
@@ -570,119 +739,137 @@ func TestHTTPLogin(t *testing.T) {
 	}
 	t.Cleanup(cancel)
 
-	t.Run("no auth is rejected", func(t *testing.T) {
-		t.Parallel()
-		req, _ := http.NewRequest("GET", baseURL, nil)
-		resp, err := http.DefaultClient.Do(req)
-		if err != nil {
-			t.Fatal(err)
-		}
-		if resp.StatusCode != http.StatusUnauthorized {
-			t.Errorf("Unexpected non-401 return code %d for unauthed request", resp.StatusCode)
-		}
-	})
+	loginUrl := baseURL + "/rest/noauth/auth/password"
+	resourceUrl := baseURL + "/meta.js"
+	resourceUrl404 := baseURL + "/any-path/that/does/nooooooot/match-any/noauth-pattern"
 
-	t.Run("incorrect password is rejected", func(t *testing.T) {
+	performLogin := func(username string, password string) *http.Response {
+		return httpPost(loginUrl, map[string]string{"username": username, "password": password}, t)
+	}
+
+	performResourceRequest := func(url string, cookies []*http.Cookie) *http.Response {
+		return httpGet(url, "", "", "", "", cookies, t)
+	}
+
+	testNoAuthPath := func(noAuthPath string) {
+		t.Run("auth is not needed for "+noAuthPath, func(t *testing.T) {
+			t.Parallel()
+			resp := httpGet(baseURL+noAuthPath, "", "", "", "", nil, t)
+			if resp.StatusCode != http.StatusOK {
+				t.Errorf("Unexpected non-200 return code %d at %s", resp.StatusCode, noAuthPath)
+			}
+			if hasSessionCookie(resp.Cookies()) {
+				t.Errorf("Unexpected session cookie at " + noAuthPath)
+			}
+		})
+	}
+	testNoAuthPath("/index.html")
+	testNoAuthPath("/rest/svc/lang")
+
+	t.Run("incorrect password is rejected with 403", func(t *testing.T) {
 		t.Parallel()
-		req, _ := http.NewRequest("GET", baseURL, nil)
-		req.SetBasicAuth("üser", "rksmrgs")
-		resp, err := http.DefaultClient.Do(req)
-		if err != nil {
-			t.Fatal(err)
+		resp := performLogin("üser", "rksmrgs") // string literals in Go source code are in UTF-8
+		if resp.StatusCode != http.StatusForbidden {
+			t.Errorf("Unexpected non-403 return code %d for incorrect password", resp.StatusCode)
+		}
+		if hasSessionCookie(resp.Cookies()) {
+			t.Errorf("Unexpected session cookie for incorrect password")
 		}
-		if resp.StatusCode != http.StatusUnauthorized {
-			t.Errorf("Unexpected non-401 return code %d for incorrect password", resp.StatusCode)
+		resp = performResourceRequest(resourceUrl, resp.Cookies())
+		if resp.StatusCode != http.StatusForbidden {
+			t.Errorf("Unexpected non-403 return code %d for incorrect password", resp.StatusCode)
 		}
 	})
 
-	t.Run("incorrect username is rejected", func(t *testing.T) {
+	t.Run("incorrect username is rejected with 403", func(t *testing.T) {
 		t.Parallel()
-		req, _ := http.NewRequest("GET", baseURL, nil)
-		req.SetBasicAuth("user", "räksmörgås") // string literals in Go source code are in UTF-8
-		resp, err := http.DefaultClient.Do(req)
-		if err != nil {
-			t.Fatal(err)
+		resp := performLogin("user", "räksmörgås") // string literals in Go source code are in UTF-8
+		if resp.StatusCode != http.StatusForbidden {
+			t.Errorf("Unexpected non-403 return code %d for incorrect username", resp.StatusCode)
 		}
-		if resp.StatusCode != http.StatusUnauthorized {
-			t.Errorf("Unexpected non-401 return code %d for incorrect username", resp.StatusCode)
+		if hasSessionCookie(resp.Cookies()) {
+			t.Errorf("Unexpected session cookie for incorrect username")
+		}
+		resp = performResourceRequest(resourceUrl, resp.Cookies())
+		if resp.StatusCode != http.StatusForbidden {
+			t.Errorf("Unexpected non-403 return code %d for incorrect username", resp.StatusCode)
 		}
 	})
 
 	t.Run("UTF-8 auth works", func(t *testing.T) {
 		t.Parallel()
-		req, _ := http.NewRequest("GET", baseURL, nil)
-		req.SetBasicAuth("üser", "räksmörgås") // string literals in Go source code are in UTF-8
-		resp, err := http.DefaultClient.Do(req)
-		if err != nil {
-			t.Fatal(err)
+		// JSON is always UTF-8, so ISO-8859-1 case is not applicable
+		resp := performLogin("üser", "räksmörgås") // string literals in Go source code are in UTF-8
+		if resp.StatusCode != http.StatusNoContent {
+			t.Errorf("Unexpected non-204 return code %d for authed request (UTF-8)", resp.StatusCode)
 		}
+		resp = performResourceRequest(resourceUrl, resp.Cookies())
 		if resp.StatusCode != http.StatusOK {
 			t.Errorf("Unexpected non-200 return code %d for authed request (UTF-8)", resp.StatusCode)
 		}
 	})
 
-	t.Run("ISO-8859-1 auth work", func(t *testing.T) {
+	t.Run("form login is not applicable to other URLs", func(t *testing.T) {
 		t.Parallel()
-		req, _ := http.NewRequest("GET", baseURL, nil)
-		req.SetBasicAuth("\xfcser", "r\xe4ksm\xf6rg\xe5s") // escaped ISO-8859-1
-		resp, err := http.DefaultClient.Do(req)
-		if err != nil {
-			t.Fatal(err)
+		resp := httpPost(baseURL+"/meta.js", map[string]string{"username": "üser", "password": "räksmörgås"}, t)
+		if resp.StatusCode != http.StatusForbidden {
+			t.Errorf("Unexpected non-403 return code %d for incorrect form login URL", resp.StatusCode)
 		}
-		if resp.StatusCode != http.StatusOK {
-			t.Errorf("Unexpected non-200 return code %d for authed request (ISO-8859-1)", resp.StatusCode)
+		if hasSessionCookie(resp.Cookies()) {
+			t.Errorf("Unexpected session cookie for incorrect form login URL")
 		}
 	})
 
-	t.Run("bad X-API-Key is rejected", func(t *testing.T) {
+	t.Run("invalid URL returns 403 before auth and 404 after auth", func(t *testing.T) {
 		t.Parallel()
-		req, _ := http.NewRequest("GET", baseURL, nil)
-		req.Header.Set("X-API-Key", testAPIKey+"X")
-		resp, err := http.DefaultClient.Do(req)
-		if err != nil {
-			t.Fatal(err)
+		resp := performResourceRequest(resourceUrl404, nil)
+		if resp.StatusCode != http.StatusForbidden {
+			t.Errorf("Unexpected non-403 return code %d for unauthed request", resp.StatusCode)
 		}
-		if resp.StatusCode != http.StatusUnauthorized {
-			t.Errorf("Unexpected non-401 return code %d for bad API key", resp.StatusCode)
+		resp = performLogin("üser", "räksmörgås")
+		if resp.StatusCode != http.StatusNoContent {
+			t.Errorf("Unexpected non-204 return code %d for authed request", resp.StatusCode)
+		}
+		resp = performResourceRequest(resourceUrl404, resp.Cookies())
+		if resp.StatusCode != http.StatusNotFound {
+			t.Errorf("Unexpected non-404 return code %d for authed request", resp.StatusCode)
 		}
 	})
+}
 
-	t.Run("good X-API-Key is accepted", func(t *testing.T) {
-		t.Parallel()
-		req, _ := http.NewRequest("GET", baseURL, nil)
-		req.Header.Set("X-API-Key", testAPIKey)
-		resp, err := http.DefaultClient.Do(req)
-		if err != nil {
-			t.Fatal(err)
-		}
-		if resp.StatusCode != http.StatusOK {
-			t.Errorf("Unexpected non-200 return code %d for API key", resp.StatusCode)
-		}
+func TestApiCache(t *testing.T) {
+	t.Parallel()
+
+	cfg := newMockedConfig()
+	cfg.GUIReturns(config.GUIConfiguration{
+		RawAddress: "127.0.0.1:0",
+		APIKey:     testAPIKey,
 	})
+	baseURL, cancel, err := startHTTP(cfg)
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(cancel)
+
+	httpGet := func(url string, bearer string) *http.Response {
+		return httpGet(url, "", "", "", bearer, nil, t)
+	}
 
-	t.Run("bad Bearer is rejected", func(t *testing.T) {
+	t.Run("meta.js has no-cache headers", func(t *testing.T) {
 		t.Parallel()
-		req, _ := http.NewRequest("GET", baseURL, nil)
-		req.Header.Set("Authorization", "Bearer "+testAPIKey+"X")
-		resp, err := http.DefaultClient.Do(req)
-		if err != nil {
-			t.Fatal(err)
-		}
-		if resp.StatusCode != http.StatusUnauthorized {
-			t.Errorf("Unexpected non-401 return code %d for bad API key", resp.StatusCode)
+		url := baseURL + "/meta.js"
+		resp := httpGet(url, testAPIKey)
+		if resp.Header.Get("Cache-Control") != "max-age=0, no-cache, no-store" {
+			t.Errorf("Expected no-cache headers at %s", url)
 		}
 	})
 
-	t.Run("good Bearer is accepted", func(t *testing.T) {
+	t.Run("/rest/ has no-cache headers", func(t *testing.T) {
 		t.Parallel()
-		req, _ := http.NewRequest("GET", baseURL, nil)
-		req.Header.Set("Authorization", "Bearer "+testAPIKey)
-		resp, err := http.DefaultClient.Do(req)
-		if err != nil {
-			t.Fatal(err)
-		}
-		if resp.StatusCode != http.StatusOK {
-			t.Errorf("Unexpected non-200 return code %d for API key", resp.StatusCode)
+		url := baseURL + "/rest/system/version"
+		resp := httpGet(url, testAPIKey)
+		if resp.Header.Get("Cache-Control") != "max-age=0, no-cache, no-store" {
+			t.Errorf("Expected no-cache headers at %s", url)
 		}
 	})
 }
@@ -774,6 +961,10 @@ func TestCSRFRequired(t *testing.T) {
 		}
 	}
 
+	if csrfTokenValue == "" {
+		t.Fatal("Failed to initialize CSRF test: no CSRF cookie returned from " + baseURL)
+	}
+
 	t.Run("/rest without a token should fail", func(t *testing.T) {
 		t.Parallel()
 		resp, err := cli.Get(baseURL + "/rest/system/config")

+ 90 - 53
lib/config/guiconfiguration.pb.go

@@ -37,6 +37,7 @@ type GUIConfiguration struct {
 	Debugging                 bool     `protobuf:"varint,11,opt,name=debugging,proto3" json:"debugging" xml:"debugging,attr"`
 	InsecureSkipHostCheck     bool     `protobuf:"varint,12,opt,name=insecure_skip_host_check,json=insecureSkipHostCheck,proto3" json:"insecureSkipHostcheck" xml:"insecureSkipHostcheck,omitempty"`
 	InsecureAllowFrameLoading bool     `protobuf:"varint,13,opt,name=insecure_allow_frame_loading,json=insecureAllowFrameLoading,proto3" json:"insecureAllowFrameLoading" xml:"insecureAllowFrameLoading,omitempty"`
+	SendBasicAuthPrompt       bool     `protobuf:"varint,14,opt,name=send_basic_auth_prompt,json=sendBasicAuthPrompt,proto3" json:"sendBasicAuthPrompt" xml:"sendBasicAuthPrompt,attr"`
 }
 
 func (m *GUIConfiguration) Reset()         { *m = GUIConfiguration{} }
@@ -79,60 +80,63 @@ func init() {
 func init() { proto.RegisterFile("lib/config/guiconfiguration.proto", fileDescriptor_2a9586d611855d64) }
 
 var fileDescriptor_2a9586d611855d64 = []byte{
-	// 837 bytes of a gzipped FileDescriptorProto
+	// 888 bytes of a gzipped FileDescriptorProto
 	0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x8c, 0x55, 0xcd, 0x6e, 0xdb, 0x46,
-	0x10, 0x16, 0x5b, 0x47, 0xb2, 0xb6, 0xae, 0x60, 0xb0, 0x4d, 0xcb, 0x04, 0x0d, 0xd7, 0x51, 0xd8,
-	0xc2, 0x01, 0x02, 0x39, 0x71, 0x5a, 0x24, 0xf0, 0xa1, 0x80, 0x1c, 0x20, 0x4d, 0x60, 0x17, 0x08,
-	0xe8, 0xfa, 0x92, 0x0b, 0xb1, 0x22, 0xd7, 0xd2, 0x42, 0xfc, 0x2b, 0x77, 0x09, 0x4b, 0x87, 0xf6,
-	0x19, 0x0a, 0xf5, 0x5c, 0xa0, 0xcf, 0xd0, 0x4b, 0x5f, 0x21, 0x37, 0xe9, 0x54, 0xe4, 0xb4, 0x40,
-	0xa4, 0x1b, 0x8f, 0x3c, 0xe6, 0x54, 0xec, 0xf2, 0x47, 0xa2, 0xac, 0xd4, 0xbd, 0xed, 0x7c, 0xf3,
-	0xcd, 0x7c, 0x33, 0xc3, 0x19, 0x10, 0xdc, 0x75, 0x49, 0xef, 0xc0, 0x0e, 0xfc, 0x0b, 0xd2, 0x3f,
-	0xe8, 0xc7, 0x24, 0x7b, 0xc5, 0x11, 0x62, 0x24, 0xf0, 0x3b, 0x61, 0x14, 0xb0, 0x40, 0xad, 0x67,
-	0xe0, 0xed, 0x5b, 0x2b, 0x54, 0x14, 0xb3, 0x81, 0x17, 0x38, 0x38, 0xa3, 0xdc, 0x6e, 0xe2, 0x11,
-	0xcb, 0x9e, 0xed, 0xb7, 0x3b, 0x60, 0xf7, 0x87, 0xf3, 0x97, 0xcf, 0x56, 0x13, 0xa9, 0x3d, 0xd0,
-	0xc0, 0x3e, 0xea, 0xb9, 0xd8, 0xd1, 0x94, 0x3d, 0x65, 0x7f, 0xfb, 0xf8, 0x45, 0xc2, 0x61, 0x01,
-	0xa5, 0x1c, 0xde, 0x1d, 0x79, 0xee, 0x51, 0x3b, 0xb7, 0x1f, 0x20, 0xc6, 0xa2, 0xf6, 0x9e, 0x83,
-	0x2f, 0x50, 0xec, 0xb2, 0xa3, 0x36, 0x8b, 0x62, 0xdc, 0x4e, 0xa6, 0xc6, 0xce, 0xaa, 0xff, 0xfd,
-	0xd4, 0xd8, 0x12, 0x0e, 0xb3, 0xc8, 0xa2, 0xfe, 0x02, 0x1a, 0xc8, 0x71, 0x22, 0x4c, 0xa9, 0xf6,
-	0xd1, 0x9e, 0xb2, 0xdf, 0x3c, 0xb6, 0xe7, 0x1c, 0x02, 0x13, 0x5d, 0x76, 0x33, 0x54, 0x28, 0xe6,
-	0x84, 0x94, 0xc3, 0x6f, 0xa4, 0x62, 0x6e, 0xaf, 0x88, 0x3d, 0x3a, 0x7c, 0xd2, 0x79, 0xd8, 0x79,
-	0xd8, 0x79, 0x74, 0xf4, 0xf4, 0xf1, 0xd3, 0x6f, 0xdb, 0xef, 0xa7, 0x46, 0xab, 0x0a, 0x4d, 0x66,
-	0xc6, 0x4a, 0x52, 0xb3, 0x48, 0xa9, 0xfe, 0xa3, 0x80, 0x2f, 0x63, 0x9f, 0x8c, 0x2c, 0x1a, 0xd8,
-	0x43, 0xcc, 0xac, 0x10, 0x47, 0x1e, 0xa1, 0x94, 0x04, 0x3e, 0xd5, 0x3e, 0x96, 0xf5, 0xfc, 0xa1,
-	0xcc, 0x39, 0xd4, 0x4c, 0x74, 0x79, 0xee, 0x93, 0xd1, 0x99, 0x64, 0xbd, 0x5a, 0x92, 0x12, 0x0e,
-	0x6f, 0xc6, 0x9b, 0x1c, 0x29, 0x87, 0x5f, 0xcb, 0x62, 0x37, 0x7a, 0x1f, 0x04, 0x1e, 0x61, 0xd8,
-	0x0b, 0xd9, 0x58, 0x8c, 0x08, 0x5e, 0xc3, 0x99, 0xcc, 0x8c, 0x0f, 0x16, 0x60, 0x6e, 0x96, 0x57,
-	0x9f, 0x83, 0xad, 0x98, 0xe2, 0x48, 0xdb, 0x92, 0x4d, 0x1c, 0x26, 0x1c, 0x4a, 0x3b, 0xe5, 0xf0,
-	0xf3, 0xac, 0x2c, 0x8a, 0xa3, 0x6a, 0x15, 0xad, 0x2a, 0x64, 0x4a, 0xbe, 0xfa, 0x1a, 0x6c, 0x87,
-	0x88, 0xd2, 0xcb, 0x20, 0x72, 0xb4, 0x1b, 0x32, 0xd7, 0xf7, 0x09, 0x87, 0x25, 0x96, 0x72, 0xa8,
-	0xc9, 0x7c, 0x05, 0x50, 0xcd, 0xa9, 0x5e, 0x85, 0xcd, 0x32, 0x56, 0xf5, 0x40, 0x53, 0x6c, 0xa4,
-	0x25, 0x56, 0x52, 0xab, 0xef, 0x29, 0xfb, 0xad, 0xc3, 0xdd, 0x4e, 0xb6, 0xaa, 0x9d, 0x6e, 0xcc,
-	0x06, 0x3f, 0x06, 0x0e, 0xce, 0xe4, 0x50, 0x6e, 0x95, 0x72, 0x05, 0xb0, 0x26, 0x77, 0x15, 0x36,
-	0xcb, 0x58, 0x15, 0x83, 0x46, 0x4c, 0xb1, 0xc5, 0x5c, 0xaa, 0x35, 0xe4, 0x3a, 0x9f, 0xce, 0x39,
-	0x6c, 0x8a, 0xc1, 0x52, 0xfc, 0xd3, 0xe9, 0x59, 0xc2, 0x61, 0x3d, 0x96, 0xaf, 0x94, 0xc3, 0x96,
-	0x54, 0x61, 0x2e, 0xcd, 0xd6, 0x3a, 0x99, 0x1a, 0xdb, 0x85, 0x91, 0x4e, 0x8d, 0x9c, 0x37, 0x99,
-	0x19, 0xcb, 0x70, 0x53, 0x82, 0x2e, 0x15, 0x32, 0x28, 0x24, 0xd6, 0x10, 0x8f, 0xb5, 0x6d, 0x39,
-	0x30, 0x21, 0x53, 0xef, 0xbe, 0x7a, 0x79, 0x82, 0xc7, 0x42, 0x03, 0x85, 0xe4, 0x04, 0x8f, 0x53,
-	0x0e, 0xbf, 0xc8, 0x3a, 0x09, 0xc9, 0x10, 0x8f, 0xab, 0x7d, 0xec, 0xae, 0x83, 0x93, 0x99, 0x91,
-	0x67, 0x30, 0xf3, 0x78, 0xf5, 0x77, 0x05, 0xdc, 0x24, 0x3e, 0xc5, 0x76, 0x1c, 0x61, 0x0b, 0x39,
-	0x1e, 0xf1, 0x2d, 0x64, 0xdb, 0xe2, 0x8e, 0x9a, 0xb2, 0x39, 0x2b, 0xe1, 0xf0, 0xb3, 0x82, 0xd0,
-	0x15, 0xfe, 0xae, 0x74, 0xa7, 0x1c, 0xde, 0x93, 0xc2, 0x1b, 0x7c, 0xd5, 0x2a, 0xee, 0xfc, 0x27,
-	0xc3, 0xdc, 0x94, 0x5c, 0x3d, 0x01, 0x37, 0xd8, 0x00, 0x7b, 0x58, 0x03, 0xb2, 0xf5, 0xef, 0x12,
-	0x0e, 0x33, 0x20, 0xe5, 0xf0, 0x4e, 0x36, 0x53, 0x61, 0xad, 0x9c, 0x6e, 0xfe, 0x10, 0x37, 0xdb,
-	0xc8, 0xdf, 0x66, 0x16, 0xa2, 0x9e, 0x83, 0xa6, 0x83, 0x7b, 0x71, 0xbf, 0x4f, 0xfc, 0xbe, 0xf6,
-	0x89, 0xec, 0xea, 0x49, 0xc2, 0xe1, 0x12, 0x2c, 0xb7, 0xb9, 0x44, 0xca, 0xcf, 0xd5, 0xaa, 0x42,
-	0xe6, 0x32, 0x48, 0xfd, 0x5b, 0x01, 0x5a, 0x39, 0x39, 0x3a, 0x24, 0xa1, 0x35, 0x08, 0x28, 0xb3,
-	0xec, 0x01, 0xb6, 0x87, 0xda, 0x8e, 0x94, 0xf9, 0x55, 0xdc, 0x75, 0xc1, 0x39, 0x1b, 0x92, 0xf0,
-	0x45, 0x40, 0x99, 0x24, 0x94, 0x77, 0xbd, 0xd1, 0xbb, 0x76, 0xd7, 0xd7, 0x70, 0xd2, 0xa9, 0xb1,
-	0x59, 0xc4, 0xbc, 0x02, 0x3f, 0x13, 0xb0, 0xfa, 0x97, 0x02, 0xbe, 0x5a, 0x7e, 0x73, 0xd7, 0x0d,
-	0x2e, 0xad, 0x8b, 0x08, 0x79, 0xd8, 0x72, 0x03, 0xe4, 0x88, 0x21, 0x7d, 0x2a, 0xab, 0xff, 0x39,
-	0xe1, 0xf0, 0x56, 0xf9, 0x75, 0x04, 0xed, 0xb9, 0x60, 0x9d, 0x66, 0xa4, 0x94, 0xc3, 0xfb, 0xd5,
-	0x05, 0x58, 0x67, 0x54, 0xbb, 0xb8, 0xf7, 0x3f, 0x78, 0xe6, 0x87, 0xe5, 0x8e, 0x4f, 0xde, 0xbc,
-	0xd3, 0x6b, 0xb3, 0x77, 0x7a, 0xed, 0xcd, 0x5c, 0x57, 0x66, 0x73, 0x5d, 0xf9, 0x6d, 0xa1, 0xd7,
-	0xfe, 0x5c, 0xe8, 0xca, 0x6c, 0xa1, 0xd7, 0xde, 0x2e, 0xf4, 0xda, 0xeb, 0xfb, 0x7d, 0xc2, 0x06,
-	0x71, 0xaf, 0x63, 0x07, 0xde, 0x01, 0x1d, 0xfb, 0x36, 0x1b, 0x10, 0xbf, 0xbf, 0xf2, 0x5a, 0xfe,
-	0xc1, 0x7a, 0x75, 0xf9, 0xbb, 0x7a, 0xfc, 0x6f, 0x00, 0x00, 0x00, 0xff, 0xff, 0xd8, 0x8c, 0xef,
-	0xc0, 0x01, 0x07, 0x00, 0x00,
+	0x10, 0x16, 0x5b, 0x47, 0xb2, 0xb6, 0x89, 0x60, 0xb0, 0x4d, 0xca, 0x04, 0x0d, 0xd7, 0x51, 0xd8,
+	0xc2, 0x01, 0x02, 0x39, 0x71, 0x5a, 0x24, 0xf0, 0xa1, 0x80, 0x1c, 0x20, 0x4d, 0x60, 0x17, 0x30,
+	0xe8, 0xfa, 0x92, 0x0b, 0xb1, 0x22, 0xd7, 0xd2, 0x42, 0xfc, 0x2b, 0x77, 0x09, 0x5b, 0x87, 0xf6,
+	0x01, 0x7a, 0x2a, 0xdc, 0x73, 0x81, 0x3e, 0x43, 0x2f, 0x7d, 0x85, 0xdc, 0xa4, 0x53, 0xd1, 0xd3,
+	0x02, 0x91, 0xd1, 0x0b, 0x8f, 0x3c, 0xe6, 0x54, 0xec, 0xf2, 0x47, 0xa2, 0x4c, 0x37, 0xb9, 0xed,
+	0x7c, 0xf3, 0xcd, 0x7c, 0x33, 0xc3, 0x19, 0x10, 0xdc, 0x73, 0xc9, 0x60, 0xdb, 0x0e, 0xfc, 0x13,
+	0x32, 0xdc, 0x1e, 0xc6, 0x24, 0x7b, 0xc5, 0x11, 0x62, 0x24, 0xf0, 0x7b, 0x61, 0x14, 0xb0, 0x40,
+	0x6d, 0x66, 0xe0, 0x9d, 0xdb, 0x4b, 0x54, 0x14, 0xb3, 0x91, 0x17, 0x38, 0x38, 0xa3, 0xdc, 0x69,
+	0xe3, 0x33, 0x96, 0x3d, 0xbb, 0xff, 0xde, 0x00, 0x1b, 0xdf, 0x1d, 0xbf, 0x7a, 0xbe, 0x9c, 0x48,
+	0x1d, 0x80, 0x16, 0xf6, 0xd1, 0xc0, 0xc5, 0x8e, 0xa6, 0x6c, 0x2a, 0x5b, 0xeb, 0x7b, 0x2f, 0x13,
+	0x0e, 0x0b, 0x28, 0xe5, 0xf0, 0xde, 0x99, 0xe7, 0xee, 0x76, 0x73, 0xfb, 0x21, 0x62, 0x2c, 0xea,
+	0x6e, 0x3a, 0xf8, 0x04, 0xc5, 0x2e, 0xdb, 0xed, 0xb2, 0x28, 0xc6, 0xdd, 0x64, 0x6a, 0x5c, 0x5f,
+	0xf6, 0xbf, 0x9b, 0x1a, 0x6b, 0xc2, 0x61, 0x16, 0x59, 0xd4, 0x9f, 0x40, 0x0b, 0x39, 0x4e, 0x84,
+	0x29, 0xd5, 0x3e, 0xda, 0x54, 0xb6, 0xda, 0x7b, 0xf6, 0x9c, 0x43, 0x60, 0xa2, 0xd3, 0x7e, 0x86,
+	0x0a, 0xc5, 0x9c, 0x90, 0x72, 0xf8, 0x95, 0x54, 0xcc, 0xed, 0x25, 0xb1, 0xc7, 0x3b, 0x4f, 0x7b,
+	0x8f, 0x7a, 0x8f, 0x7a, 0x8f, 0x77, 0x9f, 0x3d, 0x79, 0xf6, 0x75, 0xf7, 0xdd, 0xd4, 0xe8, 0x54,
+	0xa1, 0xf3, 0x99, 0xb1, 0x94, 0xd4, 0x2c, 0x52, 0xaa, 0x7f, 0x2b, 0xe0, 0xf3, 0xd8, 0x27, 0x67,
+	0x16, 0x0d, 0xec, 0x31, 0x66, 0x56, 0x88, 0x23, 0x8f, 0x50, 0x4a, 0x02, 0x9f, 0x6a, 0x1f, 0xcb,
+	0x7a, 0x7e, 0x57, 0xe6, 0x1c, 0x6a, 0x26, 0x3a, 0x3d, 0xf6, 0xc9, 0xd9, 0x91, 0x64, 0x1d, 0x2e,
+	0x48, 0x09, 0x87, 0x37, 0xe3, 0x3a, 0x47, 0xca, 0xe1, 0x97, 0xb2, 0xd8, 0x5a, 0xef, 0xc3, 0xc0,
+	0x23, 0x0c, 0x7b, 0x21, 0x9b, 0x88, 0x11, 0xc1, 0xf7, 0x70, 0xce, 0x67, 0xc6, 0x95, 0x05, 0x98,
+	0xf5, 0xf2, 0xea, 0x0b, 0xb0, 0x16, 0x53, 0x1c, 0x69, 0x6b, 0xb2, 0x89, 0x9d, 0x84, 0x43, 0x69,
+	0xa7, 0x1c, 0x7e, 0x96, 0x95, 0x45, 0x71, 0x54, 0xad, 0xa2, 0x53, 0x85, 0x4c, 0xc9, 0x57, 0x5f,
+	0x83, 0xf5, 0x10, 0x51, 0x7a, 0x1a, 0x44, 0x8e, 0x76, 0x4d, 0xe6, 0xfa, 0x36, 0xe1, 0xb0, 0xc4,
+	0x52, 0x0e, 0x35, 0x99, 0xaf, 0x00, 0xaa, 0x39, 0xd5, 0xcb, 0xb0, 0x59, 0xc6, 0xaa, 0x1e, 0x68,
+	0x8b, 0x8d, 0xb4, 0xc4, 0x4a, 0x6a, 0xcd, 0x4d, 0x65, 0xab, 0xb3, 0xb3, 0xd1, 0xcb, 0x56, 0xb5,
+	0xd7, 0x8f, 0xd9, 0xe8, 0xfb, 0xc0, 0xc1, 0x99, 0x1c, 0xca, 0xad, 0x52, 0xae, 0x00, 0x56, 0xe4,
+	0x2e, 0xc3, 0x66, 0x19, 0xab, 0x62, 0xd0, 0x8a, 0x29, 0xb6, 0x98, 0x4b, 0xb5, 0x96, 0x5c, 0xe7,
+	0x83, 0x39, 0x87, 0x6d, 0x31, 0x58, 0x8a, 0x7f, 0x38, 0x38, 0x4a, 0x38, 0x6c, 0xc6, 0xf2, 0x95,
+	0x72, 0xd8, 0x91, 0x2a, 0xcc, 0xa5, 0xd9, 0x5a, 0x27, 0x53, 0x63, 0xbd, 0x30, 0xd2, 0xa9, 0x91,
+	0xf3, 0xce, 0x67, 0xc6, 0x22, 0xdc, 0x94, 0xa0, 0x4b, 0x85, 0x0c, 0x0a, 0x89, 0x35, 0xc6, 0x13,
+	0x6d, 0x5d, 0x0e, 0x4c, 0xc8, 0x34, 0xfb, 0x87, 0xaf, 0xf6, 0xf1, 0x44, 0x68, 0xa0, 0x90, 0xec,
+	0xe3, 0x49, 0xca, 0xe1, 0xad, 0xac, 0x93, 0x90, 0x8c, 0xf1, 0xa4, 0xda, 0xc7, 0xc6, 0x2a, 0x78,
+	0x3e, 0x33, 0xf2, 0x0c, 0x66, 0x1e, 0xaf, 0xfe, 0xa6, 0x80, 0x9b, 0xc4, 0xa7, 0xd8, 0x8e, 0x23,
+	0x6c, 0x21, 0xc7, 0x23, 0xbe, 0x85, 0x6c, 0x5b, 0xdc, 0x51, 0x5b, 0x36, 0x67, 0x25, 0x1c, 0x7e,
+	0x5a, 0x10, 0xfa, 0xc2, 0xdf, 0x97, 0xee, 0x94, 0xc3, 0xfb, 0x52, 0xb8, 0xc6, 0x57, 0xad, 0xe2,
+	0xee, 0xff, 0x32, 0xcc, 0xba, 0xe4, 0xea, 0x3e, 0xb8, 0xc6, 0x46, 0xd8, 0xc3, 0x1a, 0x90, 0xad,
+	0x7f, 0x93, 0x70, 0x98, 0x01, 0x29, 0x87, 0x77, 0xb3, 0x99, 0x0a, 0x6b, 0xe9, 0x74, 0xf3, 0x87,
+	0xb8, 0xd9, 0x56, 0xfe, 0x36, 0xb3, 0x10, 0xf5, 0x18, 0xb4, 0x1d, 0x3c, 0x88, 0x87, 0x43, 0xe2,
+	0x0f, 0xb5, 0x4f, 0x64, 0x57, 0x4f, 0x13, 0x0e, 0x17, 0x60, 0xb9, 0xcd, 0x25, 0x52, 0x7e, 0xae,
+	0x4e, 0x15, 0x32, 0x17, 0x41, 0xea, 0x5f, 0x0a, 0xd0, 0xca, 0xc9, 0xd1, 0x31, 0x09, 0xad, 0x51,
+	0x40, 0x99, 0x65, 0x8f, 0xb0, 0x3d, 0xd6, 0xae, 0x4b, 0x99, 0x9f, 0xc5, 0x5d, 0x17, 0x9c, 0xa3,
+	0x31, 0x09, 0x5f, 0x06, 0x94, 0x49, 0x42, 0x79, 0xd7, 0xb5, 0xde, 0x95, 0xbb, 0x7e, 0x0f, 0x27,
+	0x9d, 0x1a, 0xf5, 0x22, 0xe6, 0x25, 0xf8, 0xb9, 0x80, 0xd5, 0x3f, 0x15, 0xf0, 0xc5, 0xe2, 0x9b,
+	0xbb, 0x6e, 0x70, 0x6a, 0x9d, 0x44, 0xc8, 0xc3, 0x96, 0x1b, 0x20, 0x47, 0x0c, 0xe9, 0x86, 0xac,
+	0xfe, 0xc7, 0x84, 0xc3, 0xdb, 0xe5, 0xd7, 0x11, 0xb4, 0x17, 0x82, 0x75, 0x90, 0x91, 0x52, 0x0e,
+	0x1f, 0x54, 0x17, 0x60, 0x95, 0x51, 0xed, 0xe2, 0xfe, 0x07, 0xf0, 0xcc, 0xab, 0xe5, 0xd4, 0x5f,
+	0x14, 0x70, 0x8b, 0x62, 0xdf, 0xb1, 0x06, 0x88, 0x12, 0xdb, 0x92, 0x17, 0x1f, 0x46, 0x81, 0x17,
+	0x32, 0xad, 0x23, 0xcb, 0x3d, 0x16, 0x9b, 0x2a, 0x18, 0x7b, 0x82, 0x20, 0x0e, 0xff, 0x50, 0xba,
+	0x53, 0x0e, 0x75, 0x59, 0x68, 0x8d, 0xaf, 0xfc, 0xce, 0xda, 0x55, 0x4e, 0xb3, 0x2e, 0xe5, 0xde,
+	0xfe, 0x9b, 0xb7, 0x7a, 0x63, 0xf6, 0x56, 0x6f, 0xbc, 0x99, 0xeb, 0xca, 0x6c, 0xae, 0x2b, 0xbf,
+	0x5e, 0xe8, 0x8d, 0x3f, 0x2e, 0x74, 0x65, 0x76, 0xa1, 0x37, 0xfe, 0xb9, 0xd0, 0x1b, 0xaf, 0x1f,
+	0x0c, 0x09, 0x1b, 0xc5, 0x83, 0x9e, 0x1d, 0x78, 0xdb, 0x74, 0xe2, 0xdb, 0x6c, 0x44, 0xfc, 0xe1,
+	0xd2, 0x6b, 0xf1, 0x3b, 0x1d, 0x34, 0xe5, 0xbf, 0xf3, 0xc9, 0x7f, 0x01, 0x00, 0x00, 0xff, 0xff,
+	0xfe, 0x19, 0xb2, 0x3c, 0x8e, 0x07, 0x00, 0x00,
 }
 
 func (m *GUIConfiguration) Marshal() (dAtA []byte, err error) {
@@ -155,6 +159,16 @@ func (m *GUIConfiguration) MarshalToSizedBuffer(dAtA []byte) (int, error) {
 	_ = i
 	var l int
 	_ = l
+	if m.SendBasicAuthPrompt {
+		i--
+		if m.SendBasicAuthPrompt {
+			dAtA[i] = 1
+		} else {
+			dAtA[i] = 0
+		}
+		i--
+		dAtA[i] = 0x70
+	}
 	if m.InsecureAllowFrameLoading {
 		i--
 		if m.InsecureAllowFrameLoading {
@@ -327,6 +341,9 @@ func (m *GUIConfiguration) ProtoSize() (n int) {
 	if m.InsecureAllowFrameLoading {
 		n += 2
 	}
+	if m.SendBasicAuthPrompt {
+		n += 2
+	}
 	return n
 }
 
@@ -696,6 +713,26 @@ func (m *GUIConfiguration) Unmarshal(dAtA []byte) error {
 				}
 			}
 			m.InsecureAllowFrameLoading = bool(v != 0)
+		case 14:
+			if wireType != 0 {
+				return fmt.Errorf("proto: wrong wireType = %d for field SendBasicAuthPrompt", wireType)
+			}
+			var v int
+			for shift := uint(0); ; shift += 7 {
+				if shift >= 64 {
+					return ErrIntOverflowGuiconfiguration
+				}
+				if iNdEx >= l {
+					return io.ErrUnexpectedEOF
+				}
+				b := dAtA[iNdEx]
+				iNdEx++
+				v |= int(b&0x7F) << shift
+				if b < 0x80 {
+					break
+				}
+			}
+			m.SendBasicAuthPrompt = bool(v != 0)
 		default:
 			iNdEx = preIndex
 			skippy, err := skipGuiconfiguration(dAtA[iNdEx:])

+ 1 - 0
proto/lib/config/guiconfiguration.proto

@@ -20,4 +20,5 @@ message GUIConfiguration {
     bool     debugging                    = 11 [(ext.xml) = "debugging,attr"];
     bool     insecure_skip_host_check     = 12 [(ext.xml) = "insecureSkipHostcheck,omitempty", (ext.json) = "insecureSkipHostcheck"];
     bool     insecure_allow_frame_loading = 13 [(ext.xml) = "insecureAllowFrameLoading,omitempty"];
+    bool     send_basic_auth_prompt       = 14 [(ext.xml) = "sendBasicAuthPrompt,attr"];
 }