Skip to content

Commit 9c093af

Browse files
committed
oidc: harden callback CSRF cookies, state reuse, and issuer config
Defence-in-depth fixes to the OIDC login flow. Set the state/nonce cookie Secure flag from the configured server_url scheme rather than req.TLS, so the cookies stay Secure behind a TLS-terminating reverse proxy where the proxy-to-Headscale hop is plain HTTP. Deriving it from config avoids trusting a spoofable X-Forwarded-Proto header. Make the OIDC state single-use: consume it from the cache on the callback and clear the state/nonce cookies once validated, so a replayed callback cannot resolve the same session and the cookies do not linger until expiry. Bound OIDC discovery to the caller's context so a slow or unreachable issuer fails startup within the timeout instead of hanging, and validate the issuer URL and required client_id/client_secret at config load so an unworkable setup fails fast.
1 parent 0b9b667 commit 9c093af

4 files changed

Lines changed: 213 additions & 17 deletions

File tree

hscontrol/oidc.go

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,8 @@ func NewAuthProviderOIDC(
8585
serverURL string,
8686
cfg *types.OIDCConfig,
8787
) (*AuthProviderOIDC, error) {
88-
var err error
89-
// grab oidc config if it hasn't been already
88+
// Use the caller's context (bounded, see app.go) so a slow or unreachable
89+
// issuer fails discovery within the timeout instead of hanging startup.
9090
oidcProvider, err := oidc.NewProvider(ctx, cfg.Issuer)
9191
if err != nil {
9292
return nil, fmt.Errorf("creating OIDC provider from issuer config: %w", err)
@@ -117,6 +117,15 @@ func NewAuthProviderOIDC(
117117
}, nil
118118
}
119119

120+
// cookiesSecure reports whether the OIDC cookies should carry the Secure flag.
121+
// It keys off the configured server_url scheme, not req.TLS, so cookies stay
122+
// Secure behind a TLS-terminating reverse proxy (where the proxy→Headscale hop
123+
// is plain HTTP and req.TLS is nil). Deriving it from config avoids trusting a
124+
// spoofable X-Forwarded-Proto header.
125+
func (a *AuthProviderOIDC) cookiesSecure() bool {
126+
return strings.HasPrefix(a.serverURL, "https://")
127+
}
128+
120129
func (a *AuthProviderOIDC) AuthURL(authID types.AuthID) string {
121130
return authPathURL(a.serverURL, "auth", authID)
122131
}
@@ -156,10 +165,10 @@ func (a *AuthProviderOIDC) authHandler(
156165
}
157166

158167
// Set the state and nonce cookies to protect against CSRF attacks
159-
state := setCSRFCookie(writer, req, "state")
168+
state := setCSRFCookie(writer, req, "state", a.cookiesSecure())
160169

161170
// Set the state and nonce cookies to protect against CSRF attacks
162-
nonce := setCSRFCookie(writer, req, "nonce")
171+
nonce := setCSRFCookie(writer, req, "nonce", a.cookiesSecure())
163172

164173
registrationInfo := AuthInfo{
165174
AuthID: authID,
@@ -263,6 +272,11 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
263272
return
264273
}
265274

275+
// The state/nonce cookies have served their CSRF purpose; clear them so a
276+
// single-use pair does not linger in the browser until MaxAge.
277+
clearOIDCCallbackCookie(writer, stateCookieName)
278+
clearOIDCCallbackCookie(writer, nonceCookieName)
279+
266280
nodeExpiry := a.determineNodeExpiry(idToken.Expiry)
267281

268282
var claims types.OIDCClaims
@@ -589,13 +603,17 @@ func doOIDCAuthorization(
589603
return nil
590604
}
591605

592-
// getAuthInfoFromState retrieves the registration ID from the state.
606+
// getAuthInfoFromState retrieves and consumes the auth info for a state. The
607+
// entry is removed on read so a state is single-use: a replayed callback cannot
608+
// resolve the same auth session twice, even within the cache TTL.
593609
func (a *AuthProviderOIDC) getAuthInfoFromState(state string) *AuthInfo {
594610
authInfo, ok := a.authCache.Get(state)
595611
if !ok {
596612
return nil
597613
}
598614

615+
a.authCache.Remove(state)
616+
599617
return &authInfo
600618
}
601619

@@ -658,14 +676,15 @@ func setRegisterConfirmCookie(
658676
authID types.AuthID,
659677
value string,
660678
maxAge int,
679+
secure bool,
661680
) {
662-
//nolint:gosec // G124: Secure set conditionally via req.TLS; HttpOnly + SameSite already set
681+
//nolint:gosec // G124: Secure from server_url scheme or req.TLS; HttpOnly + SameSite already set
663682
http.SetCookie(writer, &http.Cookie{
664683
Name: registerConfirmCSRFCookie,
665684
Value: value,
666685
Path: "/register/confirm/" + authID.String(),
667686
MaxAge: maxAge,
668-
Secure: req.TLS != nil,
687+
Secure: secure || req.TLS != nil,
669688
HttpOnly: true,
670689
SameSite: http.SameSiteStrictMode,
671690
})
@@ -707,7 +726,7 @@ func (a *AuthProviderOIDC) renderRegistrationConfirmInterstitial(
707726
CSRF: csrf,
708727
})
709728

710-
setRegisterConfirmCookie(writer, req, authID, csrf, int(authCacheExpiration.Seconds()))
729+
setRegisterConfirmCookie(writer, req, authID, csrf, int(authCacheExpiration.Seconds()), a.cookiesSecure())
711730

712731
regData := authReq.RegistrationData()
713732

@@ -824,7 +843,7 @@ func (a *AuthProviderOIDC) RegisterConfirmHandler(
824843
}
825844

826845
// Clear the CSRF cookie now that the registration is final.
827-
setRegisterConfirmCookie(writer, req, authID, "", -1)
846+
setRegisterConfirmCookie(writer, req, authID, "", -1, a.cookiesSecure())
828847

829848
content := renderRegistrationSuccessTemplate(user, newNode)
830849

@@ -920,16 +939,27 @@ func getCookieName(baseName, value string) string {
920939
return fmt.Sprintf("%s_%s", baseName, value[:n])
921940
}
922941

923-
func setCSRFCookie(w http.ResponseWriter, r *http.Request, name string) string {
942+
// clearOIDCCallbackCookie expires a /oidc/callback cookie by name. Matching the
943+
// path the cookie was set with is required for the browser to drop it.
944+
func clearOIDCCallbackCookie(w http.ResponseWriter, name string) {
945+
//nolint:gosec // G124: a deletion cookie (empty value, MaxAge<0); security attributes are moot
946+
http.SetCookie(w, &http.Cookie{
947+
Name: name,
948+
Path: "/oidc/callback",
949+
MaxAge: -1,
950+
})
951+
}
952+
953+
func setCSRFCookie(w http.ResponseWriter, r *http.Request, name string, secure bool) string {
924954
val := rands.HexString(64)
925955

926-
//nolint:gosec // G124: Secure set conditionally via r.TLS; HttpOnly + SameSite set below
956+
//nolint:gosec // G124: Secure from server_url scheme or req.TLS; HttpOnly + SameSite set below
927957
c := &http.Cookie{
928958
Path: "/oidc/callback",
929959
Name: getCookieName(name, val),
930960
Value: val,
931961
MaxAge: int(time.Hour.Seconds()),
932-
Secure: r.TLS != nil,
962+
Secure: secure || r.TLS != nil,
933963
HttpOnly: true,
934964
// Lax, not Strict: the OIDC callback is a cross-site top-level GET
935965
// redirect from the IdP that must still carry this cookie. Strict

hscontrol/oidc_test.go

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import (
44
"net/http"
55
"net/http/httptest"
66
"testing"
7+
"time"
78

9+
"github.com/hashicorp/golang-lru/v2/expirable"
810
"github.com/juanfont/headscale/hscontrol/types"
911
"github.com/stretchr/testify/assert"
1012
"github.com/stretchr/testify/require"
@@ -186,10 +188,76 @@ func TestSetCSRFCookieSameSite(t *testing.T) {
186188
w := httptest.NewRecorder()
187189
r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/abcdef0123456789", nil)
188190

189-
setCSRFCookie(w, r, "state")
191+
setCSRFCookie(w, r, "state", false)
190192

191193
cookies := w.Result().Cookies()
192194
require.Len(t, cookies, 1)
193195
assert.Equal(t, http.SameSiteLaxMode, cookies[0].SameSite,
194196
"OIDC CSRF cookie must explicitly set SameSite=Lax")
195197
}
198+
199+
// TestExtractCodeAndStateParam covers the callback's first trust-boundary
200+
// checks: both params required, and a too-short state is rejected before
201+
// getCookieName can slice out of range.
202+
func TestExtractCodeAndStateParam(t *testing.T) {
203+
_, _, err := extractCodeAndStateParamFromRequest(
204+
httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/oidc/callback", nil))
205+
require.Error(t, err)
206+
207+
_, _, err = extractCodeAndStateParamFromRequest(
208+
httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/oidc/callback?code=c&state=abc", nil))
209+
require.ErrorIs(t, err, errOIDCStateTooShort)
210+
211+
code, state, err := extractCodeAndStateParamFromRequest(
212+
httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/oidc/callback?code=c&state=abcdef0123", nil))
213+
require.NoError(t, err)
214+
assert.Equal(t, "c", code)
215+
assert.Equal(t, "abcdef0123", state)
216+
}
217+
218+
// TestGetAuthInfoFromStateSingleUse asserts a consumed OIDC state cannot be
219+
// resolved twice, so a replayed callback cannot re-bind the same session.
220+
func TestGetAuthInfoFromStateSingleUse(t *testing.T) {
221+
a := &AuthProviderOIDC{
222+
authCache: expirable.NewLRU[string, AuthInfo](16, nil, time.Minute),
223+
}
224+
a.authCache.Add("state-x", AuthInfo{Registration: true})
225+
226+
got := a.getAuthInfoFromState("state-x")
227+
require.NotNil(t, got)
228+
assert.True(t, got.Registration)
229+
230+
assert.Nil(t, a.getAuthInfoFromState("state-x"), "a consumed state must not resolve again")
231+
}
232+
233+
// TestClearOIDCCallbackCookie asserts the cookie is expired (negative MaxAge) on
234+
// the same path it was set with, so the browser drops it.
235+
func TestClearOIDCCallbackCookie(t *testing.T) {
236+
w := httptest.NewRecorder()
237+
clearOIDCCallbackCookie(w, "state_abcdef")
238+
239+
cookies := w.Result().Cookies()
240+
require.Len(t, cookies, 1)
241+
assert.Equal(t, "state_abcdef", cookies[0].Name)
242+
assert.Negative(t, cookies[0].MaxAge, "deletion cookie must have negative MaxAge")
243+
}
244+
245+
// TestSetCSRFCookieSecure verifies the Secure flag is driven by the secure
246+
// argument (derived from the configured https server_url), not only req.TLS, so
247+
// cookies stay Secure behind a TLS-terminating reverse proxy where req.TLS is
248+
// nil.
249+
func TestSetCSRFCookieSecure(t *testing.T) {
250+
r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/abcdef0123456789", nil)
251+
252+
secureRec := httptest.NewRecorder()
253+
setCSRFCookie(secureRec, r, "state", true)
254+
require.Len(t, secureRec.Result().Cookies(), 1)
255+
assert.True(t, secureRec.Result().Cookies()[0].Secure,
256+
"https server_url must set Secure even when req.TLS is nil (proxy case)")
257+
258+
plainRec := httptest.NewRecorder()
259+
setCSRFCookie(plainRec, r, "state", false)
260+
require.Len(t, plainRec.Result().Cookies(), 1)
261+
assert.False(t, plainRec.Result().Cookies()[0].Secure,
262+
"plain-http server_url without req.TLS must not set Secure")
263+
}

hscontrol/types/config.go

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ const (
3333

3434
var (
3535
errOidcMutuallyExclusive = errors.New("oidc_client_secret and oidc_client_secret_path are mutually exclusive")
36+
errOIDCIssuerInvalid = errors.New("oidc.issuer must be a valid http(s) URL")
37+
errOIDCClientIDRequired = errors.New("oidc.client_id is required when oidc.issuer is set")
38+
errOIDCClientSecretRequired = errors.New("oidc.client_secret or oidc.client_secret_path is required when oidc.issuer is set")
3639
errServerURLSuffix = errors.New("server_url cannot be part of base_domain in a way that could make the DERP and headscale server unreachable")
3740
errServerURLSame = errors.New("server_url cannot use the same domain as base_domain in a way that could make the DERP and headscale server unreachable")
3841
errInvalidPKCEMethod = errors.New("pkce.method must be either 'plain' or 'S256'")
@@ -363,6 +366,35 @@ func validatePKCEMethod(method string) error {
363366
return nil
364367
}
365368

369+
// validateOIDCConfig validates the OIDC settings, called when oidc.issuer is
370+
// set. It fails fast on a setup that cannot work: an invalid PKCE method, a
371+
// malformed issuer URL (which would otherwise surface as an opaque discovery
372+
// error or, worse, resolve to an unintended provider), or a missing client
373+
// id/secret.
374+
func validateOIDCConfig() error {
375+
err := validatePKCEMethod(viper.GetString("oidc.pkce.method"))
376+
if err != nil {
377+
return err
378+
}
379+
380+
issuer := viper.GetString("oidc.issuer")
381+
382+
u, err := url.Parse(issuer)
383+
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" {
384+
return fmt.Errorf("%w: got %q", errOIDCIssuerInvalid, issuer)
385+
}
386+
387+
if viper.GetString("oidc.client_id") == "" {
388+
return errOIDCClientIDRequired
389+
}
390+
391+
if viper.GetString("oidc.client_secret") == "" && viper.GetString("oidc.client_secret_path") == "" {
392+
return errOIDCClientSecretRequired
393+
}
394+
395+
return nil
396+
}
397+
366398
// Domain returns the hostname/domain part of the [Config.ServerURL].
367399
// If the [Config.ServerURL] is not a valid URL, it returns the [Config.BaseDomain].
368400
func (c *Config) Domain() string {
@@ -564,11 +596,10 @@ func validateServerConfig() error {
564596
depr.fatalIfSet("oidc.expiry", "node.expiry")
565597

566598
// OIDC is activated by setting oidc.issuer (see app.go), not by a
567-
// dedicated oidc.enabled key. Gate PKCE method validation on the real
568-
// activation condition so an invalid method fails at startup instead of
569-
// silently disabling PKCE at runtime.
599+
// dedicated oidc.enabled key. Gate validation on the real activation
600+
// condition so a misconfiguration fails at startup.
570601
if viper.GetString("oidc.issuer") != "" {
571-
err := validatePKCEMethod(viper.GetString("oidc.pkce.method"))
602+
err := validateOIDCConfig()
572603
if err != nil {
573604
return err
574605
}

hscontrol/types/config_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,73 @@ oidc:
444444
assert.Contains(t, err.Error(), errInvalidPKCEMethod.Error())
445445
}
446446

447+
// TestOIDCConfigValidation covers the issuer-URL and required-field checks that
448+
// fail an unworkable OIDC setup fast at config load.
449+
func TestOIDCConfigValidation(t *testing.T) {
450+
tests := []struct {
451+
name string
452+
oidcBlock string
453+
wantErr string
454+
}{
455+
{
456+
name: "non-http issuer",
457+
oidcBlock: `
458+
issuer: ftp://idp.example.com
459+
client_id: headscale
460+
client_secret: sekret`,
461+
wantErr: "valid http(s) URL",
462+
},
463+
{
464+
name: "missing client_id",
465+
oidcBlock: `
466+
issuer: https://idp.example.com
467+
client_secret: sekret`,
468+
wantErr: "client_id is required",
469+
},
470+
{
471+
name: "missing client_secret",
472+
oidcBlock: `
473+
issuer: https://idp.example.com
474+
client_id: headscale`,
475+
wantErr: "client_secret",
476+
},
477+
{
478+
name: "valid",
479+
oidcBlock: `
480+
issuer: https://idp.example.com
481+
client_id: headscale
482+
client_secret: sekret`,
483+
wantErr: "",
484+
},
485+
}
486+
487+
for _, tt := range tests {
488+
t.Run(tt.name, func(t *testing.T) {
489+
tmpDir := t.TempDir()
490+
configYaml := []byte(`---
491+
noise:
492+
private_key_path: noise_private.key
493+
server_url: http://127.0.0.1:8080
494+
dns:
495+
override_local_dns: false
496+
oidc:` + tt.oidcBlock + "\n")
497+
498+
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "config.yaml"), configYaml, 0o600))
499+
require.NoError(t, LoadConfig(tmpDir, false))
500+
501+
err := validateServerConfig()
502+
if tt.wantErr == "" {
503+
require.NoError(t, err)
504+
505+
return
506+
}
507+
508+
require.Error(t, err)
509+
assert.Contains(t, err.Error(), tt.wantErr)
510+
})
511+
}
512+
}
513+
447514
// OK
448515
// server_url: headscale.com, base: clients.headscale.com
449516
// server_url: headscale.com, base: headscale.net

0 commit comments

Comments
 (0)