VYPR
Medium severity4.6NVD Advisory· Published Jun 10, 2026· Updated Jun 10, 2026

nebula-mesh: Session and OIDC state cookies lack the Secure attribute

CVE-2026-48058

Description

Session and OIDC state cookies in Nebula Management lack the Secure attribute, allowing exposure over unencrypted HTTP.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Session and OIDC state cookies in Nebula Management lack the Secure attribute, allowing exposure over unencrypted HTTP.

Vulnerability

internal/web/session.go and internal/web/oidc.go fail to set the Secure attribute on cookies, despite setting HttpOnly and SameSite=Lax. This vulnerability affects all released versions up to v0.3.1 [2, 3]. The issue occurs when the application is accessed via an unencrypted HTTP connection, which can happen due to operator misconfiguration, a mistyped URL, or a reverse proxy not strictly enforcing HTTPS.

Exploitation

An attacker needs to be in a network position to observe unencrypted HTTP traffic to the origin. This can be achieved on a local area network, or through various misconfigurations. The attacker simply needs to capture a single HTTP request to the origin, such as a login attempt using curl, to obtain the session cookie [2, 3].

Impact

Successful exploitation allows an attacker to recover the session cookie, enabling them to impersonate the operator for the cookie's 24-hour Time To Live (TTL). Additionally, the OIDC state cookie, with a shorter 10-minute window, can be exploited for Cross-Site Request Forgery (CSRF) attacks during the OIDC callback phase [2, 3].

Mitigation

Future versions will include an explicit cookie_secure configuration option. This option will be inferred as true when tls_cert and tls_key are configured, and false otherwise. Operators deploying behind a TLS-terminating proxy must explicitly set cookie_secure: true. A fix is available in commit ffdd67d [1]. No specific patch version or release date is provided in the references, but versions up to v0.3.0 [3] and v0.3.1 [2] are affected.

AI Insight generated on Jun 10, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

1

Patches

1
ffdd67dbf221

fix(oidc): harden operator login path + add httptest mock IdP scaffolding (#135)

https://github.com/forgekeep/nebula-meshAdamMay 21, 2026via ghsa-ref
10 files changed · +1192 2
  • go.mod+1 1 modified
    @@ -6,6 +6,7 @@ require (
     	github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc
     	github.com/coreos/go-oidc/v3 v3.18.0
     	github.com/go-chi/chi/v5 v5.2.5
    +	github.com/go-jose/go-jose/v4 v4.1.4
     	github.com/google/uuid v1.6.0
     	github.com/pquerna/otp v1.5.0
     	github.com/prometheus/client_golang v1.23.2
    @@ -24,7 +25,6 @@ require (
     	github.com/cespare/xxhash/v2 v2.3.0 // indirect
     	github.com/davecgh/go-spew v1.1.1 // indirect
     	github.com/dustin/go-humanize v1.0.1 // indirect
    -	github.com/go-jose/go-jose/v4 v4.1.4 // indirect
     	github.com/kr/text v0.2.0 // indirect
     	github.com/mattn/go-isatty v0.0.20 // indirect
     	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
    
  • internal/cli/serve.go+12 0 modified
    @@ -221,6 +221,18 @@ func Serve(configPath string) error {
     		}
     		webUI.WithOIDC(oidcProvider)
     		logger.Info("oidc enabled", "issuer", cfg.OIDC.Issuer)
    +		// Surface the relaxed-posture deployment in startup logs so the
    +		// operator-of-operator sees that email_verified is being skipped.
    +		// Silent bypass is a real risk: a hostile or coerced deployer
    +		// could flip the bit, register an unverified-email account at a
    +		// permitted domain on a hostile IdP, and log in as a legitimate
    +		// operator. Matches dex's `insecureSkipEmailVerified` posture
    +		// (logs loudly when relaxed).
    +		if !cfg.OIDC.EmailVerifiedRequired() {
    +			logger.Warn("oidc email_verified check disabled",
    +				"oidc_issuer", cfg.OIDC.Issuer,
    +				"hint", "set oidc.require_email_verified: true (default) to re-enable")
    +		}
     	}
     
     	// Resolve cookie_secure AFTER any OIDC wiring so the OIDC state cookie
    
  • internal/config/oidc_validate_test.go+132 0 modified
    @@ -3,6 +3,8 @@ package config
     import (
     	"strings"
     	"testing"
    +
    +	"gopkg.in/yaml.v3"
     )
     
     func TestOIDCConfig_Validate(t *testing.T) {
    @@ -84,3 +86,133 @@ func TestOIDCConfig_Validate_ErrorMessageNamesFields(t *testing.T) {
     		}
     	}
     }
    +
    +// TestOIDCConfig_RequireEmailVerified_YAMLRoundTrip pins yaml.v3's
    +// decoding of the `require_email_verified` knob. The field is a
    +// *bool so the unset case is distinguishable from explicit `false`
    +// — EmailVerifiedRequired() must default to `true` when the field
    +// is nil, and only return `false` for an explicit `false` value.
    +//
    +// The quoted-bool case (`"false"`) is the interesting one: yaml.v3
    +// is strict about !!str → !!bool coercion and rejects it with a
    +// type-error rather than silently treating it as false. Pinning the
    +// behavior here means a future YAML-library swap (or yaml.v3 major
    +// version bump) that started accepting quoted bools as truthy would
    +// be caught by CI instead of silently downgrading the gate.
    +func TestOIDCConfig_RequireEmailVerified_YAMLRoundTrip(t *testing.T) {
    +	cases := []struct {
    +		name              string
    +		yamlSnippet       string
    +		wantParseError    bool
    +		wantPointerNil    bool
    +		wantRequired      bool // value of EmailVerifiedRequired() after parse (only checked if no parse error)
    +	}{
    +		{
    +			name:              "unset_defaults_to_required",
    +			yamlSnippet:       `enabled: true`,
    +			wantPointerNil:    true,
    +			wantRequired:      true,
    +		},
    +		{
    +			name:              "bare_true_keeps_required",
    +			yamlSnippet:       "enabled: true\nrequire_email_verified: true\n",
    +			wantPointerNil:    false,
    +			wantRequired:      true,
    +		},
    +		{
    +			name:              "bare_false_opts_out",
    +			yamlSnippet:       "enabled: true\nrequire_email_verified: false\n",
    +			wantPointerNil:    false,
    +			wantRequired:      false,
    +		},
    +		{
    +			// yaml.v3 default mode: !!str cannot unmarshal into
    +			// a *bool, so the quoted form errors out. The error
    +			// surfaces to the operator at LoadServerConfig time
    +			// — louder failure than silently treating the
    +			// quoted form as a bool of either polarity.
    +			name:           "quoted_false_errors",
    +			yamlSnippet:    "enabled: true\nrequire_email_verified: \"false\"\n",
    +			wantParseError: true,
    +		},
    +		{
    +			// Same posture for quoted "true": yaml.v3 errors
    +			// rather than silently accepting it as truthy.
    +			name:           "quoted_true_errors",
    +			yamlSnippet:    "enabled: true\nrequire_email_verified: \"true\"\n",
    +			wantParseError: true,
    +		},
    +		{
    +			// yaml.v3 still accepts YAML 1.1 truthy strings
    +			// (yes/no/on/off/y/n) as bools when the target is
    +			// *bool. Operators who write `require_email_verified:
    +			// no` thinking it's a quoted string will silently
    +			// land in bypass mode. Pinning so the surprising
    +			// behavior is at least visible in tests — a future
    +			// yaml-lib swap that rejected these would actually
    +			// be safer, not a regression.
    +			name:           "yaml_1_1_no_accepted_as_bypass",
    +			yamlSnippet:    "enabled: true\nrequire_email_verified: no\n",
    +			wantPointerNil: false,
    +			wantRequired:   false,
    +		},
    +		{
    +			name:           "yaml_1_1_yes_accepted_as_required",
    +			yamlSnippet:    "enabled: true\nrequire_email_verified: yes\n",
    +			wantPointerNil: false,
    +			wantRequired:   true,
    +		},
    +		{
    +			name:           "yaml_1_1_off_accepted_as_bypass",
    +			yamlSnippet:    "enabled: true\nrequire_email_verified: off\n",
    +			wantPointerNil: false,
    +			wantRequired:   false,
    +		},
    +	}
    +	for _, tc := range cases {
    +		t.Run(tc.name, func(t *testing.T) {
    +			var cfg OIDCConfig
    +			err := yaml.Unmarshal([]byte(tc.yamlSnippet), &cfg)
    +			if tc.wantParseError {
    +				if err == nil {
    +					t.Fatalf("expected parse error, got nil; cfg.RequireEmailVerified=%v EmailVerifiedRequired=%v", cfg.RequireEmailVerified, cfg.EmailVerifiedRequired())
    +				}
    +				return
    +			}
    +			if err != nil {
    +				t.Fatalf("unexpected parse error: %v", err)
    +			}
    +			if (cfg.RequireEmailVerified == nil) != tc.wantPointerNil {
    +				t.Errorf("RequireEmailVerified pointer nil=%v, want nil=%v", cfg.RequireEmailVerified == nil, tc.wantPointerNil)
    +			}
    +			if got := cfg.EmailVerifiedRequired(); got != tc.wantRequired {
    +				t.Errorf("EmailVerifiedRequired() = %v, want %v", got, tc.wantRequired)
    +			}
    +		})
    +	}
    +}
    +
    +// TestOIDCConfig_EmailVerifiedRequired_DefaultsToTrue pins the
    +// accessor's behavior on nil receiver and nil field, so a future
    +// refactor that changes the default cannot do so silently.
    +func TestOIDCConfig_EmailVerifiedRequired_DefaultsToTrue(t *testing.T) {
    +	cases := []struct {
    +		name string
    +		cfg  *OIDCConfig
    +		want bool
    +	}{
    +		{"nil_receiver", nil, true},
    +		{"empty_config", &OIDCConfig{}, true},
    +		{"explicit_true", &OIDCConfig{RequireEmailVerified: ptrBool(true)}, true},
    +		{"explicit_false", &OIDCConfig{RequireEmailVerified: ptrBool(false)}, false},
    +	}
    +	for _, tc := range cases {
    +		t.Run(tc.name, func(t *testing.T) {
    +			if got := tc.cfg.EmailVerifiedRequired(); got != tc.want {
    +				t.Errorf("EmailVerifiedRequired() = %v, want %v", got, tc.want)
    +			}
    +		})
    +	}
    +}
    +
    +func ptrBool(b bool) *bool { return &b }
    
  • internal/config/server.go+19 0 modified
    @@ -217,6 +217,25 @@ type OIDCConfig struct {
     	AllowedGroups []string `yaml:"allowed_groups,omitempty"`
     	AllowedEmails []string `yaml:"allowed_emails,omitempty"`
     	DefaultRole   string   `yaml:"default_role,omitempty"`
    +
    +	// RequireEmailVerified gates the post-callback email_verified claim
    +	// check. Pointer-bool to distinguish unset (default true: the IdP
    +	// must assert email_verified before the address counts toward
    +	// AllowedEmails) from an explicit `false` opt-out. The explicit
    +	// opt-out is the escape hatch for legacy IdPs that omit the claim
    +	// or send it in a shape HandleCallback can't decode (numeric,
    +	// nested object, etc). emailVerifiedRequired() resolves nil → true.
    +	RequireEmailVerified *bool `yaml:"require_email_verified,omitempty"`
    +}
    +
    +// EmailVerifiedRequired reports whether HandleCallback must enforce the
    +// email_verified claim. Defaults to true when RequireEmailVerified is
    +// unset.
    +func (o *OIDCConfig) EmailVerifiedRequired() bool {
    +	if o == nil || o.RequireEmailVerified == nil {
    +		return true
    +	}
    +	return *o.RequireEmailVerified
     }
     
     // Validate refuses configurations that would silently auto-provision the
    
  • internal/web/oidc_email_verified_test.go+330 0 added
    @@ -0,0 +1,330 @@
    +package web
    +
    +import (
    +	"context"
    +	"encoding/json"
    +	"net/http"
    +	"strings"
    +	"testing"
    +
    +	"github.com/juev/nebula-mesh/internal/config"
    +	"github.com/juev/nebula-mesh/internal/models"
    +	"github.com/juev/nebula-mesh/internal/store"
    +)
    +
    +// TestOIDC_HandleCallback_EmailVerified covers the email_verified claim
    +// enforcement: a permitted email at a permitted domain must not satisfy
    +// AllowedEmails when the IdP has not asserted the address is verified.
    +//
    +// Default-deny posture: missing claim is treated the same as explicit
    +// false. Matches dexidp/dex's connector posture (go-oidc itself does
    +// not enforce this claim — the RP must).
    +//
    +// The string-encoding rows ("true"/"TRUE"/"false") cover real IdP
    +// shapes — Azure AD, Salesforce, older Keycloak — that parseEmailVerified
    +// must accept. Numeric (1) and nil rows confirm the parser stays
    +// default-deny for non-spec shapes.
    +//
    +// The RequireEmailVerified opt-out row covers the escape hatch for
    +// legacy IdPs whose encoding the parser still can't decode: an explicit
    +// `false` on the config field skips the check entirely.
    +func TestOIDC_HandleCallback_EmailVerified(t *testing.T) {
    +	skipCheck := false
    +	cases := []struct {
    +		name             string
    +		emailVerified    any  // value to put under the email_verified key
    +		omitClaim        bool // true = drop the email_verified key entirely
    +		requireOverride  *bool
    +		wantStatus       int
    +		wantBodyContain  string
    +		wantAuditAction  string
    +	}{
    +		{
    +			name:            "verified true succeeds",
    +			emailVerified:   true,
    +			wantStatus:      http.StatusSeeOther,
    +			wantBodyContain: "",
    +			wantAuditAction: "operator.oidc.login",
    +		},
    +		{
    +			name:            "verified false rejects",
    +			emailVerified:   false,
    +			wantStatus:      http.StatusForbidden,
    +			wantBodyContain: "not allowed to log in",
    +			wantAuditAction: "operator.oidc.denied",
    +		},
    +		{
    +			name:            "claim missing rejects",
    +			omitClaim:       true,
    +			wantStatus:      http.StatusForbidden,
    +			wantBodyContain: "not allowed to log in",
    +			wantAuditAction: "operator.oidc.denied",
    +		},
    +		{
    +			name:            "string true succeeds",
    +			emailVerified:   "true",
    +			wantStatus:      http.StatusSeeOther,
    +			wantBodyContain: "",
    +			wantAuditAction: "operator.oidc.login",
    +		},
    +		{
    +			name:            "string TRUE succeeds (case-insensitive)",
    +			emailVerified:   "TRUE",
    +			wantStatus:      http.StatusSeeOther,
    +			wantBodyContain: "",
    +			wantAuditAction: "operator.oidc.login",
    +		},
    +		{
    +			name:            "string false rejects",
    +			emailVerified:   "false",
    +			wantStatus:      http.StatusForbidden,
    +			wantBodyContain: "not allowed to log in",
    +			wantAuditAction: "operator.oidc.denied",
    +		},
    +		{
    +			// idToken.Claims(&map[string]any) routes through
    +			// encoding/json, which decodes JSON numbers as float64
    +			// by default. Production callers never see Go int from
    +			// the wire — use float64 here so the test asserts the
    +			// real-world code path. (Earlier int(1) row passed by
    +			// accident: the parser rejects both int and float64,
    +			// but only float64 reflects what the JSON decoder
    +			// actually produces.)
    +			name:            "numeric float64(1) rejects (non-spec encoding)",
    +			emailVerified:   float64(1),
    +			wantStatus:      http.StatusForbidden,
    +			wantBodyContain: "not allowed to log in",
    +			wantAuditAction: "operator.oidc.denied",
    +		},
    +		{
    +			// Same shape as above with a trailing zero; pinning
    +			// the no-numeric-coercion contract.
    +			name:            "numeric float64(1.0) rejects",
    +			emailVerified:   float64(1.0),
    +			wantStatus:      http.StatusForbidden,
    +			wantBodyContain: "not allowed to log in",
    +			wantAuditAction: "operator.oidc.denied",
    +		},
    +		{
    +			name:            "explicit nil rejects",
    +			emailVerified:   nil,
    +			wantStatus:      http.StatusForbidden,
    +			wantBodyContain: "not allowed to log in",
    +			wantAuditAction: "operator.oidc.denied",
    +		},
    +		{
    +			name:            "RequireEmailVerified=false skips the check",
    +			emailVerified:   false,
    +			requireOverride: &skipCheck,
    +			wantStatus:      http.StatusSeeOther,
    +			wantBodyContain: "",
    +			wantAuditAction: "operator.oidc.login",
    +		},
    +	}
    +
    +	for _, tc := range cases {
    +		t.Run(tc.name, func(t *testing.T) {
    +			_, s := newTestWeb(t)
    +			idp := setupOIDCServer(t)
    +			o := newOIDCFromMock(t, idp, s, config.OIDCConfig{
    +				AllowedEmails:        []string{"alice@example.com"},
    +				DefaultRole:          "user",
    +				RequireEmailVerified: tc.requireOverride,
    +			})
    +
    +			claims := map[string]any{
    +				"sub":                "alice-sub",
    +				"aud":                "test-client",
    +				"email":              "alice@example.com",
    +				"preferred_username": "alice",
    +				"name":               "Alice",
    +			}
    +			if !tc.omitClaim {
    +				claims["email_verified"] = tc.emailVerified
    +			}
    +			idp.NextIDToken(claims)
    +
    +			rec := driveCallback(t, o, "state-emailverified", "code-1")
    +			if rec.Code != tc.wantStatus {
    +				t.Errorf("status = %d, want %d (body: %s)", rec.Code, tc.wantStatus, rec.Body.String())
    +			}
    +			if tc.wantBodyContain != "" && !strings.Contains(rec.Body.String(), tc.wantBodyContain) {
    +				t.Errorf("body = %q, want substring %q", rec.Body.String(), tc.wantBodyContain)
    +			}
    +
    +			// Confirm an audit row of the expected shape landed. The
    +			// store is in-memory and small; a list-and-scan is fine.
    +			rows, err := s.ListAuditEntries(context.Background(), store.AuditFilter{Limit: 10})
    +			if err != nil {
    +				t.Fatalf("ListAuditEntries: %v", err)
    +				return
    +			}
    +			found := false
    +			for _, row := range rows {
    +				if row.Action == tc.wantAuditAction {
    +					found = true
    +					break
    +				}
    +			}
    +			if !found {
    +				t.Errorf("expected audit action %q, got rows: %+v", tc.wantAuditAction, rows)
    +			}
    +		})
    +	}
    +}
    +
    +// TestParseEmailVerified exercises the parser at the function boundary
    +// rather than through the full callback path, so it can pin shapes that
    +// the JWT decode pipeline collapses (json.Number → float64 after a JSON
    +// round-trip) before they reach the parser. Production callers receive
    +// values from `idToken.Claims(&map[string]any{})`, which routes through
    +// encoding/json; JSON numbers arrive as float64. UseNumber would change
    +// that — a future refactor flipping it should still see json.Number
    +// rejected.
    +//
    +// The strict posture is load-bearing per the parser's doc comment:
    +// every widening is a security-sensitive change. This test pins the
    +// rejection of every non-spec shape we've considered.
    +func TestParseEmailVerified(t *testing.T) {
    +	cases := []struct {
    +		name string
    +		raw  any
    +		want bool
    +	}{
    +		// Accepted (spec or documented compatibility shape):
    +		{"bool_true", true, true},
    +		{"bool_false", false, false},
    +		{"string_true", "true", true},
    +		{"string_false", "false", false},
    +		{"string_True_mixed_case", "True", true},
    +		{"string_TRUE_upper", "TRUE", true},
    +		{"string_False_mixed_case", "False", false},
    +
    +		// Rejected — non-spec shapes that production claim-decoding
    +		// can actually emit:
    +		{"float64_1_rejects", float64(1), false},
    +		{"float64_1.0_rejects", float64(1.0), false},
    +		{"float64_0_rejects", float64(0), false},
    +		// json.Number is what a json.Decoder with UseNumber() would
    +		// yield. The production decoder doesn't use it today, but
    +		// the parser's contract must still reject the shape.
    +		{"json_Number_1_rejects", json.Number("1"), false},
    +		{"json_Number_0_rejects", json.Number("0"), false},
    +
    +		// Rejected — non-spec shapes from defensive coverage:
    +		{"int_1_rejects", int(1), false},
    +		{"int_0_rejects", int(0), false},
    +		{"string_1_rejects", "1", false},
    +		{"string_yes_rejects", "yes", false},
    +		{"string_on_rejects", "on", false},
    +		{"whitespace_padded_true_rejects", " true ", false},
    +		{"nested_object_rejects", map[string]any{"value": true}, false},
    +		{"slice_rejects", []any{true}, false},
    +		{"nil_rejects", nil, false},
    +	}
    +	for _, tc := range cases {
    +		t.Run(tc.name, func(t *testing.T) {
    +			if got := parseEmailVerified(tc.raw); got != tc.want {
    +				t.Errorf("parseEmailVerified(%T %v) = %v, want %v", tc.raw, tc.raw, got, tc.want)
    +			}
    +		})
    +	}
    +}
    +
    +// TestOIDC_HandleCallback_EmailVerifiedBypass_AuditsAcceptance pins the
    +// per-login audit shape when `RequireEmailVerified=false`. The bypass is
    +// not silent: every callback whose `email_verified` claim would otherwise
    +// have failed the gate is tagged with `email_unverified_accepted=true` on
    +// the success-path `operator.oidc.login` row. Combined with the startup
    +// WARN log, this lets a forensics reader distinguish bypass-logins from
    +// verified-and-passed logins via a single substring grep against an
    +// existing audit action.
    +//
    +// Coverage matrix:
    +//   - email_verified=false → audit row tagged
    +//   - email_verified missing → audit row tagged
    +//   - email_verified=true with bypass enabled → row NOT tagged (the
    +//     bypass is only meaningful when the claim would have failed; a
    +//     verified login under a relaxed policy is still a verified login)
    +func TestOIDC_HandleCallback_EmailVerifiedBypass_AuditsAcceptance(t *testing.T) {
    +	skipCheck := false
    +	cases := []struct {
    +		name          string
    +		emailVerified any
    +		omitClaim     bool
    +		wantTagged    bool // success-path row should carry `email_unverified_accepted=true`
    +	}{
    +		{
    +			name:          "bypass_with_false_claim_tags_audit",
    +			emailVerified: false,
    +			wantTagged:    true,
    +		},
    +		{
    +			name:       "bypass_with_missing_claim_tags_audit",
    +			omitClaim:  true,
    +			wantTagged: true,
    +		},
    +		{
    +			name:          "bypass_with_verified_true_does_not_tag",
    +			emailVerified: true,
    +			wantTagged:    false,
    +		},
    +	}
    +
    +	for _, tc := range cases {
    +		t.Run(tc.name, func(t *testing.T) {
    +			_, s := newTestWeb(t)
    +			idp := setupOIDCServer(t)
    +			o := newOIDCFromMock(t, idp, s, config.OIDCConfig{
    +				AllowedEmails:        []string{"alice@example.com"},
    +				DefaultRole:          "user",
    +				RequireEmailVerified: &skipCheck,
    +			})
    +
    +			claims := map[string]any{
    +				"sub":                "alice-sub",
    +				"aud":                "test-client",
    +				"email":              "alice@example.com",
    +				"preferred_username": "alice",
    +				"name":               "Alice",
    +			}
    +			if !tc.omitClaim {
    +				claims["email_verified"] = tc.emailVerified
    +			}
    +			idp.NextIDToken(claims)
    +
    +			rec := driveCallback(t, o, "state-bypass-audit", "code-bypass")
    +			if rec.Code != http.StatusSeeOther {
    +				t.Fatalf("status = %d, want %d (body: %s)", rec.Code, http.StatusSeeOther, rec.Body.String())
    +			}
    +
    +			rows, err := s.ListAuditEntries(context.Background(), store.AuditFilter{Limit: 10})
    +			if err != nil {
    +				t.Fatalf("ListAuditEntries: %v", err)
    +				return
    +			}
    +			var loginRow *models.AuditEntry
    +			for _, row := range rows {
    +				if row.Action == "operator.oidc.login" {
    +					loginRow = row
    +					break
    +				}
    +			}
    +			if loginRow == nil {
    +				t.Fatalf("no operator.oidc.login audit row found; rows: %+v", rows)
    +				return
    +			}
    +			gotTagged := strings.Contains(loginRow.Details, "email_unverified_accepted=true")
    +			t.Logf("audit row: action=%q resource=%q details=%q", loginRow.Action, loginRow.Resource, loginRow.Details)
    +			if gotTagged != tc.wantTagged {
    +				t.Errorf("audit row tagged=%v, want %v; details=%q", gotTagged, tc.wantTagged, loginRow.Details)
    +			}
    +			// On bypass-tagged rows the issuer must still be present as
    +			// a prefix — the tag is additive, not a replacement, so
    +			// existing queries that filter by issuer keep working.
    +			if tc.wantTagged && !strings.HasPrefix(loginRow.Details, idp.Issuer()) {
    +				t.Errorf("audit details = %q, want prefix %q (issuer)", loginRow.Details, idp.Issuer())
    +			}
    +		})
    +	}
    +}
    
  • internal/web/oidc.go+113 1 modified
    @@ -126,6 +126,17 @@ func (o *OIDC) HandleLogin(rw http.ResponseWriter, r *http.Request) {
     // establishes a session cookie.
     func (o *OIDC) HandleCallback(rw http.ResponseWriter, r *http.Request) {
     	if errParam := r.URL.Query().Get("error"); errParam != "" {
    +		// Consume the matching state before the early return so an
    +		// attacker who induces an IdP error on the first callback hit
    +		// cannot replay the same state on a follow-up callback with a
    +		// valid code. Missing-cookie / mismatched-state cases short-
    +		// circuit through the same code path: nothing was consumed, but
    +		// nothing was usable either.
    +		if cookie, err := r.Cookie(oidcStateCookieName); err == nil && cookie.Value != "" {
    +			if state := r.URL.Query().Get("state"); state != "" && cookie.Value == state {
    +				_ = o.consumeState(state)
    +			}
    +		}
     		desc := r.URL.Query().Get("error_description")
     		o.logger.Warn("oidc provider error", "error", errParam, "description", desc)
     		http.Error(rw, "oidc provider error: "+errParam, http.StatusBadRequest)
    @@ -207,6 +218,37 @@ func (o *OIDC) HandleCallback(rw http.ResponseWriter, r *http.Request) {
     	subject := idToken.Subject
     	issuer := idToken.Issuer
     
    +	// If the IdP supplied an email claim, refuse to consume it unless the
    +	// IdP also asserts the address is verified. Default-deny when
    +	// `email_verified` is missing or false: an attacker who registers an
    +	// unverified address at a permitted domain on the IdP would otherwise
    +	// satisfy AllowedEmails. Matches dex's connector posture; go-oidc does
    +	// not enforce this claim itself.
    +	//
    +	// Operators can disable the check entirely via
    +	// oidc.require_email_verified: false — the escape hatch for legacy
    +	// IdPs whose claim encoding parseEmailVerified can't decode. The
    +	// bypass is not silent: a per-login audit row tags the success path
    +	// with `email_unverified_accepted=true` so forensics can distinguish
    +	// bypass logins from verified ones via a single grep against the
    +	// existing `operator.oidc.login` action. Startup also emits a WARN
    +	// log surfacing the relaxed posture.
    +	emailUnverifiedAccepted := false
    +	if email != "" {
    +		if o.cfg.EmailVerifiedRequired() {
    +			if !parseEmailVerified(claims["email_verified"]) {
    +				_ = o.store.AddAuditEntry(r.Context(), username, "operator.oidc.denied", subject, "email_unverified")
    +				http.Error(rw, "your account is not allowed to log in", http.StatusForbidden)
    +				return
    +			}
    +		} else if !parseEmailVerified(claims["email_verified"]) {
    +			// The check is disabled AND the claim would not have
    +			// passed it. Tag the audit row so a bypass-login is
    +			// distinguishable from a verified-and-passed login.
    +			emailUnverifiedAccepted = true
    +		}
    +	}
    +
     	if !o.isAllowed(email, extractGroups(claims, groupsClaim)) {
     		_ = o.store.AddAuditEntry(r.Context(), username, "operator.oidc.denied", subject, email)
     		http.Error(rw, "your account is not allowed to log in", http.StatusForbidden)
    @@ -219,7 +261,16 @@ func (o *OIDC) HandleCallback(rw http.ResponseWriter, r *http.Request) {
     		http.Error(rw, "internal error", http.StatusInternalServerError)
     		return
     	}
    -	_ = o.store.AddAuditEntry(r.Context(), op.Username, "operator.oidc.login", op.ID, issuer)
    +	details := issuer
    +	if emailUnverifiedAccepted {
    +		// Append rather than replace so existing queries against the
    +		// issuer URL keep working; new queries can match the suffix
    +		// `email_unverified_accepted=true` via a LIKE or substring
    +		// check. Mirrors the `predecessor=%s` / `new_key=true` key=val
    +		// shape used elsewhere in the audit log.
    +		details = fmt.Sprintf("%s email_unverified_accepted=true", issuer)
    +	}
    +	_ = o.store.AddAuditEntry(r.Context(), op.Username, "operator.oidc.login", op.ID, details)
     
     	if err := o.session.StartAuthenticatedSession(rw, r, op); err != nil {
     		o.logger.Error("oidc session", "error", err)
    @@ -293,9 +344,25 @@ func (o *OIDC) isAllowed(email string, groups []string) bool {
     func (o *OIDC) rememberState(state string) {
     	o.stateMu.Lock()
     	defer o.stateMu.Unlock()
    +	o.sweepLocked(time.Now())
     	o.states[state] = time.Now().Add(oidcStateTTL)
     }
     
    +// sweepLocked drops every state entry whose expiry is at or before now. The
    +// caller MUST hold stateMu. Called lazily from rememberState so the per-call
    +// cost is amortised across logins and the map cannot grow without bound
    +// when an unauthenticated client bursts /ui/oidc/login. Avoiding a
    +// background goroutine keeps the OIDC type cheap to construct and matches
    +// the project's preference for lazy in-memory cleanup where the hot path
    +// already takes the lock.
    +func (o *OIDC) sweepLocked(now time.Time) {
    +	for s, exp := range o.states {
    +		if !now.Before(exp) {
    +			delete(o.states, s)
    +		}
    +	}
    +}
    +
     func (o *OIDC) consumeState(state string) bool {
     	o.stateMu.Lock()
     	defer o.stateMu.Unlock()
    @@ -307,6 +374,51 @@ func (o *OIDC) consumeState(state string) bool {
     	return !time.Now().After(exp)
     }
     
    +// parseEmailVerified decodes the OIDC `email_verified` claim across the
    +// shapes real-world IdPs send. The spec (OIDC core, section 5.1) calls
    +// for a JSON boolean, but Azure AD, Salesforce, older Keycloak releases
    +// and some SAML→OIDC gateways send a JSON string ("true"/"false"). A
    +// bare bool-typed type assertion fails closed for those callers,
    +// pressuring operators to disable the check entirely — worse outcome
    +// than accepting the documented string form.
    +//
    +// Accepted:
    +//   - bool true / bool false
    +//   - string "true" / "false" (case-insensitive: "True", "TRUE", etc.)
    +//
    +// Default-deny for everything else (nil, missing, numeric 0/1, nested
    +// object). Numeric encoding was raised during review but is rarer than
    +// the string form and violates the spec more clearly; rejecting it
    +// keeps the parser narrow.
    +//
    +// The strict posture is load-bearing and intentional:
    +//   - no whitespace trimming (" true " is rejected)
    +//   - no numeric coercion (1, 1.0, json.Number("1") all rejected — note
    +//     that encoding/json decodes JSON numbers as float64 by default, so
    +//     production callers see float64 not int)
    +//   - no "yes"/"no"/"on"/"off"/"1"/"0" string forms
    +//
    +// Widening the parser is a security-sensitive change: every new accepted
    +// shape is a new opportunity for an attacker-controlled IdP to satisfy
    +// the check with an unverified address. Operators who genuinely need
    +// looser handling should set oidc.require_email_verified: false as a
    +// deliberate opt-out (the bypass is logged at startup and audited per
    +// login).
    +func parseEmailVerified(raw any) bool {
    +	switch v := raw.(type) {
    +	case bool:
    +		return v
    +	case string:
    +		switch strings.ToLower(v) {
    +		case "true":
    +			return true
    +		case "false":
    +			return false
    +		}
    +	}
    +	return false
    +}
    +
     func extractGroups(claims map[string]any, key string) []string {
     	raw, ok := claims[key]
     	if !ok {
    
  • internal/web/oidc_scaffolding_smoke_test.go+51 0 added
    @@ -0,0 +1,51 @@
    +package web
    +
    +import (
    +	"net/http"
    +	"testing"
    +
    +	"github.com/juev/nebula-mesh/internal/config"
    +)
    +
    +// TestOIDC_MockIDP_HappyPath is the scaffolding smoke test: it drives the
    +// full callback flow end-to-end through the mock IdP and asserts that a
    +// session cookie lands. It deliberately omits the email claim so the
    +// allowlist gate, the email_verified gate, and any future email-shaped
    +// checks don't affect the scaffolding's own coverage.
    +func TestOIDC_MockIDP_HappyPath(t *testing.T) {
    +	_, s := newTestWeb(t)
    +	idp := setupOIDCServer(t)
    +	o := newOIDCFromMock(t, idp, s, config.OIDCConfig{
    +		DefaultRole: "user",
    +	})
    +
    +	idp.NextIDToken(map[string]any{
    +		"sub":                "scaffold-sub",
    +		"aud":                "test-client",
    +		"preferred_username": "scaffold-user",
    +		"name":               "Scaffold User",
    +	})
    +
    +	rec := driveCallback(t, o, "state-scaffold", "code-scaffold")
    +	if rec.Code != http.StatusSeeOther {
    +		t.Fatalf("status = %d, want 303 (body: %s)", rec.Code, rec.Body.String())
    +	}
    +
    +	// Session cookie must be set as a side effect of the successful flow.
    +	var sessionSet bool
    +	for _, c := range rec.Result().Cookies() {
    +		if c.Name == sessionCookieName && c.Value != "" {
    +			sessionSet = true
    +			break
    +		}
    +	}
    +	if !sessionSet {
    +		t.Error("expected session cookie after successful OIDC callback")
    +	}
    +
    +	// Replay must reuse a non-empty state token: hasState reflects the
    +	// in-memory map, and consumeState should have just dropped this one.
    +	if o.hasState("state-scaffold") {
    +		t.Error("state was not consumed after successful callback")
    +	}
    +}
    
  • internal/web/oidc_state_error_test.go+65 0 added
    @@ -0,0 +1,65 @@
    +package web
    +
    +import (
    +	"net/http"
    +	"net/http/httptest"
    +	"strings"
    +	"testing"
    +
    +	"github.com/juev/nebula-mesh/internal/config"
    +)
    +
    +// TestOIDC_HandleCallback_ErrorParamConsumesState pins that the IdP-error
    +// early-return branch invalidates the state cookie. Without this, an
    +// attacker who can induce the IdP to redirect back with ?error=... on the
    +// first callback hit could replay the same state on a second hit that
    +// carries a valid code.
    +func TestOIDC_HandleCallback_ErrorParamConsumesState(t *testing.T) {
    +	_, s := newTestWeb(t)
    +	idp := setupOIDCServer(t)
    +	o := newOIDCFromMock(t, idp, s, config.OIDCConfig{
    +		AllowedEmails: []string{"alice@example.com"},
    +		DefaultRole:   "user",
    +	})
    +
    +	const stateValue = "state-replay-defense"
    +
    +	// First: drive a callback with ?error=access_denied. This used to
    +	// early-return without consuming the state.
    +	errRec := driveCallbackWithError(t, o, stateValue, "access_denied")
    +	if errRec.Code != http.StatusBadRequest {
    +		t.Errorf("error-path status = %d, want 400 (body: %s)", errRec.Code, errRec.Body.String())
    +	}
    +
    +	// State must no longer be in the in-memory map. consumeState would
    +	// also return false on a second call.
    +	if o.hasState(stateValue) {
    +		t.Fatal("state was not consumed on the IdP-error path: replay window still open")
    +	}
    +
    +	// Second: drive a follow-up callback with the same state value and a
    +	// legitimate-looking code. The state cookie should already be
    +	// invalidated — expect 400 "invalid oidc state", not a successful
    +	// session.
    +	idp.NextIDToken(map[string]any{
    +		"sub":                "alice-sub",
    +		"aud":                "test-client",
    +		"email":              "alice@example.com",
    +		"email_verified":     true,
    +		"preferred_username": "alice",
    +		"name":               "Alice",
    +	})
    +	// driveCallback re-seats the state — bypass that for the replay test
    +	// so we observe the post-error state.
    +	req := httptest.NewRequest("GET", "/ui/oidc/callback?state="+stateValue+"&code=code-replay", nil)
    +	req.AddCookie(&http.Cookie{Name: oidcStateCookieName, Value: stateValue})
    +	replayRec := httptest.NewRecorder()
    +	o.HandleCallback(replayRec, req)
    +
    +	if replayRec.Code != http.StatusBadRequest {
    +		t.Errorf("replay status = %d, want 400 (body: %s)", replayRec.Code, replayRec.Body.String())
    +	}
    +	if !strings.Contains(replayRec.Body.String(), "invalid oidc state") {
    +		t.Errorf("replay body = %q, want 'invalid oidc state'", replayRec.Body.String())
    +	}
    +}
    
  • internal/web/oidc_state_sweep_test.go+84 0 added
    @@ -0,0 +1,84 @@
    +package web
    +
    +import (
    +	"strconv"
    +	"testing"
    +	"time"
    +)
    +
    +// TestOIDC_RememberState_SweepsExpired confirms that the lazy TTL sweeper
    +// drops entries past their expiry on every rememberState call. Without
    +// this, an unauthenticated client bursting /ui/oidc/login grows the
    +// states map without bound (DoS amplifier).
    +func TestOIDC_RememberState_SweepsExpired(t *testing.T) {
    +	o := newOIDCForStateTests(t)
    +
    +	const liveCount = 5
    +	const expiredCount = 20
    +
    +	// Plant entries by direct mutation so the sweep isn't triggered
    +	// between inserts. Fresh entries get a future expiry; expired entries
    +	// get a past one. Once the map is fully populated, trigger the sweep
    +	// with a single rememberState call.
    +	now := time.Now()
    +	future := now.Add(oidcStateTTL)
    +	past := now.Add(-time.Minute)
    +	o.stateMu.Lock()
    +	for i := 0; i < liveCount; i++ {
    +		o.states["live-"+strconv.Itoa(i)] = future
    +	}
    +	for i := 0; i < expiredCount; i++ {
    +		o.states["expired-"+strconv.Itoa(i)] = past
    +	}
    +	o.stateMu.Unlock()
    +
    +	if got := o.stateCount(); got != liveCount+expiredCount {
    +		t.Fatalf("pre-sweep state count = %d, want %d", got, liveCount+expiredCount)
    +	}
    +
    +	// Trigger the lazy sweep by remembering one more state.
    +	o.rememberState("trigger-sweep")
    +
    +	want := liveCount + 1 // the live ones plus the trigger
    +	if got := o.stateCount(); got != want {
    +		t.Errorf("post-sweep state count = %d, want %d", got, want)
    +	}
    +
    +	// Spot-check: every entry left in the map must have a future expiry.
    +	o.stateMu.Lock()
    +	defer o.stateMu.Unlock()
    +	checkpoint := time.Now()
    +	for state, exp := range o.states {
    +		if !exp.After(checkpoint) {
    +			t.Errorf("post-sweep state %q has stale expiry %v", state, exp)
    +		}
    +	}
    +}
    +
    +// TestOIDC_RememberState_BurstDoesNotGrowUnbounded simulates the DoS
    +// amplifier shape: a burst of /ui/oidc/login hits after which the entries
    +// expire. The next rememberState must not leave the old entries behind.
    +func TestOIDC_RememberState_BurstDoesNotGrowUnbounded(t *testing.T) {
    +	o := newOIDCForStateTests(t)
    +
    +	// Simulate an attacker burst that pre-dates the TTL window.
    +	const burst = 1000
    +	past := time.Now().Add(-2 * oidcStateTTL)
    +	for i := 0; i < burst; i++ {
    +		s := "burst-" + strconv.Itoa(i)
    +		o.stateMu.Lock()
    +		o.states[s] = past
    +		o.stateMu.Unlock()
    +	}
    +	if got := o.stateCount(); got != burst {
    +		t.Fatalf("pre-sweep state count = %d, want %d", got, burst)
    +	}
    +
    +	o.rememberState("legitimate-login")
    +
    +	// The burst entries should all be gone; only the legitimate login
    +	// remains.
    +	if got := o.stateCount(); got != 1 {
    +		t.Errorf("post-sweep state count = %d, want 1 (DoS map grew unbounded)", got)
    +	}
    +}
    
  • internal/web/oidc_testhelper_test.go+385 0 added
    @@ -0,0 +1,385 @@
    +package web
    +
    +import (
    +	"context"
    +	"crypto/rand"
    +	"crypto/rsa"
    +	"encoding/base64"
    +	"encoding/json"
    +	"io"
    +	"log/slog"
    +	"math/big"
    +	"net/http"
    +	"net/http/httptest"
    +	"strings"
    +	"sync"
    +	"testing"
    +	"time"
    +
    +	"github.com/go-jose/go-jose/v4"
    +	"github.com/go-jose/go-jose/v4/jwt"
    +	"github.com/juev/nebula-mesh/internal/config"
    +	"github.com/juev/nebula-mesh/internal/store"
    +)
    +
    +// mockIDP is an httptest-backed OpenID Connect identity provider that lets
    +// tests drive the full nebula-mesh OIDC callback flow without a real IdP.
    +//
    +// Modeled on dexidp/dex's connector/oidc/oidc_test.go:setupServer pattern
    +// (the 85-line idiom referenced in docs/nebula-mesh testing roadmap).
    +// Serves the four documents the relying-party flow needs:
    +//
    +//	GET  /.well-known/openid-configuration
    +//	GET  /keys                    (JWKS)
    +//	GET  /auth                    (authorization endpoint — opaque, never hit
    +//	                               by HandleCallback; we exchange tokens directly)
    +//	POST /token                   (returns a freshly-minted id_token whose
    +//	                               claims come from the last NextIDToken call)
    +//	GET  /userinfo                (echoes the same claims)
    +//
    +// Tests configure per-callback behavior with NextIDToken and (optionally)
    +// NextTokenResponse, then drive HandleCallback by issuing a request with
    +// the matching state cookie + state query parameter.
    +type mockIDP struct {
    +	server *httptest.Server
    +
    +	signer    jose.Signer
    +	publicKey *rsa.PublicKey
    +	kid       string
    +
    +	mu sync.Mutex
    +	// nextClaims is the claim set used by the next /token call. Tests set
    +	// this before driving HandleCallback. Defaults to a minimal valid token
    +	// (sub, aud, iss, exp/nbf, iat) so unset-claim tests are explicit.
    +	nextClaims map[string]any
    +	// rawTokenOverride, if non-empty, is returned verbatim as the id_token
    +	// instead of minting one from nextClaims. Use for malformed-token tests
    +	// (non-string id_token, wrong-aud token, wrong-iss token).
    +	rawTokenOverride string
    +	// tokenStatus, if non-zero, overrides the /token response status code
    +	// (200 default). Use for failed-exchange tests.
    +	tokenStatus int
    +}
    +
    +// setupOIDCServer starts a mock IdP and returns a handle for configuring it.
    +// Callers must call t.Cleanup or defer Close.
    +func setupOIDCServer(t *testing.T) *mockIDP {
    +	t.Helper()
    +
    +	key, err := rsa.GenerateKey(rand.Reader, 2048)
    +	if err != nil {
    +		t.Fatalf("rsa.GenerateKey: %v", err)
    +		return nil
    +	}
    +	kid := "test-kid-1"
    +	signerKey := jose.SigningKey{
    +		Algorithm: jose.RS256,
    +		Key: jose.JSONWebKey{
    +			Key:       key,
    +			KeyID:     kid,
    +			Algorithm: string(jose.RS256),
    +			Use:       "sig",
    +		},
    +	}
    +	signer, err := jose.NewSigner(signerKey, (&jose.SignerOptions{}).WithType("JWT"))
    +	if err != nil {
    +		t.Fatalf("jose.NewSigner: %v", err)
    +		return nil
    +	}
    +
    +	idp := &mockIDP{
    +		signer:    signer,
    +		publicKey: &key.PublicKey,
    +		kid:       kid,
    +	}
    +
    +	mux := http.NewServeMux()
    +	mux.HandleFunc("/.well-known/openid-configuration", idp.handleDiscovery)
    +	mux.HandleFunc("/keys", idp.handleJWKS)
    +	mux.HandleFunc("/auth", idp.handleAuth)
    +	mux.HandleFunc("/token", idp.handleToken)
    +	mux.HandleFunc("/userinfo", idp.handleUserinfo)
    +	idp.server = httptest.NewServer(mux)
    +	t.Cleanup(idp.Close)
    +	return idp
    +}
    +
    +// Close stops the underlying httptest server.
    +func (m *mockIDP) Close() {
    +	if m.server != nil {
    +		m.server.Close()
    +	}
    +}
    +
    +// Issuer returns the issuer URL (the base of the httptest server) so callers
    +// can wire it into config.OIDCConfig.Issuer.
    +func (m *mockIDP) Issuer() string { return m.server.URL }
    +
    +// NextIDToken configures the claim set used by the next /token call. Returns
    +// the IDP so calls chain naturally with newOIDCFromMock.
    +func (m *mockIDP) NextIDToken(claims map[string]any) *mockIDP {
    +	m.mu.Lock()
    +	defer m.mu.Unlock()
    +	m.nextClaims = claims
    +	m.rawTokenOverride = ""
    +	return m
    +}
    +
    +// NextRawIDToken bypasses claim-based minting and returns the given string
    +// as the id_token in the next /token response. Used for malformed-token
    +// tests (wrong issuer, wrong audience, non-JWT garbage).
    +func (m *mockIDP) NextRawIDToken(raw string) *mockIDP {
    +	m.mu.Lock()
    +	defer m.mu.Unlock()
    +	m.rawTokenOverride = raw
    +	m.nextClaims = nil
    +	return m
    +}
    +
    +// SetTokenStatus overrides the /token endpoint's response status code on the
    +// next call. Use 0 to reset to 200.
    +func (m *mockIDP) SetTokenStatus(status int) { //nolint:unused // reserved for future fail-path tests
    +	m.mu.Lock()
    +	defer m.mu.Unlock()
    +	m.tokenStatus = status
    +}
    +
    +// mintToken signs the given claim map as an RS256 JWT using the IdP's signing
    +// key. Defaults exp/nbf/iat to sane values when the caller doesn't set them.
    +func (m *mockIDP) mintToken(claims map[string]any) (string, error) {
    +	now := time.Now()
    +	if _, ok := claims["iat"]; !ok {
    +		claims["iat"] = now.Unix()
    +	}
    +	if _, ok := claims["nbf"]; !ok {
    +		claims["nbf"] = now.Unix()
    +	}
    +	if _, ok := claims["exp"]; !ok {
    +		claims["exp"] = now.Add(5 * time.Minute).Unix()
    +	}
    +	if _, ok := claims["iss"]; !ok {
    +		claims["iss"] = m.server.URL
    +	}
    +	return jwt.Signed(m.signer).Claims(claims).Serialize()
    +}
    +
    +func (m *mockIDP) handleDiscovery(w http.ResponseWriter, _ *http.Request) {
    +	w.Header().Set("Content-Type", "application/json")
    +	doc := map[string]any{
    +		"issuer":                                m.server.URL,
    +		"authorization_endpoint":                m.server.URL + "/auth",
    +		"token_endpoint":                        m.server.URL + "/token",
    +		"jwks_uri":                              m.server.URL + "/keys",
    +		"userinfo_endpoint":                     m.server.URL + "/userinfo",
    +		"id_token_signing_alg_values_supported": []string{"RS256"},
    +		"response_types_supported":              []string{"code"},
    +		"subject_types_supported":               []string{"public"},
    +	}
    +	_ = json.NewEncoder(w).Encode(doc)
    +}
    +
    +func (m *mockIDP) handleJWKS(w http.ResponseWriter, _ *http.Request) {
    +	w.Header().Set("Content-Type", "application/json")
    +	jwks := jwksDoc{
    +		Keys: []jwkEntry{{
    +			Kty: "RSA",
    +			Use: "sig",
    +			Alg: "RS256",
    +			Kid: m.kid,
    +			N:   base64.RawURLEncoding.EncodeToString(m.publicKey.N.Bytes()),
    +			E:   base64.RawURLEncoding.EncodeToString(big.NewInt(int64(m.publicKey.E)).Bytes()),
    +		}},
    +	}
    +	_ = json.NewEncoder(w).Encode(jwks)
    +}
    +
    +func (m *mockIDP) handleAuth(w http.ResponseWriter, _ *http.Request) {
    +	// HandleCallback never hits /auth — the browser would. Return 200 so a
    +	// curl in the middle of debugging gets something useful.
    +	w.WriteHeader(http.StatusOK)
    +	_, _ = io.WriteString(w, "mock idp auth endpoint")
    +}
    +
    +func (m *mockIDP) handleToken(w http.ResponseWriter, _ *http.Request) {
    +	m.mu.Lock()
    +	status := m.tokenStatus
    +	override := m.rawTokenOverride
    +	claims := m.nextClaims
    +	m.mu.Unlock()
    +
    +	if status != 0 {
    +		w.WriteHeader(status)
    +		return
    +	}
    +
    +	w.Header().Set("Content-Type", "application/json")
    +	var idToken string
    +	if override != "" {
    +		idToken = override
    +	} else {
    +		if claims == nil {
    +			claims = map[string]any{}
    +		}
    +		tok, err := m.mintToken(claims)
    +		if err != nil {
    +			http.Error(w, "mint: "+err.Error(), http.StatusInternalServerError)
    +			return
    +		}
    +		idToken = tok
    +	}
    +	resp := map[string]any{
    +		"access_token": "mock-access-token",
    +		"token_type":   "Bearer",
    +		"id_token":     idToken,
    +		"expires_in":   300,
    +	}
    +	_ = json.NewEncoder(w).Encode(resp)
    +}
    +
    +func (m *mockIDP) handleUserinfo(w http.ResponseWriter, _ *http.Request) {
    +	m.mu.Lock()
    +	claims := m.nextClaims
    +	m.mu.Unlock()
    +	w.Header().Set("Content-Type", "application/json")
    +	if claims == nil {
    +		claims = map[string]any{}
    +	}
    +	_ = json.NewEncoder(w).Encode(claims)
    +}
    +
    +// jwksDoc / jwkEntry are the minimal shape the go-oidc verifier needs.
    +type jwksDoc struct {
    +	Keys []jwkEntry `json:"keys"`
    +}
    +
    +type jwkEntry struct {
    +	Kty string `json:"kty"`
    +	Use string `json:"use"`
    +	Alg string `json:"alg"`
    +	Kid string `json:"kid"`
    +	N   string `json:"n"`
    +	E   string `json:"e"`
    +}
    +
    +// newOIDCFromMock constructs an *OIDC wired to the given mock IdP. The
    +// returned OIDC has the same shape as production NewOIDC: it does discovery
    +// against the mock, attaches a verifier that enforces ClientID + issuer,
    +// and shares the same store-backed session manager as the rest of the test.
    +func newOIDCFromMock(t *testing.T, idp *mockIDP, s store.Store, extraCfg config.OIDCConfig) *OIDC {
    +	t.Helper()
    +	cfg := extraCfg
    +	cfg.Enabled = true
    +	cfg.Issuer = idp.Issuer()
    +	if cfg.ClientID == "" {
    +		cfg.ClientID = "test-client"
    +	}
    +	if cfg.ClientSecret == "" {
    +		cfg.ClientSecret = "test-secret"
    +	}
    +	if cfg.RedirectURL == "" {
    +		cfg.RedirectURL = "https://nebula-mesh.test/ui/oidc/callback"
    +	}
    +	sm := NewSessionManager(s)
    +	o, err := NewOIDC(context.Background(), &cfg, s, sm, slog.New(slog.NewTextHandler(io.Discard, nil)))
    +	if err != nil {
    +		t.Fatalf("NewOIDC: %v", err)
    +		return nil
    +	}
    +	if o == nil {
    +		t.Fatal("NewOIDC returned nil")
    +		return nil
    +	}
    +	return o
    +}
    +
    +// driveCallback issues a /ui/oidc/callback request with a valid state cookie
    +// and runs HandleCallback against it. Returns the recorder so tests can
    +// inspect status / body / Set-Cookie headers.
    +//
    +// stateValue is the value to put in BOTH the cookie and the ?state= query
    +// param. To exercise mismatch cases, pass different values via the *Mismatch
    +// variant or set the cookie / query explicitly in the test.
    +func driveCallback(t *testing.T, o *OIDC, stateValue, code string) *httptest.ResponseRecorder {
    +	t.Helper()
    +	// Pre-seat the state so consumeState recognises it.
    +	o.rememberState(stateValue)
    +	q := "/ui/oidc/callback?state=" + stateValue
    +	if code != "" {
    +		q += "&code=" + code
    +	}
    +	req := httptest.NewRequest("GET", q, nil)
    +	req.AddCookie(&http.Cookie{Name: oidcStateCookieName, Value: stateValue})
    +	rec := httptest.NewRecorder()
    +	o.HandleCallback(rec, req)
    +	return rec
    +}
    +
    +// driveCallbackWithError exercises the IdP-error early-return branch: the
    +// IdP redirected back with ?error=...&state=... after the user denied
    +// consent (or some IdP-side failure). The state cookie is set so we can
    +// observe whether the early return invalidates the state.
    +func driveCallbackWithError(t *testing.T, o *OIDC, stateValue, errParam string) *httptest.ResponseRecorder { //nolint:unused // consumed by the state-error fix's regression test in a later commit
    +	t.Helper()
    +	o.rememberState(stateValue)
    +	q := "/ui/oidc/callback?error=" + errParam + "&state=" + stateValue
    +	req := httptest.NewRequest("GET", q, nil)
    +	req.AddCookie(&http.Cookie{Name: oidcStateCookieName, Value: stateValue})
    +	rec := httptest.NewRecorder()
    +	o.HandleCallback(rec, req)
    +	return rec
    +}
    +
    +// newOIDCForStateTests returns the minimum viable *OIDC for exercising the
    +// rememberState / consumeState / sweepLocked code paths without a real
    +// IdP, store, or session manager. State-sweep tests want to drive those
    +// methods directly; the full newOIDCFromMock path stands up an httptest
    +// server, generates an RSA key, and runs OIDC discovery, all of which are
    +// irrelevant to the in-memory map's behaviour.
    +//
    +// Returns a struct with: empty states map, a discard-sink slog logger, an
    +// empty OIDCConfig, and zero values for the rest. Future required
    +// non-pointer fields show up here as a single edit rather than five
    +// scattered constructors across the sweep tests.
    +func newOIDCForStateTests(t *testing.T) *OIDC {
    +	t.Helper()
    +	return &OIDC{
    +		states: map[string]time.Time{},
    +		logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
    +	}
    +}
    +
    +// hasState reports whether the in-memory states map currently carries the
    +// given token. Used by replay-defense assertions.
    +func (o *OIDC) hasState(state string) bool {
    +	o.stateMu.Lock()
    +	defer o.stateMu.Unlock()
    +	_, ok := o.states[state]
    +	return ok
    +}
    +
    +// stateCount returns the current size of the in-memory states map. Used by
    +// the TTL sweeper test.
    +func (o *OIDC) stateCount() int { //nolint:unused // consumed by the TTL sweeper fix's regression test in a later commit
    +	o.stateMu.Lock()
    +	defer o.stateMu.Unlock()
    +	return len(o.states)
    +}
    +
    +// setStateExpiry rewrites a state entry's expiry to the given time without
    +// re-running rememberState. Reserved for future OIDC tests that need to
    +// plant already-expired entries without triggering the lazy sweep.
    +func (o *OIDC) setStateExpiry(state string, expiry time.Time) { //nolint:unused // reserved for future tests
    +	o.stateMu.Lock()
    +	defer o.stateMu.Unlock()
    +	o.states[state] = expiry
    +}
    +
    +// findCallbackErrorBody pulls the first line of the response body, useful
    +// for matching against http.Error's "msg\n" output shape.
    +func findCallbackErrorBody(rec *httptest.ResponseRecorder) string { //nolint:unused // helper for future tests
    +	body := rec.Body.String()
    +	if i := strings.IndexByte(body, '\n'); i >= 0 {
    +		return body[:i]
    +	}
    +	return body
    +}
    

Vulnerability mechanics

Root cause

"Session and OIDC state cookies are not configured with the Secure attribute, allowing them to be transmitted over unencrypted HTTP connections."

Attack vector

An attacker can observe network traffic between the operator and the application, for example, on a local area network. By intercepting a single HTTP request, the attacker can capture the session cookie and impersonate the operator. The OIDC state cookie is also vulnerable, though for a shorter duration, enabling Cross-Site Request Forgery (CSRF) attacks during the OIDC callback window [ref_id=2].

Affected code

The vulnerability lies in the cookie handling within `internal/web/session.go` and `internal/web/oidc.go`. Specifically, the `HttpOnly` and `SameSite=Lax` attributes are set for session and OIDC state cookies, but the `Secure` attribute is consistently omitted across various functions like `Login`, `StartAuthenticatedSession`, `HandleLogin`, and `HandleCallback` [ref_id=2].

What the fix does

The fix introduces a `cookie_secure` configuration option. This option is automatically enabled when TLS certificates and keys are configured, and disabled otherwise. Operators deploying behind a TLS-terminating proxy must explicitly set `cookie_secure: true`. This ensures that the `Secure` attribute is added to cookies when appropriate, preventing their transmission over unencrypted HTTP and mitigating the described vulnerability [patch_id=5504843].

Preconditions

  • networkThe attacker must be able to observe network traffic between the operator and the application.
  • configThe application must be accessible via an unencrypted HTTP connection, either through direct misconfiguration, a mistyped URL, or a reverse proxy that does not strictly enforce HTTPS.

Reproduction

Start `nebula-mgmt` without TLS certificates and keys. Send a POST request to the login endpoint over HTTP, for example: `curl -i -X POST -d 'username=admin&password=…' http://127.0.0.1:8080/ui/login`. Observe the `Set-Cookie: nebula_session=…` header; it will lack the `Secure` attribute. A subsequent unencrypted request will reveal this cookie [ref_id=2].

Generated on Jun 10, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

3

News mentions

0

No linked articles in our index yet.