mirror of
https://github.com/juanfont/headscale.git
synced 2026-09-26 02:04:53 +09:00
2da47a77d6
The interstitial was the body of /oidc/callback, the URL carrying the
single-use code, so any reload re-entered the spent exchange. Redirect to
GET /register/confirm/{auth_id}, also missing from the route table.
0.29 lacks the authPathURL helper from main, so it is added here.
(cherry picked from commit 6d377b5348)
338 lines
12 KiB
Go
338 lines
12 KiB
Go
package hscontrol
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
|
|
grpcRuntime "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
|
"github.com/juanfont/headscale/hscontrol/types"
|
|
"github.com/oauth2-proxy/mockoidc"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"tailscale.com/types/key"
|
|
)
|
|
|
|
// oidcBrowser drives the interactive OIDC registration flow the way a
|
|
// browser does: over real HTTP against the real route table, through a
|
|
// cookie jar that honours Path and expiry, following redirects.
|
|
//
|
|
// Both halves matter for this bug. The route table is where the
|
|
// confirmation page's missing GET route lives, and cookie Path decides
|
|
// whether the state cookie the callback deletes is actually gone on the
|
|
// next hit.
|
|
type oidcBrowser struct {
|
|
app *Headscale
|
|
idp *mockoidc.MockOIDC
|
|
srv *httptest.Server
|
|
client *http.Client
|
|
}
|
|
|
|
func newOIDCBrowser(t *testing.T) *oidcBrowser {
|
|
t.Helper()
|
|
|
|
idp, err := mockoidc.Run()
|
|
require.NoError(t, err)
|
|
|
|
t.Cleanup(func() {
|
|
_ = idp.Shutdown()
|
|
})
|
|
|
|
app := createTestApp(t)
|
|
|
|
// The provider derives its OIDC redirect_uri from the server URL, and
|
|
// the server serves the router the provider is registered on, so bind
|
|
// the router late to break the cycle.
|
|
var router http.Handler
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
router.ServeHTTP(w, r)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
provider, err := NewAuthProviderOIDC(
|
|
context.Background(),
|
|
app,
|
|
srv.URL,
|
|
&types.OIDCConfig{
|
|
Issuer: idp.Issuer(),
|
|
ClientID: idp.ClientID,
|
|
ClientSecret: idp.ClientSecret,
|
|
Scope: []string{"openid", "profile", "email"},
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
app.authProvider = provider
|
|
router = app.createRouter(grpcRuntime.NewServeMux())
|
|
|
|
jar, err := cookiejar.New(nil)
|
|
require.NoError(t, err)
|
|
|
|
return &oidcBrowser{
|
|
app: app,
|
|
idp: idp,
|
|
srv: srv,
|
|
client: &http.Client{Jar: jar},
|
|
}
|
|
}
|
|
|
|
// get fetches a URL, follows redirects, and returns the status, the URL
|
|
// the browser ended up on, and the page body.
|
|
func (b *oidcBrowser) get(t *testing.T, rawURL string) (int, *url.URL, string) {
|
|
t.Helper()
|
|
|
|
resp, err := b.client.Get(rawURL) //nolint:noctx,bodyclose // test client; closed below
|
|
require.NoError(t, err)
|
|
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
|
|
return resp.StatusCode, resp.Request.URL, string(body)
|
|
}
|
|
|
|
// pendingNode mints a pending node registration and returns the URL the
|
|
// tailscale client would print for the user to open.
|
|
func (b *oidcBrowser) pendingNode(t *testing.T) (types.AuthID, string) {
|
|
t.Helper()
|
|
|
|
authID := types.MustAuthID()
|
|
b.app.state.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
|
|
MachineKey: key.NewMachine().Public(),
|
|
NodeKey: key.NewNode().Public(),
|
|
Hostname: "reload-victim",
|
|
}))
|
|
|
|
return authID, b.srv.URL + "/register/" + authID.String()
|
|
}
|
|
|
|
var (
|
|
csrfInputRe = regexp.MustCompile(
|
|
`name="` + registerConfirmCSRFCookie + `"[^>]*value="([^"]+)"`,
|
|
)
|
|
formActionRe = regexp.MustCompile(`action="([^"]+)"`)
|
|
)
|
|
|
|
// TestOIDCLoginDoesNotParkTheBrowserOnTheCodeURL reproduces
|
|
// https://github.com/juanfont/headscale/issues/3365.
|
|
//
|
|
// The interactive OIDC flow ends with the browser sitting on the
|
|
// confirmation interstitial, waiting for the user to click. Today that
|
|
// interstitial is written as the body of the /oidc/callback response, so
|
|
// the URL the browser is parked on is the one carrying the single-use
|
|
// OAuth authorization code. Reloading it re-enters the callback, which
|
|
// has already spent the code and deleted the state cookie, and the error
|
|
// page paints over the interstitial. The node is never registered.
|
|
//
|
|
// Adblock Plus is only the loudest trigger — it calls
|
|
// window.location.reload() to apply element-hiding filters. The back
|
|
// button, mobile pull-to-refresh and browser prerendering all aim at the
|
|
// same URL.
|
|
//
|
|
// The property asserted here is the one that fixes the whole class,
|
|
// stated without naming an implementation: wherever the flow leaves the
|
|
// browser, that URL must be free of the authorization code and safe to
|
|
// load again.
|
|
func TestOIDCLoginDoesNotParkTheBrowserOnTheCodeURL(t *testing.T) {
|
|
b := newOIDCBrowser(t)
|
|
|
|
_, registerURL := b.pendingNode(t)
|
|
|
|
status, landed, body := b.get(t, registerURL)
|
|
require.Equal(t, http.StatusOK, status, "the login flow must reach a page")
|
|
require.Contains(t, body, "Confirm node registration",
|
|
"the flow must end on the confirmation interstitial")
|
|
|
|
assert.Empty(t, landed.Query().Get("code"),
|
|
"the browser must not be left parked on the URL carrying the one-time "+
|
|
"OAuth code; any reload of it re-enters the spent callback")
|
|
|
|
reloadedStatus, _, reloadedBody := b.get(t, landed.String())
|
|
|
|
require.Equal(t, http.StatusOK, reloadedStatus,
|
|
"reloading the page the flow left the browser on must re-render it")
|
|
assert.Contains(t, reloadedBody, "Confirm node registration",
|
|
"the reload must show the confirmation interstitial, not an error page")
|
|
}
|
|
|
|
// TestOIDCLoginCompletesAfterReload is the reporters' scenario end to
|
|
// end: the confirmation page is reloaded before the user clicks, and the
|
|
// registration must still complete. One deployment measured login
|
|
// completion falling from 100% to 61-73% across this exact step.
|
|
func TestOIDCLoginCompletesAfterReload(t *testing.T) {
|
|
b := newOIDCBrowser(t)
|
|
|
|
_, registerURL := b.pendingNode(t)
|
|
|
|
status, landed, _ := b.get(t, registerURL)
|
|
require.Equal(t, http.StatusOK, status)
|
|
|
|
// The spurious reload, on whatever URL the flow parked the browser on.
|
|
reloadedStatus, reloadedURL, body := b.get(t, landed.String())
|
|
require.Equal(t, http.StatusOK, reloadedStatus,
|
|
"the page the user is sitting on must survive a reload")
|
|
|
|
csrf := csrfInputRe.FindStringSubmatch(body)
|
|
require.Len(t, csrf, 2, "the reloaded page must still carry a usable confirm form")
|
|
|
|
action := formActionRe.FindStringSubmatch(body)
|
|
require.Len(t, action, 2, "the reloaded page must still carry a form action")
|
|
|
|
confirmURL, err := reloadedURL.Parse(action[1])
|
|
require.NoError(t, err)
|
|
|
|
//nolint:noctx,bodyclose // test client; closed below
|
|
confirmed, err := b.client.PostForm(confirmURL.String(), url.Values{
|
|
registerConfirmCSRFCookie: {csrf[1]},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
defer confirmed.Body.Close()
|
|
|
|
confirmedBody, err := io.ReadAll(confirmed.Body)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, http.StatusOK, confirmed.StatusCode,
|
|
"confirming after a reload must register the node")
|
|
assert.Contains(t, strings.ToLower(string(confirmedBody)), "registered",
|
|
"the user must get the registration success page")
|
|
}
|
|
|
|
// TestRegisterConfirmGETIsNotADeadEnd covers the second, independent way
|
|
// a registration is lost, reported with no ad blocker involved: the
|
|
// confirmation endpoint is POST-only in the route table, so a user who
|
|
// refreshes or navigates back to it gets a bare 405 from the router with
|
|
// no way to recover, while the pending registration sits in the cache
|
|
// unreachable until it expires.
|
|
//
|
|
// This is a route-table gap, so it can only be observed through the real
|
|
// router — calling the handler directly cannot see it.
|
|
func TestRegisterConfirmGETIsNotADeadEnd(t *testing.T) {
|
|
b := newOIDCBrowser(t)
|
|
|
|
authID, registerURL := b.pendingNode(t)
|
|
|
|
// Complete the OIDC leg so there is a pending confirmation to render.
|
|
status, _, _ := b.get(t, registerURL)
|
|
require.Equal(t, http.StatusOK, status)
|
|
|
|
confirmStatus, _, body := b.get(t, b.srv.URL+"/register/confirm/"+authID.String())
|
|
|
|
require.NotEqual(t, http.StatusMethodNotAllowed, confirmStatus,
|
|
"GET on the confirmation URL must not be a dead end for a user who "+
|
|
"refreshes or goes back")
|
|
require.Equal(t, http.StatusOK, confirmStatus,
|
|
"the confirmation page must be reachable by GET")
|
|
assert.Contains(t, body, "Confirm node registration")
|
|
}
|
|
|
|
// TestRegisterConfirmNeedsTheCallbackCookie locks the reason the
|
|
// confirmation step exists. The node being registered knows its own auth
|
|
// ID, so the auth ID alone must never be enough to view the device
|
|
// details or to finalise the registration — only the browser that
|
|
// completed the OIDC login holds the cookie the callback set, and holding
|
|
// it is what authorises the confirm.
|
|
//
|
|
// Without this, an attacker could hand a victim a /register/{auth_id}
|
|
// link for the attacker's own node, let the victim's IdP silently sign
|
|
// in, and then confirm the registration themselves under the victim's
|
|
// identity.
|
|
func TestRegisterConfirmNeedsTheCallbackCookie(t *testing.T) {
|
|
b := newOIDCBrowser(t)
|
|
|
|
authID, registerURL := b.pendingNode(t)
|
|
|
|
status, _, body := b.get(t, registerURL)
|
|
require.Equal(t, http.StatusOK, status)
|
|
|
|
csrf := csrfInputRe.FindStringSubmatch(body)
|
|
require.Len(t, csrf, 2)
|
|
|
|
// A second browser that knows the auth ID, and even the token from the
|
|
// rendered page, but never completed the OIDC login.
|
|
jar, err := cookiejar.New(nil)
|
|
require.NoError(t, err)
|
|
|
|
attacker := &http.Client{Jar: jar}
|
|
confirmURL := b.srv.URL + "/register/confirm/" + authID.String()
|
|
|
|
//nolint:noctx,bodyclose // test client; closed below
|
|
viewed, err := attacker.Get(confirmURL)
|
|
require.NoError(t, err)
|
|
|
|
defer viewed.Body.Close()
|
|
|
|
assert.Equal(t, http.StatusForbidden, viewed.StatusCode,
|
|
"the confirmation page must not render without the callback cookie")
|
|
|
|
//nolint:noctx,bodyclose // test client; closed below
|
|
submitted, err := attacker.PostForm(confirmURL, url.Values{
|
|
registerConfirmCSRFCookie: {csrf[1]},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
defer submitted.Body.Close()
|
|
|
|
assert.Equal(t, http.StatusForbidden, submitted.StatusCode,
|
|
"the registration must not finalise without the callback cookie")
|
|
|
|
cached, ok := b.app.state.GetAuthCacheEntry(authID)
|
|
require.True(t, ok, "the pending registration must survive the attempt")
|
|
assert.NotNil(t, cached.PendingConfirmation(),
|
|
"the pending registration must still be waiting for the real user")
|
|
}
|
|
|
|
// TestSetRegisterConfirmCookieSameSite pins SameSite=Lax. Strict is
|
|
// withheld by browsers that evaluate the whole redirect chain, and this
|
|
// cookie now has to survive the callback's redirect to the confirmation
|
|
// page — a chain that begins cross-site at the identity provider. Lax is
|
|
// still never attached to a cross-site POST, so the confirm submission
|
|
// keeps its protection.
|
|
func TestSetRegisterConfirmCookieSameSite(t *testing.T) {
|
|
a := &AuthProviderOIDC{serverURL: "https://hs.example.com"}
|
|
authID := types.MustAuthID()
|
|
|
|
rec := httptest.NewRecorder()
|
|
a.setRegisterConfirmCookie(rec,
|
|
httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/oidc/callback", nil),
|
|
authID, "token", 900)
|
|
|
|
cookies := rec.Result().Cookies()
|
|
require.Len(t, cookies, 1)
|
|
assert.Equal(t, http.SameSiteLaxMode, cookies[0].SameSite,
|
|
"the confirm cookie must survive the callback redirect")
|
|
assert.True(t, cookies[0].Secure, "https server_url must set Secure")
|
|
assert.Equal(t, "/register/confirm/"+authID.String(), cookies[0].Path)
|
|
}
|
|
|
|
// TestRegisterConfirmURLFollowsServerURLPrefix covers the deployment
|
|
// where a reverse proxy serves Headscale under a path prefix. The
|
|
// redirect target, the form action and the cookie scope are all seen by
|
|
// the browser, so they carry the prefix even though the routed path does
|
|
// not.
|
|
func TestRegisterConfirmURLFollowsServerURLPrefix(t *testing.T) {
|
|
a := &AuthProviderOIDC{serverURL: "https://example.com/hs"}
|
|
authID := types.MustAuthID()
|
|
|
|
assert.Equal(t, "https://example.com/hs/register/confirm/"+authID.String(),
|
|
a.registerConfirmURL(authID))
|
|
|
|
rec := httptest.NewRecorder()
|
|
a.setRegisterConfirmCookie(rec,
|
|
httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/oidc/callback", nil),
|
|
authID, "token", 900)
|
|
|
|
cookies := rec.Result().Cookies()
|
|
require.Len(t, cookies, 1)
|
|
assert.Equal(t, "/hs/register/confirm/"+authID.String(), cookies[0].Path,
|
|
"the cookie must be scoped to the path the browser sees")
|
|
}
|