Compare commits

...
3 Commits
Author SHA1 Message Date
Jakob Borg b014a9ebc2 lib/api: Add cache busting for basic auth (ref #9208) (#9215)
This adds our short device ID to the basic auth realm. This has at least
two consequences:

- It is different from what's presented by another device on the same
address (e.g., if I use SSH forwards to different dives on the same
local address), preventing credentials for one from being sent to
another.

- It is different from what we did previously, meaning we avoid cached
credentials from old versions interfering with the new login flow.

I don't *think* there should be things that depend on our precise realm
string, so this shouldn't break any existing setups...

Sneakily this also changes the session cookie and CSRF name, because I
think `id.Short().String()` is nicer than `id.String()[:5]` and the
short ID is two characters longer. That's also not a problem...
2023-11-15 10:54:03 +01:00
Jakob Borg 53123c0b01 lib/api: Improve cookie handling (fixes #9208) (#9214) 2023-11-15 10:53:51 +01:00
Jakob Borg 07ad2db503 build: Version constraint to avoid Go 1.21.4 on Windows (ref #9207) (#9213) 2023-11-15 10:53:36 +01:00
7 changed files with 69 additions and 39 deletions
+17 -1
View File
@@ -49,6 +49,19 @@ jobs:
# The oldest version in this list should match what we have in our go.mod. # The oldest version in this list should match what we have in our go.mod.
# Variables don't seem to be supported here, or we could have done something nice. # Variables don't seem to be supported here, or we could have done something nice.
go: ["1.20", "1.21"] go: ["1.20", "1.21"]
# Don't run the Windows tests with Go 1.21.4 or 1.20.11; this can be
# removed when 1.21.5 and 1.20.12 is released.
exclude:
- runner: windows-latest
go: "1.20"
- runner: windows-latest
go: "1.21"
include:
- runner: windows-latest
go: "~1.20.12 || ~1.20.0 <1.20.11"
- runner: windows-latest
go: "~1.21.5 || ~1.21.1 <1.21.4"
runs-on: ${{ matrix.runner }} runs-on: ${{ matrix.runner }}
steps: steps:
- name: Set git to use LF - name: Set git to use LF
@@ -79,6 +92,7 @@ jobs:
- name: Test - name: Test
run: | run: |
go version
go run build.go test | go-test-json-to-loki go run build.go test | go-test-json-to-loki
env: env:
GOFLAGS: "-json" GOFLAGS: "-json"
@@ -155,7 +169,9 @@ jobs:
- uses: actions/setup-go@v4 - uses: actions/setup-go@v4
with: with:
go-version: ${{ env.GO_VERSION }} # Temporary version constraint to avoid 1.21.4 which has a bug in
# path handling. This can be removed when 1.21.5 is released.
go-version: "~1.21.5 || ~1.21.1 <1.21.4"
cache: false cache: false
check-latest: true check-latest: true
+2 -3
View File
@@ -38,9 +38,8 @@ syncthing.config(function ($httpProvider, $translateProvider, LocaleServiceProvi
return; return;
} }
var deviceIDShort = metadata.deviceID.substr(0, 5); $httpProvider.defaults.xsrfHeaderName = 'X-CSRF-Token-' + metadata.deviceIDShort;
$httpProvider.defaults.xsrfHeaderName = 'X-CSRF-Token-' + deviceIDShort; $httpProvider.defaults.xsrfCookieName = 'CSRF-Token-' + metadata.deviceIDShort;
$httpProvider.defaults.xsrfCookieName = 'CSRF-Token-' + deviceIDShort;
}); });
// @TODO: extract global level functions into separate service(s) // @TODO: extract global level functions into separate service(s)
+4 -3
View File
@@ -365,15 +365,15 @@ func (s *service) Serve(ctx context.Context) error {
// Wrap everything in CSRF protection. The /rest prefix should be // Wrap everything in CSRF protection. The /rest prefix should be
// protected, other requests will grant cookies. // protected, other requests will grant cookies.
var handler http.Handler = newCsrfManager(s.id.String()[:5], "/rest", guiCfg, mux, locations.Get(locations.CsrfTokens)) var handler http.Handler = newCsrfManager(s.id.Short().String(), "/rest", guiCfg, mux, locations.Get(locations.CsrfTokens))
// Add our version and ID as a header to responses // Add our version and ID as a header to responses
handler = withDetailsMiddleware(s.id, handler) handler = withDetailsMiddleware(s.id, handler)
// Wrap everything in basic auth, if user/password is set. // Wrap everything in basic auth, if user/password is set.
if guiCfg.IsAuthEnabled() { if guiCfg.IsAuthEnabled() {
sessionCookieName := "sessionid-" + s.id.String()[:5] sessionCookieName := "sessionid-" + s.id.Short().String()
handler = basicAuthAndSessionMiddleware(sessionCookieName, guiCfg, s.cfg.LDAP(), handler, s.evLogger) handler = basicAuthAndSessionMiddleware(sessionCookieName, s.id.Short().String(), guiCfg, s.cfg.LDAP(), handler, s.evLogger)
handlePasswordAuth := passwordAuthHandler(sessionCookieName, guiCfg, s.cfg.LDAP(), s.evLogger) handlePasswordAuth := passwordAuthHandler(sessionCookieName, guiCfg, s.cfg.LDAP(), s.evLogger)
restMux.Handler(http.MethodPost, "/rest/noauth/auth/password", handlePasswordAuth) restMux.Handler(http.MethodPost, "/rest/noauth/auth/password", handlePasswordAuth)
@@ -719,6 +719,7 @@ func (*service) getSystemPaths(w http.ResponseWriter, _ *http.Request) {
func (s *service) getJSMetadata(w http.ResponseWriter, _ *http.Request) { func (s *service) getJSMetadata(w http.ResponseWriter, _ *http.Request) {
meta, _ := json.Marshal(map[string]interface{}{ meta, _ := json.Marshal(map[string]interface{}{
"deviceID": s.id.String(), "deviceID": s.id.String(),
"deviceIDShort": s.id.Short().String(),
"authenticated": true, "authenticated": true,
}) })
w.Header().Set("Content-Type", "application/javascript") w.Header().Set("Content-Type", "application/javascript")
+35 -25
View File
@@ -42,8 +42,8 @@ func antiBruteForceSleep() {
time.Sleep(time.Duration(rand.Intn(100)+100) * time.Millisecond) time.Sleep(time.Duration(rand.Intn(100)+100) * time.Millisecond)
} }
func unauthorized(w http.ResponseWriter) { func unauthorized(w http.ResponseWriter, shortID string) {
w.Header().Set("WWW-Authenticate", "Basic realm=\"Authorization Required\"") w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm="Authorization Required (%s)"`, shortID))
http.Error(w, "Not Authorized", http.StatusUnauthorized) http.Error(w, "Not Authorized", http.StatusUnauthorized)
} }
@@ -78,21 +78,26 @@ func isNoAuthPath(path string) bool {
}) })
} }
func basicAuthAndSessionMiddleware(cookieName string, guiCfg config.GUIConfiguration, ldapCfg config.LDAPConfiguration, next http.Handler, evLogger events.Logger) http.Handler { func basicAuthAndSessionMiddleware(cookieName, shortID 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) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if hasValidAPIKeyHeader(r, guiCfg) { if hasValidAPIKeyHeader(r, guiCfg) {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }
cookie, err := r.Cookie(cookieName) for _, cookie := range r.Cookies() {
if err == nil && cookie != nil { // We iterate here since there may, historically, be multiple
sessionsMut.Lock() // cookies with the same name but different path. Any "old" ones
_, ok := sessions[cookie.Value] // won't match an existing session and will be ignored, then
sessionsMut.Unlock() // later removed on logout or when timing out.
if ok { if cookie.Name == cookieName {
next.ServeHTTP(w, r) sessionsMut.Lock()
return _, ok := sessions[cookie.Value]
sessionsMut.Unlock()
if ok {
next.ServeHTTP(w, r)
return
}
} }
} }
@@ -112,7 +117,7 @@ func basicAuthAndSessionMiddleware(cookieName string, guiCfg config.GUIConfigura
// Some browsers don't send the Authorization request header unless prompted by a 401 response. // 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. // This enables https://user:pass@localhost style URLs to keep working.
if guiCfg.SendBasicAuthPrompt { if guiCfg.SendBasicAuthPrompt {
unauthorized(w) unauthorized(w, shortID)
return return
} }
@@ -198,21 +203,26 @@ func createSession(cookieName string, username string, guiCfg config.GUIConfigur
func handleLogout(cookieName string) http.Handler { func handleLogout(cookieName string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(cookieName) for _, cookie := range r.Cookies() {
if err == nil && cookie != nil { // We iterate here since there may, historically, be multiple
sessionsMut.Lock() // cookies with the same name but different path. We drop them
delete(sessions, cookie.Value) // all.
sessionsMut.Unlock() if cookie.Name == cookieName {
sessionsMut.Lock()
delete(sessions, cookie.Value)
sessionsMut.Unlock()
// Delete the cookie
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: "",
MaxAge: -1,
Secure: cookie.Secure,
Path: cookie.Path,
})
}
} }
// 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: "",
MaxAge: -1,
Secure: true,
Path: "/",
})
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
}) })
} }
+9 -4
View File
@@ -13,10 +13,15 @@ import (
"github.com/syncthing/syncthing/lib/sha256" "github.com/syncthing/syncthing/lib/sha256"
) )
const DeviceIDLength = 32 const (
DeviceIDLength = 32
ShortIDStringLength = 7
)
type DeviceID [DeviceIDLength]byte type (
type ShortID uint64 DeviceID [DeviceIDLength]byte
ShortID uint64
)
var ( var (
LocalDeviceID = repeatedDeviceID(0xff) LocalDeviceID = repeatedDeviceID(0xff)
@@ -94,7 +99,7 @@ func (s ShortID) String() string {
} }
var bs [8]byte var bs [8]byte
binary.BigEndian.PutUint64(bs[:], uint64(s)) binary.BigEndian.PutUint64(bs[:], uint64(s))
return base32.StdEncoding.EncodeToString(bs[:])[:7] return base32.StdEncoding.EncodeToString(bs[:])[:ShortIDStringLength]
} }
func (n *DeviceID) UnmarshalText(bs []byte) error { func (n *DeviceID) UnmarshalText(bs []byte) error {
+1 -2
View File
@@ -1,8 +1,7 @@
import { environment } from '../environments/environment' import { environment } from '../environments/environment'
export const deviceID = (): String => { export const deviceID = (): String => {
const dID: String = environment.production ? globalThis.metadata['deviceID'] : '12345'; return environment.production ? globalThis.metadata['deviceIDShort'] : '1234567';
return dID.substring(0, 5)
} }
export const apiURL: String = '/' export const apiURL: String = '/'
+1 -1
View File
@@ -173,7 +173,7 @@ func TestHTTPPOSTWithoutCSRF(t *testing.T) {
} }
res.Body.Close() res.Body.Close()
hdr := res.Header.Get("Set-Cookie") hdr := res.Header.Get("Set-Cookie")
id := res.Header.Get("X-Syncthing-ID")[:5] id := res.Header.Get("X-Syncthing-ID")[:protocol.ShortIDStringLength]
if !strings.Contains(hdr, "CSRF-Token") { if !strings.Contains(hdr, "CSRF-Token") {
t.Error("Missing CSRF-Token in", hdr) t.Error("Missing CSRF-Token in", hdr)
} }