feat: add OIDC endpoint relaxations for private/isolated networks

Closes #2364

`AssumeRoleWithWebIdentity` only ever trusted an `OIDC` provider reachable over verified `https`, at a publicly routable address, on the implicit `:443`. That posture is right for an internet-facing IdP but rejects every address an internal one can have, so a `SPIFFE/SPIRE` OIDC discovery provider in the same cluster — or as a sidecar in the same pod — could never be registered, let alone verified against, and no setting could express "this private address is the IdP".

Two opt-in flags on `versitygw iam`, both off by default:

`--oidc-allow-private-endpoints`
Permit a provider `Url` resolving to a loopback/private/link-local address, and an explicit port. Transport is unchanged: still `https`, still fully verified (a self-signed in-cluster cert is trusted the way AWS documents, through `ThumbprintList`).

`--oidc-allow-insecure-transport`
Additionally permit plaintext `http` provider URLs, discovery/JWKS endpoints and redirects, and drop TLS verification (`thumbprint` pinning included) for `https` ones.

Both apply uniformly to the thumbprint auto-fetch at `CreateOpenIDConnectProvider` time and to the discovery-document plus `JWKS` fetch at `AssumeRoleWithWebIdentity` time. Neither weakens anything past the endpoint: signature verification, issuer matching, audience and trust policy evaluation are untouched, and the DNS-resolve-once/dial-the-resolved-IP shape stays in place so a rebind still cannot redirect a connection.

An `http` provider keeps its scheme in its stored `Url`, `ARN` and `iss` matching, rather than being stripped like an `https` one — otherwise `"http://host"` and `"https://host"` would collapse onto a single ARN and storage key and each could satisfy the other's trust policy. It also stores an empty `ThumbprintList` rather than failing: a plaintext provider presents no certificate to thumbprint.

Helm: `iamServer.oidc.{allowPrivateEndpoints,allowInsecureTransport}`, alongside `disableThumbprintAutofetch` moved into the same block (the flat `iamServer.disableOidcThumbprintAutofetch` stays honored). Chart `0.4.1 -> 0.4.2`.

The WebUI's create-provider form no longer rejects `http` URLs and ports client-side; it cannot see the service's settings, so those two rules are left to the server, whose error surfaces as a toast like any other.
This commit is contained in:
niksis02
2026-09-08 16:30:37 +04:00
parent 7f0a793150
commit 658c37907d
25 changed files with 972 additions and 140 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ apiVersion: v2
name: versitygw
description: A Helm chart for deploying the Versity S3 Gateway on Kubernetes
type: application
version: 0.4.1
version: 0.4.2
sources:
- https://github.com/versity/versitygw
icon: https://raw.githubusercontent.com/versity/versitygw/main/webui/web/assets/images/Versity-logo-blue-horizontal.png
+1
View File
@@ -146,6 +146,7 @@ Key points:
- **Private mTLS endpoint**: gateways reach the standalone IAM service over a private endpoint (`iamServer.private.port`, default `7443`) that always requires mutual TLS on TCP. Provide certificates either via `existingSecret` (bring your own `tls.crt`/`tls.key`/`ca.crt`) or `certificate.create=true` to auto-provision via cert-manager.
- **Shared CA requirement**: when using cert-manager auto-provisioning, `iamServer.private.certificate.issuerRef` and `iam.standalone.certificate.issuerRef` **must reference the same CA-type issuer** (an `Issuer`/`ClusterIssuer` of kind `CA`, or a Vault issuer) — one that populates `ca.crt` in the resulting Secret. Both sides verify their peer using their own certificate's `ca.crt`, which only works when both certificates share the same issuing CA.
- **External IAM service**: to point a gateway at a standalone IAM service deployed outside this chart (or by a separate chart release), set `iam.standalone.endpoint` to its `host:port` and provide the mTLS material via `iam.standalone.certificate.existingSecret`.
- **OIDC identity providers**: `AssumeRoleWithWebIdentity` only trusts an OIDC provider reachable over verified https at a publicly routable address on the implicit `:443`. An in-cluster provider satisfies none of that, so `iamServer.oidc.allowPrivateEndpoints` permits private/loopback addresses and an explicit port (a SPIRE OIDC discovery provider on a ClusterIP `Service`), and `iamServer.oidc.allowInsecureTransport` additionally permits plaintext `http://` providers and drops TLS verification (the same provider bound to `127.0.0.1` as a sidecar). Each relaxes real protections — see the comments in `values.yaml` — so enable only the one your topology needs.
- **WebUI access**: to manage IAM users from the WebUI, set `webui.iamGateways` to the URL a browser can reach `iamServer` on, and `iamServer.corsAllowOrigin` to the WebUI's own origin. Every WebUI call to the IAM API is cross-origin, so without `corsAllowOrigin` the browser blocks it and the WebUI's IAM navigation silently never appears.
- **Secret rotation**: the processes load mTLS material and environment-based credentials at startup. After a referenced Secret rotates, restart both Deployments or configure a Secret-reloader controller through `deploymentAnnotations` and `iamServer.deploymentAnnotations`.
+10 -1
View File
@@ -1,6 +1,7 @@
{{- $iamServer := .Values.iamServer | default dict -}}
{{- if ($iamServer.enabled | default false) }}
{{- $iamServerAuth := .Values.iamServer.auth | default dict -}}
{{- $iamServerOidc := .Values.iamServer.oidc | default dict -}}
{{- if not (or (eq .Values.iamServer.storage.type "internal") (eq .Values.iamServer.storage.type "vault")) }}
{{- fail "iamServer.storage.type must be either internal or vault" }}
{{- end }}
@@ -111,10 +112,18 @@ spec:
- name: VGW_LOG_LEVEL
value: {{ .Values.iamServer.logLevel | quote }}
{{- end }}
{{- if .Values.iamServer.disableOidcThumbprintAutofetch }}
{{- if or $iamServerOidc.disableThumbprintAutofetch .Values.iamServer.disableOidcThumbprintAutofetch }}
- name: VGW_IAM_DISABLE_OIDC_THUMBPRINT_AUTOFETCH
value: "true"
{{- end }}
{{- if $iamServerOidc.allowPrivateEndpoints }}
- name: VGW_IAM_OIDC_ALLOW_PRIVATE_ENDPOINTS
value: "true"
{{- end }}
{{- if $iamServerOidc.allowInsecureTransport }}
- name: VGW_IAM_OIDC_ALLOW_INSECURE_TRANSPORT
value: "true"
{{- end }}
{{- if .Values.iamServer.corsAllowOrigin }}
- name: VGW_CORS_ALLOW_ORIGIN
value: {{ .Values.iamServer.corsAllowOrigin | quote }}
+27 -3
View File
@@ -407,10 +407,34 @@ iamServer:
# Debug logger verbosity: "silent" (default), "debug", or "unsafe" -- see
# gateway.logLevel for details.
logLevel: silent
# Reject CreateOpenIDConnectProvider requests that omit ThumbprintList
# instead of auto-fetching it over an outbound TLS connection to the
# caller-supplied URL. Recommended for restricted/air-gapped clusters.
# Deprecated alias for oidc.disableThumbprintAutofetch below; still honored.
disableOidcThumbprintAutofetch: false
# -- OIDC identity providers (AssumeRoleWithWebIdentity) --
oidc:
# Reject CreateOpenIDConnectProvider requests that omit ThumbprintList
# instead of auto-fetching it over an outbound TLS connection to the
# caller-supplied URL. Recommended for restricted/air-gapped clusters.
disableThumbprintAutofetch: false
# Allow OIDC provider URLs that resolve to loopback/private/link-local
# addresses and that carry an explicit port. Both are refused by default,
# which makes an in-cluster identity provider -- a SPIFFE/SPIRE OIDC
# discovery provider on a ClusterIP Service, say -- impossible to register
# or to verify tokens against. Transport is unaffected: still https, still
# fully verified.
#
# This also re-permits cloud metadata endpoints (169.254.169.254) as fetch
# targets, so enable it only where CreateOpenIDConnectProvider is already
# an administrator-only operation.
allowPrivateEndpoints: false
# Allow plaintext http OIDC provider URLs and skip TLS certificate
# verification (thumbprint pinning included) for https ones, leaving the
# network path as the only thing authenticating the identity provider.
# Intended for a provider reached over an already-trusted path -- a
# discovery provider bound to 127.0.0.1 as a sidecar in the IAM server's
# own pod. Needs allowPrivateEndpoints as well for a loopback or
# cluster-internal address.
allowInsecureTransport: false
# Access-Control-Allow-Origin for the control-plane API. Required before a
# browser can reach this service: the WebUI is served from another origin, so
# every call it makes is cross-origin and is blocked without this. Set it to
+10
View File
@@ -114,6 +114,16 @@ func IAMCommand() *cli.Command {
Usage: "reject CreateOpenIDConnectProvider requests that omit ThumbprintList instead of auto-fetching it over an outbound TLS connection",
EnvVars: []string{"VGW_IAM_DISABLE_OIDC_THUMBPRINT_AUTOFETCH"},
},
&cli.BoolFlag{
Name: "oidc-allow-private-endpoints",
Usage: "allow OIDC provider URLs that resolve to loopback/private/link-local addresses and that carry an explicit port; needed for an identity provider that only exists on an internal network, and also re-permits cloud metadata endpoints as fetch targets",
EnvVars: []string{"VGW_IAM_OIDC_ALLOW_PRIVATE_ENDPOINTS"},
},
&cli.BoolFlag{
Name: "oidc-allow-insecure-transport",
Usage: "allow plaintext http OIDC provider URLs and skip TLS certificate verification (thumbprint pinning included) for https ones; only for an identity provider reached over an already-trusted path, such as a loopback-bound sidecar",
EnvVars: []string{"VGW_IAM_OIDC_ALLOW_INSECURE_TRANSPORT"},
},
&cli.StringSliceFlag{
Name: "private-ports",
Usage: "private endpoint listen address: a unix socket path, or <ip>:<port>/:<port> when mTLS (--private-cert/--private-cert-key/--private-client-ca) is also configured — refuses to start otherwise (can be specified multiple times)",
+2
View File
@@ -71,6 +71,8 @@ func runIAM(ctx *cli.Context) error {
VaultClientCert: ctx.String("vault-client-cert"),
VaultClientCertKey: ctx.String("vault-client-cert-key"),
DisableOIDCThumbprintAutoFetch: ctx.Bool("disable-oidc-thumbprint-autofetch"),
OIDCAllowPrivateEndpoints: ctx.Bool("oidc-allow-private-endpoints"),
OIDCAllowInsecureTransport: ctx.Bool("oidc-allow-insecure-transport"),
CORSAllowOrigin: corsAllowOrigin,
Region: region,
WebuiPorts: webuiPorts,
+21
View File
@@ -193,6 +193,21 @@ type IAMConfig struct {
// outbound TLS connection to the caller-supplied URL — for restricted
// or air-gapped deployments.
DisableOIDCThumbprintAutoFetch bool
// OIDCAllowPrivateEndpoints permits OIDC provider URLs that resolve to
// loopback/private/link-local addresses and that carry an explicit port,
// both refused by default. Required to use an IdP that exists only on an
// internal network, such as a SPIFFE/SPIRE OIDC discovery provider on a
// cluster-internal Service. Transport stays https and fully verified.
OIDCAllowPrivateEndpoints bool
// OIDCAllowInsecureTransport permits plaintext http OIDC provider URLs
// and drops TLS certificate verification (ThumbprintList pinning
// included) for https ones, leaving the network path as the only thing
// authenticating the IdP. For an IdP reachable only over a path that is
// itself trusted, such as a discovery provider bound to loopback as a
// sidecar in this process's own pod.
OIDCAllowInsecureTransport bool
}
// privateAPIServer is the standalone IAM service's private endpoint set
@@ -420,6 +435,12 @@ func RunIAMAPI(ctx context.Context, cfg *IAMConfig) error {
if cfg.DisableOIDCThumbprintAutoFetch {
opts = append(opts, iamapi.WithOIDCThumbprintAutoFetchDisabled())
}
if cfg.OIDCAllowPrivateEndpoints {
opts = append(opts, iamapi.WithOIDCAllowPrivateEndpoints())
}
if cfg.OIDCAllowInsecureTransport {
opts = append(opts, iamapi.WithOIDCAllowInsecureTransport())
}
corsAllowOrigin := strings.TrimSpace(cfg.CORSAllowOrigin)
if len(cfg.WebuiPorts) > 0 && corsAllowOrigin == "" {
// Every WebUI call to this API is cross-origin, so without an allowed
+13 -1
View File
@@ -141,4 +141,16 @@ ROOT_SECRET_ACCESS_KEY=
#################
# Reject OIDC provider creation without an explicit certificate thumbprint.
#VGW_IAM_DISABLE_OIDC_THUMBPRINT_AUTOFETCH=false
#VGW_IAM_DISABLE_OIDC_THUMBPRINT_AUTOFETCH=false
# Allow OIDC provider URLs that resolve to loopback/private/link-local
# addresses and that carry an explicit port; needed for an identity provider
# that only exists on an internal network, and also re-permits cloud
# metadata endpoints as fetch targets.
#VGW_IAM_OIDC_ALLOW_PRIVATE_ENDPOINTS=false
# Allow plaintext http OIDC provider URLs and skip TLS certificate
# verification (thumbprint pinning included) for https ones; only for an
# identity provider reached over an already-trusted path, such as a
# loopback-bound sidecar.
#VGW_IAM_OIDC_ALLOW_INSECURE_TRANSPORT=false
+28 -18
View File
@@ -32,18 +32,20 @@ import (
type IAMApiController struct {
store storage.Storer
// oidcThumbprintAutoFetchDisabled disables CreateOpenIDConnectProvider's
// TLS auto-fetch fallback when ThumbprintList is omitted (operational
// safety valve for restricted/air-gapped deployments); set via
// iamapi.WithOIDCThumbprintAutoFetchDisabled(). Defaults to false
// (auto-fetch enabled), matching real AWS behavior.
oidcThumbprintAutoFetchDisabled bool
// oidc holds the OIDC provider settings; see OIDCConfig. Its zero value
// is the default AWS-matching posture: auto-fetch enabled, and only
// verified https endpoints at publicly routable addresses.
oidc OIDCConfig
// oidcPolicy is oidc's endpoint relaxations in the form iamutil's URL
// validation and fetch helpers take, projected once at construction.
oidcPolicy iamutil.OIDCEndpointPolicy
}
func NewController(store storage.Storer, oidcThumbprintAutoFetchDisabled bool) IAMApiController {
func NewController(store storage.Storer, oidc OIDCConfig) IAMApiController {
return IAMApiController{
store: store,
oidcThumbprintAutoFetchDisabled: oidcThumbprintAutoFetchDisabled,
store: store,
oidc: oidc,
oidcPolicy: oidc.endpointPolicy(),
}
}
@@ -1035,7 +1037,7 @@ func (c IAMApiController) CreateOpenIDConnectProvider(ctx fiber.Ctx) (*Response,
debuglogger.Logf("missing required CreateOpenIDConnectProvider parameter: Url")
return nil, iamerr.MissingValue("url")
}
url, err := iamutil.ValidateOIDCProviderURL(rawURL)
url, err := iamutil.ValidateOIDCProviderURL(rawURL, c.oidcPolicy)
if err != nil {
return nil, err
}
@@ -1052,16 +1054,24 @@ func (c IAMApiController) CreateOpenIDConnectProvider(ctx fiber.Ctx) (*Response,
thumbprints := iamutil.ParseStringList(ctx, "ThumbprintList")
if len(thumbprints) == 0 {
if c.oidcThumbprintAutoFetchDisabled {
switch {
case iamutil.IsInsecureOIDCProviderURL(url):
// A plaintext http provider never presents a certificate, so
// there is nothing to auto-fetch and nothing for a later JWKS
// fetch to pin against: an empty ThumbprintList is the accurate
// record of that, not a missing one.
debuglogger.Logf("CreateOpenIDConnectProvider: %q is a plaintext http provider; storing an empty ThumbprintList", url)
case c.oidc.ThumbprintAutoFetchDisabled:
debuglogger.Logf("CreateOpenIDConnectProvider: ThumbprintList omitted and auto-fetch is disabled")
return nil, iamerr.MissingValue("thumbprintList")
default:
fetched, err := iamutil.FetchThumbprint(ctx.Context(), url, c.oidcPolicy)
if err != nil {
debuglogger.Logf("failed to auto-fetch OIDC thumbprint for url %q: %v", url, err)
return nil, err
}
thumbprints = []string{fetched}
}
fetched, err := iamutil.FetchThumbprint(ctx.Context(), url)
if err != nil {
debuglogger.Logf("failed to auto-fetch OIDC thumbprint for url %q: %v", url, err)
return nil, err
}
thumbprints = []string{fetched}
} else {
if err := iamutil.ValidateThumbprintList(thumbprints, false); err != nil {
return nil, err
@@ -1450,7 +1460,7 @@ func (c IAMApiController) AssumeRoleWithWebIdentity(ctx fiber.Ctx) (*Response, e
return nil, iamerr.InvalidIdentityTokenClaims()
}
verifiedClaims, err := iamutil.VerifyWebIdentitySignature(ctx.Context(), webIdentityToken, provider.Url, provider.ThumbprintList)
verifiedClaims, err := iamutil.VerifyWebIdentitySignature(ctx.Context(), webIdentityToken, provider.Url, provider.ThumbprintList, c.oidcPolicy)
if err != nil {
return nil, err
}
+232
View File
@@ -16,12 +16,15 @@ package iamapi
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/xml"
"fmt"
"math/big"
"net/http"
"net/http/httptest"
"net/url"
@@ -34,6 +37,7 @@ import (
"github.com/aws/aws-sdk-go-v2/aws"
awsv4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/gofiber/fiber/v3"
"github.com/golang-jwt/jwt/v5"
"github.com/versity/versitygw/iamapi/iamerr"
"github.com/versity/versitygw/iamapi/internal/iammiddleware"
"github.com/versity/versitygw/iamapi/internal/iamutil"
@@ -3632,6 +3636,234 @@ func TestIAMApiControllerCreateOIDCProviderAutoFetchSSRFGuard(t *testing.T) {
"Could not connect to https://127.0.0.1")
}
// newIAMControllerTestServerWith is newIAMControllerTestServer for the tests
// that need a non-default server option.
func newIAMControllerTestServerWith(t *testing.T, opts ...Option) *IAMApiServer {
t.Helper()
store, err := storage.New(storage.Config{Dir: t.TempDir()})
if err != nil {
t.Fatalf("storage.New: %v", err)
}
server, err := New(store, testRoot, append([]Option{WithQuiet()}, opts...)...)
if err != nil {
t.Fatalf("New: %v", err)
}
return server
}
// TestIAMApiControllerCreateOIDCProviderEndpointRelaxations covers both
// endpoint-relaxation options end to end through the HTTP action handler:
// the two Url shapes an IdP on an isolated network needs (an explicit port,
// and plaintext http) are rejected by default and accepted once the
// corresponding option is set — including the record each one leaves behind,
// since the stored Url is what a later token's iss claim is matched against.
func TestIAMApiControllerCreateOIDCProviderEndpointRelaxations(t *testing.T) {
const thumbprint = "6938fd4d98bab03faadb97b34396831e3780aea1"
create := func(t *testing.T, server *IAMApiServer, providerURL string, withThumbprint bool) *http.Response {
t.Helper()
params := url.Values{
"Action": {"CreateOpenIDConnectProvider"},
"Url": {providerURL},
}
if withThumbprint {
params.Set("ThumbprintList.member.1", thumbprint)
}
return doIAMAction(t, server, params)
}
getProvider := func(t *testing.T, server *IAMApiServer, arn string) iamtypes.GetOpenIDConnectProviderResult {
t.Helper()
resp := doIAMAction(t, server, url.Values{
"Action": {"GetOpenIDConnectProvider"},
"OpenIDConnectProviderArn": {arn},
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("GetOpenIDConnectProvider status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
var out iamtypes.GetOpenIDConnectProviderResponse
unmarshalXML(t, readBody(t, resp), &out)
return out.Result
}
requireCreated := func(t *testing.T, resp *http.Response, wantArn string) {
t.Helper()
if resp.StatusCode != http.StatusOK {
t.Fatalf("CreateOpenIDConnectProvider status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
var out iamtypes.CreateOpenIDConnectProviderResponse
unmarshalXML(t, readBody(t, resp), &out)
if out.Result.OpenIDConnectProviderArn != wantArn {
t.Fatalf("OpenIDConnectProviderArn = %q, want %q", out.Result.OpenIDConnectProviderArn, wantArn)
}
}
t.Run("explicit port rejected by default", func(t *testing.T) {
server := newIAMControllerTestServer(t)
requireIAMError(t, create(t, server, "https://spire-oidc.spire.svc:8443", true),
http.StatusBadRequest, "Sender", "InvalidInput", "Invalid Open ID Connect Provider URL.")
})
t.Run("http rejected by default", func(t *testing.T) {
server := newIAMControllerTestServer(t)
requireIAMError(t, create(t, server, "http://127.0.0.1:8080", true),
http.StatusBadRequest, "Sender", "InvalidInput",
"Invalid Open ID Connect Provider URL. The URL must begin with https://.")
})
t.Run("http still rejected with only private endpoints allowed", func(t *testing.T) {
server := newIAMControllerTestServerWith(t, WithOIDCAllowPrivateEndpoints())
requireIAMError(t, create(t, server, "http://127.0.0.1:8080", true),
http.StatusBadRequest, "Sender", "InvalidInput",
"Invalid Open ID Connect Provider URL. The URL must begin with https://.")
})
t.Run("explicit port accepted with private endpoints allowed", func(t *testing.T) {
server := newIAMControllerTestServerWith(t, WithOIDCAllowPrivateEndpoints())
requireCreated(t, create(t, server, "https://spire-oidc.spire.svc:8443", true),
"arn:aws:iam::000000000000:oidc-provider/spire-oidc.spire.svc:8443")
// The port survives into the stored Url, so it is part of what a
// token's iss claim must match.
got := getProvider(t, server, "arn:aws:iam::000000000000:oidc-provider/spire-oidc.spire.svc:8443")
if got.Url != "spire-oidc.spire.svc:8443" {
t.Errorf("stored Url = %q, want %q", got.Url, "spire-oidc.spire.svc:8443")
}
if len(got.ThumbprintList) != 1 || got.ThumbprintList[0] != thumbprint {
t.Errorf("ThumbprintList = %v, want [%s]", got.ThumbprintList, thumbprint)
}
})
t.Run("http provider accepted with insecure transport allowed", func(t *testing.T) {
server := newIAMControllerTestServerWith(t, WithOIDCAllowPrivateEndpoints(), WithOIDCAllowInsecureTransport())
const arn = "arn:aws:iam::000000000000:oidc-provider/http://127.0.0.1:8080"
// No ThumbprintList, and no auto-fetch attempt either: a plaintext
// provider presents no certificate, so an empty list is stored
// rather than the request failing.
requireCreated(t, create(t, server, "http://127.0.0.1:8080", false), arn)
got := getProvider(t, server, arn)
if got.Url != "http://127.0.0.1:8080" {
t.Errorf("stored Url = %q, want the scheme to be retained", got.Url)
}
if len(got.ThumbprintList) != 0 {
t.Errorf("ThumbprintList = %v, want empty for a plaintext provider", got.ThumbprintList)
}
})
t.Run("http and https providers for the same host coexist", func(t *testing.T) {
// The retained scheme is what keeps these two distinct resources:
// stored stripped, both would collide on one ARN and one storage key.
server := newIAMControllerTestServerWith(t, WithOIDCAllowPrivateEndpoints(), WithOIDCAllowInsecureTransport())
requireCreated(t, create(t, server, "http://idp.example", false),
"arn:aws:iam::000000000000:oidc-provider/http://idp.example")
requireCreated(t, create(t, server, "https://idp.example", true),
"arn:aws:iam::000000000000:oidc-provider/idp.example")
})
}
// TestIAMApiControllerAssumeRoleWithWebIdentityLoopbackIdP is the only test
// that drives AssumeRoleWithWebIdentity all the way to a real verified
// signature: with the two endpoint relaxations set, a plaintext OIDC
// provider on loopback — the spire-oidc-discovery-provider-as-a-sidecar
// shape — is reachable, so the discovery document and JWKS are really
// fetched over the network and a real RS256 signature is really checked
// against the published key. Every other AssumeRoleWithWebIdentity test
// stops at the fetch, which the default posture refuses outright.
func TestIAMApiControllerAssumeRoleWithWebIdentityLoopbackIdP(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa.GenerateKey: %v", err)
}
var issuer string
idp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/openid-configuration":
json.NewEncoder(w).Encode(map[string]any{
"issuer": issuer,
"jwks_uri": issuer + "/keys",
})
case "/keys":
json.NewEncoder(w).Encode(map[string]any{"keys": []any{map[string]any{
"kty": "RSA",
"kid": "k1",
"n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes()),
}}})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer idp.Close()
issuer = idp.URL // "http://127.0.0.1:<port>"
server := newIAMControllerTestServerWith(t, WithOIDCAllowPrivateEndpoints(), WithOIDCAllowInsecureTransport())
providerArn := createTestOIDCProviderForTrust(t, server, issuer, "versitygw")
createTestRoleForTrust(t, server, "spire-role",
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"`+providerArn+`"},`+
`"Action":"sts:AssumeRoleWithWebIdentity","Condition":{"StringEquals":{"`+issuer+`:aud":"versitygw"}}}]}`)
signedToken := func(t *testing.T, signingKey *rsa.PrivateKey, subject string) string {
t.Helper()
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
"iss": issuer,
"aud": "versitygw",
"sub": subject,
"iat": time.Now().Unix(),
"exp": time.Now().Add(time.Hour).Unix(),
})
token.Header["kid"] = "k1"
signed, err := token.SignedString(signingKey)
if err != nil {
t.Fatalf("sign token: %v", err)
}
return signed
}
assume := func(t *testing.T, token, sessionName string) *http.Response {
t.Helper()
return doSTSAction(t, server, url.Values{
"Action": {"AssumeRoleWithWebIdentity"},
"RoleArn": {"arn:aws:iam::000000000000:role/spire-role"},
"RoleSessionName": {sessionName},
"WebIdentityToken": {token},
})
}
t.Run("correctly signed token is accepted", func(t *testing.T) {
resp := assume(t, signedToken(t, key, "spiffe://example.org/ns/default/sa/versitygw"), "spire-session")
if resp.StatusCode != http.StatusOK {
t.Fatalf("AssumeRoleWithWebIdentity status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
var out iamtypes.AssumeRoleWithWebIdentityResponse
unmarshalXML(t, readBody(t, resp), &out)
if out.Result.SubjectFromWebIdentityToken != "spiffe://example.org/ns/default/sa/versitygw" {
t.Errorf("SubjectFromWebIdentityToken = %q", out.Result.SubjectFromWebIdentityToken)
}
if out.Result.Provider != issuer {
t.Errorf("Provider = %q, want %q", out.Result.Provider, issuer)
}
if out.Result.Credentials.SessionToken == "" || out.Result.Credentials.AccessKeyId == "" {
t.Errorf("no session credentials returned: %+v", out.Result.Credentials)
}
})
t.Run("token signed by a different key is rejected", func(t *testing.T) {
// The relaxations reach the endpoint; they must not weaken what
// happens once the real JWKS is in hand.
forgedKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa.GenerateKey: %v", err)
}
resp := assume(t, signedToken(t, forgedKey, "attacker"), "attacker-session")
requireSTSError(t, resp, http.StatusBadRequest, "Sender", "InvalidIdentityToken",
"The web identity token provided could not be validated. See the AssumeRoleWithWebIdentity documentation for requirements.")
})
}
// accessKeyImplicitUserNameActions are the four access-key actions that
// accept an omitted UserName and infer it from the calling access key.
var accessKeyImplicitUserNameActions = []string{"CreateAccessKey", "UpdateAccessKey", "DeleteAccessKey", "ListAccessKeys"}
+11 -6
View File
@@ -52,7 +52,7 @@ const iamActionPrefix = "iam:"
// requestConditionContext supplies the request's aws:SourceIp/aws:username/
// aws:PrincipalArn/aws:CurrentTime/aws:EpochTime values for a statement's
// Condition block.
func VerifyIAMPolicy(store iamutil.IdentityStore) fiber.Handler {
func VerifyIAMPolicy(store iamutil.IdentityStore, oidcPolicy iamutil.OIDCEndpointPolicy) fiber.Handler {
return func(ctx fiber.Ctx) error {
identity, _ := httpctx.ContextKeyCallerIdentity.Get(ctx).(types.Identity)
if identity.IsRoot {
@@ -62,7 +62,7 @@ func VerifyIAMPolicy(store iamutil.IdentityStore) fiber.Handler {
action, _ := iamutil.RequestParam(ctx, "Action")
fullAction := iamActionPrefix + action
resourceArn, resourceTags := resourceForAction(ctx, store, action)
resourceArn, resourceTags := resourceForAction(ctx, store, action, oidcPolicy)
reqCtx := policy.RequestContext{
Action: fullAction,
Resource: resourceArn,
@@ -166,7 +166,7 @@ func AuthorizeSplit(identity types.Identity, reqCtx policy.RequestContext) (iden
// request still reaches the controller afterward, which reports the
// specific NoSuchEntity/MissingValue error if authorization happens to pass
// on a wildcard grant, or AccessDenied first if it doesn't.
func resourceForAction(ctx fiber.Ctx, store iamutil.IdentityStore, action string) (string, []types.Tag) {
func resourceForAction(ctx fiber.Ctx, store iamutil.IdentityStore, action string, oidcPolicy iamutil.OIDCEndpointPolicy) (string, []types.Tag) {
switch action {
case "CreateUser":
return newUserResource(ctx), nil
@@ -183,7 +183,7 @@ func resourceForAction(ctx fiber.Ctx, store iamutil.IdentityStore, action string
"TagRole", "UntagRole", "ListRoleTags":
return existingRoleResource(ctx, store)
case "CreateOpenIDConnectProvider":
return newOIDCProviderResource(ctx), nil
return newOIDCProviderResource(ctx, oidcPolicy), nil
case "GetOpenIDConnectProvider", "DeleteOpenIDConnectProvider", "AddClientIDToOpenIDConnectProvider",
"RemoveClientIDFromOpenIDConnectProvider", "UpdateOpenIDConnectProviderThumbprint",
"TagOpenIDConnectProvider", "UntagOpenIDConnectProvider", "ListOpenIDConnectProviderTags":
@@ -326,12 +326,17 @@ func existingRoleResource(ctx fiber.Ctx, store iamutil.IdentityStore) (string, [
return role.Arn, role.Tags
}
func newOIDCProviderResource(ctx fiber.Ctx) string {
// newOIDCProviderResource builds the ARN of the provider a
// CreateOpenIDConnectProvider request would create, canonicalizing the Url
// exactly as the controller will. oidcPolicy must therefore be the same one
// the controller holds: a Url the controller would accept but this rejects
// resolves to "", which only a wildcard Resource statement matches.
func newOIDCProviderResource(ctx fiber.Ctx, oidcPolicy iamutil.OIDCEndpointPolicy) string {
rawURL, ok := iamutil.RequestParam(ctx, "Url")
if !ok || rawURL == "" {
return "*"
}
url, err := iamutil.ValidateOIDCProviderURL(rawURL)
url, err := iamutil.ValidateOIDCProviderURL(rawURL, oidcPolicy)
if err != nil {
return ""
}
+99 -13
View File
@@ -39,6 +39,70 @@ const (
var oidcHostLabelPattern = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`)
// insecureOIDCScheme is the plaintext scheme an OIDC provider Url may carry
// only when OIDCEndpointPolicy.AllowInsecureTransport is set.
const insecureOIDCScheme = "http://"
// OIDCEndpointPolicy relaxes the endpoint checks applied to an OIDC
// provider's Url and to every outbound fetch made against it (thumbprint
// auto-fetch at CreateOpenIDConnectProvider time, and the discovery
// document plus JWKS at AssumeRoleWithWebIdentity time).
//
// The zero value is the default, AWS-matching posture for an
// internet-facing IdP: https only, on the implicit :443, at a publicly
// routable address, with full hostname and chain verification against the
// system trust store (or a registered ThumbprintList). That posture makes
// the IAM API unusable with an IdP that is deliberately unreachable from
// the public internet — a SPIFFE/SPIRE OIDC discovery provider on a
// cluster-internal Service, or one bound to loopback as a sidecar in the
// gateway's own pod — because every address such an IdP can have is
// rejected outright, and no combination of the other settings can express
// "this private address is the IdP".
type OIDCEndpointPolicy struct {
// AllowPrivateEndpoints permits a provider Url that resolves to a
// loopback, private, link-local, unspecified, or multicast address, and
// permits an explicit port in that Url (an IdP on an internal network
// rarely gets to own :443 on its host). Transport is otherwise
// unchanged: still https, still fully verified.
//
// This necessarily also re-permits cloud metadata endpoints
// (e.g. 169.254.169.254) as fetch targets, so enable it only
// where registering an OIDC provider is already a trusted,
// administrator-only operation.
AllowPrivateEndpoints bool
// AllowInsecureTransport permits a plaintext http:// provider Url —
// along with the http discovery/JWKS endpoints and redirects that
// implies — and disables TLS certificate verification, ThumbprintList
// pinning included, for https ones. It makes the network path itself
// the only thing authenticating the IdP, so it belongs only where that
// path is trustworthy on its own, such as a sidecar bound to loopback
// inside the gateway's own pod.
AllowInsecureTransport bool
}
// IsInsecureOIDCProviderURL reports whether providerURL, a stored provider
// Url, names a plaintext http endpoint.
//
// An https provider is stored scheme-stripped, the canonical form AWS uses;
// an http one (creatable only under AllowInsecureTransport) deliberately
// keeps its scheme in storage, in its ARN, and in the iss claim it is
// matched against, so "http://host" and "https://host" can never be taken
// for one another — the same reason WebIdentityIssuer strips only "https://".
func IsInsecureOIDCProviderURL(providerURL string) bool {
return strings.HasPrefix(providerURL, insecureOIDCScheme)
}
// OIDCEndpointURL restores the full endpoint URL of a stored provider Url:
// the "https://" ValidateOIDCProviderURL stripped, or the "http://" it
// deliberately kept.
func OIDCEndpointURL(providerURL string) string {
if IsInsecureOIDCProviderURL(providerURL) {
return providerURL
}
return "https://" + providerURL
}
// ParseStringList reads flat indexed list members "<paramName>.member.1",
// "<paramName>.member.2", ... — the AWS Query-protocol wire form for a bare
// []string (distinct from ParseTags's Key/Value-pair member form, used by
@@ -57,15 +121,17 @@ func ParseStringList(ctx fiber.Ctx, paramName string) []string {
}
// BuildOIDCProviderArn constructs the ARN for an IAM OIDC identity
// provider. url must already have its "https://" scheme stripped.
// provider. url must already be in ValidateOIDCProviderURL's canonical
// stored form: an https provider with its scheme stripped, an http one
// (AllowInsecureTransport only) with its scheme intact.
func BuildOIDCProviderArn(accountID, url string) string {
return fmt.Sprintf("arn:aws:iam::%s:oidc-provider/%s", accountID, url)
}
// ParseOIDCProviderArn validates arn's overall length and structural shape
// (arn:aws:iam::<account>:<resource-type>/<resource>) and, on success,
// returns the resource segment — the provider's Url with "https://" already
// stripped, exactly as stored. The account-id segment must match
// returns the resource segment — the provider's Url exactly as stored (see
// BuildOIDCProviderArn for that form). The account-id segment must match
// DefaultAccountID; any other value is rejected with AccessDenied, matching
// real AWS's behavior for a well-formed ARN referencing a foreign account.
//
@@ -134,9 +200,9 @@ func GetOIDCProviderArn(ctx fiber.Ctx, operation string) (string, error) {
}
// ValidateOIDCProviderURL validates the Url parameter of
// CreateOpenIDConnectProvider and returns it with its "https://" scheme
// stripped (the canonical form used for ARN construction, storage keys, and
// GetOpenIDConnectProvider's own Url response field).
// CreateOpenIDConnectProvider and returns its canonical stored form — the
// form used for ARN construction, storage keys, iss-claim matching, and
// GetOpenIDConnectProvider's own Url response field.
//
// This implements a pragmatic subset of AWS's real validation: scheme must
// be exactly "https", no userinfo/port/query/fragment, host must be a
@@ -144,7 +210,16 @@ func GetOIDCProviderArn(ctx fiber.Ctx, operation string) (string, error) {
// length <= MaxOIDCProviderURLLen. It does not attempt to reproduce every
// hostname-shape check AWS performs; it returns clear InvalidInput/
// ValidationError messages instead of chasing every malformed edge case.
func ValidateOIDCProviderURL(rawURL string) (string, error) {
//
// policy relaxes two of those rules for non-public IdPs:
// AllowPrivateEndpoints additionally accepts an explicit port, and
// AllowInsecureTransport additionally accepts an "http://" scheme.
//
// An https Url is returned scheme-stripped, as AWS canonicalizes it; an
// http one keeps its scheme, so that it stays distinguishable from the same
// host over https everywhere the stored form is used (see
// IsInsecureOIDCProviderURL).
func ValidateOIDCProviderURL(rawURL string, policy OIDCEndpointPolicy) (string, error) {
if rawURL == "" {
return "", iamerr.MissingValue("url")
}
@@ -152,27 +227,38 @@ func ValidateOIDCProviderURL(rawURL string) (string, error) {
return "", iamerr.ValueTooLong("url", MaxOIDCProviderURLLen)
}
// A URL with no scheme delimiter at all (e.g. "example.com") is
// rejected as ValidationError; one with a scheme other than https
// (e.g. "http://example.com") is rejected as InvalidInput — distinct
// error codes for distinct malformed inputs.
// rejected as ValidationError; one with a scheme the policy doesn't
// permit (e.g. "http://example.com" by default) is rejected as
// InvalidInput — distinct error codes for distinct malformed inputs.
if !strings.Contains(rawURL, "://") {
return "", iamerr.ValidationError("Invalid Open ID Connect Provider URL")
}
if !strings.HasPrefix(rawURL, "https://") {
insecure := policy.AllowInsecureTransport && strings.HasPrefix(rawURL, insecureOIDCScheme)
if !insecure && !strings.HasPrefix(rawURL, "https://") {
return "", iamerr.InvalidInput("Invalid Open ID Connect Provider URL. The URL must begin with https://.")
}
wantScheme := "https"
if insecure {
wantScheme = "http"
}
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Scheme != "https" || parsed.Host == "" {
if err != nil || parsed.Scheme != wantScheme || parsed.Host == "" {
return "", iamerr.ValidationError("Invalid Open ID Connect Provider URL")
}
if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Port() != "" {
if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return "", iamerr.InvalidInput("Invalid Open ID Connect Provider URL.")
}
if parsed.Port() != "" && !policy.AllowPrivateEndpoints {
return "", iamerr.InvalidInput("Invalid Open ID Connect Provider URL.")
}
if !isValidOIDCHostname(parsed.Hostname()) {
return "", iamerr.InvalidInput("Invalid Open ID Connect Provider URL.")
}
if insecure {
return rawURL, nil
}
return strings.TrimPrefix(rawURL, "https://"), nil
}
+158
View File
@@ -0,0 +1,158 @@
// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package iamutil
import (
"strings"
"testing"
)
var (
strictOIDCPolicy = OIDCEndpointPolicy{}
privateOIDCPolicy = OIDCEndpointPolicy{AllowPrivateEndpoints: true}
insecureOIDCPolicy = OIDCEndpointPolicy{AllowPrivateEndpoints: true, AllowInsecureTransport: true}
)
func TestValidateOIDCProviderURL(t *testing.T) {
tests := []struct {
name string
rawURL string
policy OIDCEndpointPolicy
want string // "" means the URL must be rejected
}{
// Default posture: AWS's own rules.
{"https host", "https://example.com", strictOIDCPolicy, "example.com"},
{"https host with path", "https://example.com/oidc", strictOIDCPolicy, "example.com/oidc"},
{"no scheme", "example.com", strictOIDCPolicy, ""},
{"http rejected by default", "http://example.com", strictOIDCPolicy, ""},
{"port rejected by default", "https://example.com:8443", strictOIDCPolicy, ""},
{"userinfo", "https://user@example.com", strictOIDCPolicy, ""},
{"query", "https://example.com?a=b", strictOIDCPolicy, ""},
{"fragment", "https://example.com#frag", strictOIDCPolicy, ""},
{"too long", "https://" + strings.Repeat("a", MaxOIDCProviderURLLen) + ".com", strictOIDCPolicy, ""},
{"empty", "", strictOIDCPolicy, ""},
// AllowPrivateEndpoints: an explicit port becomes legal. A private
// address was always legal *syntax* - it is the fetch that refuses
// it - so a bare private host is accepted under both policies.
{"port allowed", "https://spire-oidc.spire.svc:8443", privateOIDCPolicy, "spire-oidc.spire.svc:8443"},
{"loopback with port", "https://127.0.0.1:8443", privateOIDCPolicy, "127.0.0.1:8443"},
{"ipv6 literal with port", "https://[::1]:8443", privateOIDCPolicy, "[::1]:8443"},
{"cluster service no port", "https://spire-oidc.spire.svc", privateOIDCPolicy, "spire-oidc.spire.svc"},
{"http still rejected", "http://127.0.0.1:8080", privateOIDCPolicy, ""},
// AllowInsecureTransport: http is accepted and, unlike https, keeps
// its scheme in the stored form.
{"http kept verbatim", "http://127.0.0.1:8080", insecureOIDCPolicy, "http://127.0.0.1:8080"},
{"http with path", "http://127.0.0.1:8080/oidc", insecureOIDCPolicy, "http://127.0.0.1:8080/oidc"},
{"https still stripped", "https://example.com", insecureOIDCPolicy, "example.com"},
{"other scheme still rejected", "ftp://example.com", insecureOIDCPolicy, ""},
{"http userinfo still rejected", "http://user@127.0.0.1:8080", insecureOIDCPolicy, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ValidateOIDCProviderURL(tt.rawURL, tt.policy)
if tt.want == "" {
if err == nil {
t.Fatalf("ValidateOIDCProviderURL(%q, %+v) = %q, want an error", tt.rawURL, tt.policy, got)
}
return
}
if err != nil {
t.Fatalf("ValidateOIDCProviderURL(%q, %+v): %v", tt.rawURL, tt.policy, err)
}
if got != tt.want {
t.Errorf("ValidateOIDCProviderURL(%q, %+v) = %q, want %q", tt.rawURL, tt.policy, got, tt.want)
}
})
}
}
// TestOIDCProviderURLSchemeStaysDistinguishable pins down why an http
// provider keeps its scheme in the stored form: the stored Url is what an
// incoming token's iss claim is matched against, so if "http://host" were
// stored stripped it would be indistinguishable from a separately
// registered "https://host", and a token from either issuer would satisfy
// the other's trust policy.
func TestOIDCProviderURLSchemeStaysDistinguishable(t *testing.T) {
secure, err := ValidateOIDCProviderURL("https://idp.example", insecureOIDCPolicy)
if err != nil {
t.Fatalf("ValidateOIDCProviderURL(https): %v", err)
}
insecure, err := ValidateOIDCProviderURL("http://idp.example", insecureOIDCPolicy)
if err != nil {
t.Fatalf("ValidateOIDCProviderURL(http): %v", err)
}
if secure == insecure {
t.Fatalf("http and https providers for the same host both stored as %q", secure)
}
// WebIdentityIssuer is the other half: it must map each token's iss back
// onto exactly the provider that issued it.
for iss, want := range map[string]string{
"https://idp.example": secure,
"http://idp.example": insecure,
} {
got, ok := WebIdentityIssuer(map[string]any{"iss": iss})
if !ok || got != want {
t.Errorf("WebIdentityIssuer(%q) = (%q, %v), want (%q, true)", iss, got, ok, want)
}
}
}
func TestOIDCEndpointURL(t *testing.T) {
tests := []struct{ providerURL, want string }{
{"example.com", "https://example.com"},
{"example.com/oidc", "https://example.com/oidc"},
{"spire-oidc.spire.svc:8443", "https://spire-oidc.spire.svc:8443"},
{"http://127.0.0.1:8080", "http://127.0.0.1:8080"},
{"http://127.0.0.1:8080/oidc", "http://127.0.0.1:8080/oidc"},
}
for _, tt := range tests {
t.Run(tt.providerURL, func(t *testing.T) {
if got := OIDCEndpointURL(tt.providerURL); got != tt.want {
t.Errorf("OIDCEndpointURL(%q) = %q, want %q", tt.providerURL, got, tt.want)
}
if got := IsInsecureOIDCProviderURL(tt.providerURL); got != strings.HasPrefix(tt.want, "http://") {
t.Errorf("IsInsecureOIDCProviderURL(%q) = %v", tt.providerURL, got)
}
})
}
}
// TestBuildOIDCProviderArnRoundTripsRelaxedURLs confirms the ARN encoding
// survives the two new Url shapes: an explicit port and a retained
// "http://" both contain characters ParseOIDCProviderArn splits on, so a
// naive split would truncate the provider Url or misread the account id.
func TestBuildOIDCProviderArnRoundTripsRelaxedURLs(t *testing.T) {
for _, url := range []string{
"example.com",
"spire-oidc.spire.svc:8443",
"http://127.0.0.1:8080",
"http://127.0.0.1:8080/oidc",
} {
t.Run(url, func(t *testing.T) {
arn := BuildOIDCProviderArn(DefaultAccountID, url)
got, err := ParseOIDCProviderArn(arn)
if err != nil {
t.Fatalf("ParseOIDCProviderArn(%q): %v", arn, err)
}
if got != url {
t.Errorf("ParseOIDCProviderArn(%q) = %q, want %q", arn, got, url)
}
})
}
}
+62 -28
View File
@@ -33,13 +33,14 @@ const oidcThumbprintFetchTimeout = 8 * time.Second
// FetchThumbprint implements CreateOpenIDConnectProvider's auto-fetch
// behavior: it opens a TLS handshake (crypto/tls, not a full HTTP GET) to
// host:443, where host is derived from providerURL (a scheme-stripped OIDC
// provider Url), verifying the presented chain against the system trust
// store and the provider's own hostname like any normal TLS client, and
// returns the SHA-1 thumbprint of the last (top-most/intermediate CA)
// certificate in the peer's presented chain.
// the host authority of providerURL (a stored OIDC provider Url) on its
// explicit port or, as is normally the only possibility, 443 — verifying
// the presented chain against the system trust store and the provider's own
// hostname like any normal TLS client, and returns the SHA-1 thumbprint of
// the last (top-most/intermediate CA) certificate in the peer's presented
// chain.
//
// SSRF hardening (mandatory): the hostname is resolved once via
// SSRF hardening: the hostname is resolved once via
// net.DefaultResolver.LookupIP; if any resolved address is
// loopback/private/link-local/unspecified/multicast (this range covers
// 169.254.169.254 and other cloud metadata endpoints), the fetch is
@@ -47,8 +48,10 @@ const oidcThumbprintFetchTimeout = 8 * time.Second
// the pre-validated IPs directly (never re-resolving the hostname at dial
// time, closing the DNS-rebinding TOCTOU gap) while presenting the original
// hostname via tls.Config.ServerName for SNI/certificate purposes.
// policy.AllowPrivateEndpoints waives only the address check — the single
// resolution and pinned-IP dial stay in place either way.
//
// Verification is deliberately NOT skipped here: unlike a one-shot
// Verification is deliberately not skipped by default: unlike a one-shot
// connection whose result is used and discarded, the certificate observed
// during this handshake is persisted as a long-lived trust anchor, compared
// against every future JWKS fetch for this provider. An unauthenticated
@@ -62,10 +65,19 @@ const oidcThumbprintFetchTimeout = 8 * time.Second
// obtained the fingerprint through some independently verified channel —
// the same operational shape WithOIDCThumbprintAutoFetchDisabled already
// provides unconditionally, scoped here to just the providers that fail
// public verification.
func FetchThumbprint(ctx context.Context, providerURL string) (string, error) {
host := hostFromOIDCUrl(providerURL)
displayURL := "https://" + providerURL
// public verification — or, for an IdP whose certificate cannot chain to a
// public root by construction, policy.AllowInsecureTransport, which drops
// verification for this handshake entirely and pins whatever is presented.
func FetchThumbprint(ctx context.Context, providerURL string, policy OIDCEndpointPolicy) (string, error) {
displayURL := OIDCEndpointURL(providerURL)
if IsInsecureOIDCProviderURL(providerURL) {
// A plaintext http provider performs no handshake, so there is no
// certificate to observe. Callers skip auto-fetch for these
// entirely; this is the guard for the ones that don't.
debuglogger.Logf("oidc thumbprint fetch: %q is a plaintext http provider and presents no certificate", displayURL)
return "", iamerr.OpenIdIdpCommunicationError(displayURL)
}
host, port := splitOIDCHostPort(hostFromOIDCUrl(providerURL))
ctx, cancel := context.WithTimeout(ctx, oidcThumbprintFetchTimeout)
defer cancel()
@@ -75,14 +87,16 @@ func FetchThumbprint(ctx context.Context, providerURL string) (string, error) {
debuglogger.Logf("oidc thumbprint fetch: dns lookup failed for %q: %v", host, err)
return "", iamerr.OpenIdIdpCommunicationError(displayURL)
}
for _, ip := range ips {
if isDisallowedFetchTarget(ip) {
debuglogger.Logf("oidc thumbprint fetch: refusing to dial disallowed address %q for host %q", ip, host)
return "", iamerr.OpenIdIdpCommunicationError(displayURL)
if !policy.AllowPrivateEndpoints {
for _, ip := range ips {
if isDisallowedFetchTarget(ip) {
debuglogger.Logf("oidc thumbprint fetch: refusing to dial disallowed address %q for host %q", ip, host)
return "", iamerr.OpenIdIdpCommunicationError(displayURL)
}
}
}
thumbprint, err := dialAndVerifyThumbprint(ctx, net.JoinHostPort(ips[0].String(), "443"), host, nil)
thumbprint, err := dialAndVerifyThumbprint(ctx, net.JoinHostPort(ips[0].String(), port), host, nil, policy.AllowInsecureTransport)
if err != nil {
debuglogger.Logf("oidc thumbprint fetch: tls dial/verify failed for %q (%s): %v — supply ThumbprintList explicitly for providers that fail public CA verification", host, ips[0], err)
return "", iamerr.OpenIdIdpCommunicationError(displayURL)
@@ -93,15 +107,19 @@ func FetchThumbprint(ctx context.Context, providerURL string) (string, error) {
// dialAndVerifyThumbprint dials addr over TLS, presenting host via SNI and
// verifying the peer's certificate against roots (nil selects the host
// system's trust store, FetchThumbprint's real usage), then returns
// ThumbprintFromChain's result for the now-verified presented chain. Split
// out from FetchThumbprint so the verification behavior itself is
// unit-testable with an explicit root pool — the same rationale as
// ThumbprintFromChain's own split, and for the same reason: FetchThumbprint's
// SSRF guard must always reject loopback targets, so it can never itself be
// exercised against a same-process test server.
func dialAndVerifyThumbprint(ctx context.Context, addr, host string, roots *x509.CertPool) (string, error) {
dialer := &tls.Dialer{Config: &tls.Config{ServerName: host, RootCAs: roots}}
// system's trust store, FetchThumbprint's real usage) unless insecure drops
// verification altogether, then returns ThumbprintFromChain's result for the
// presented chain. Split out from FetchThumbprint so the verification
// behavior itself is unit-testable with an explicit root pool — the same
// rationale as ThumbprintFromChain's own split, and for the same reason:
// FetchThumbprint's SSRF guard rejects loopback targets by default, so it
// can never itself be exercised against a same-process test server.
func dialAndVerifyThumbprint(ctx context.Context, addr, host string, roots *x509.CertPool, insecure bool) (string, error) {
dialer := &tls.Dialer{Config: &tls.Config{
ServerName: host,
RootCAs: roots,
InsecureSkipVerify: insecure,
}}
conn, err := dialer.DialContext(ctx, "tcp", addr)
if err != nil {
return "", err
@@ -121,7 +139,7 @@ func dialAndVerifyThumbprint(ctx context.Context, addr, host string, roots *x509
// in chain, hex-encoded and lowercased. Split out from FetchThumbprint as a
// pure function specifically so it is unit-testable (e.g. against a chain
// obtained from httptest.NewTLSServer) without going through
// FetchThumbprint's SSRF guard, which must always reject loopback targets
// FetchThumbprint's SSRF guard, which rejects loopback targets by default
// and therefore can never itself be exercised against a same-process test
// server.
func ThumbprintFromChain(chain []*x509.Certificate) (string, error) {
@@ -133,17 +151,33 @@ func ThumbprintFromChain(chain []*x509.Certificate) (string, error) {
return hex.EncodeToString(sum[:]), nil
}
// isDisallowedFetchTarget reports whether ip is off-limits as an outbound
// OIDC fetch target under the default posture. Callers skip it entirely
// when OIDCEndpointPolicy.AllowPrivateEndpoints is set.
func isDisallowedFetchTarget(ip net.IP) bool {
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast()
}
// hostFromOIDCUrl extracts the host (no scheme, no path — OIDC provider
// URLs are validated to disallow explicit ports) from a scheme-stripped
// hostFromOIDCUrl extracts the host authority (no scheme, no path, but
// including an explicit port when the Url carries one) from a stored
// provider Url.
func hostFromOIDCUrl(providerURL string) string {
providerURL = strings.TrimPrefix(providerURL, insecureOIDCScheme)
if before, _, ok := strings.Cut(providerURL, "/"); ok {
return before
}
return providerURL
}
// splitOIDCHostPort splits a provider Url's host authority into hostname
// and port, defaulting to 443 — the only port reachable unless
// OIDCEndpointPolicy.AllowPrivateEndpoints permitted an explicit one — and
// unwrapping the brackets around a port-less IPv6 literal so the result is
// always a dialable hostname.
func splitOIDCHostPort(hostport string) (host, port string) {
if h, p, err := net.SplitHostPort(hostport); err == nil {
return h, p
}
return strings.TrimSuffix(strings.TrimPrefix(hostport, "["), "]"), "443"
}
@@ -85,11 +85,32 @@ func TestDialAndVerifyThumbprintRejectsUntrustedCert(t *testing.T) {
// roots=nil selects the host system's real trust store, the same as
// FetchThumbprint's actual usage - httptest's self-signed certificate
// must not verify against it.
if _, err := dialAndVerifyThumbprint(context.Background(), srv.Listener.Addr().String(), "example.com", nil); err == nil {
if _, err := dialAndVerifyThumbprint(context.Background(), srv.Listener.Addr().String(), "example.com", nil, false); err == nil {
t.Fatal("dialAndVerifyThumbprint: expected verification error for untrusted self-signed certificate, got nil")
}
}
// TestDialAndVerifyThumbprintInsecureAcceptsUntrustedCert covers the
// AllowInsecureTransport path: the very chain
// TestDialAndVerifyThumbprintRejectsUntrustedCert requires to be rejected
// must be accepted and hashed once the operator has declared the network
// path itself to be the trust boundary — otherwise an IdP whose certificate
// cannot chain to a public root by construction could never use auto-fetch.
func TestDialAndVerifyThumbprintInsecureAcceptsUntrustedCert(t *testing.T) {
srv := httptest.NewTLSServer(nil)
defer srv.Close()
got, err := dialAndVerifyThumbprint(context.Background(), srv.Listener.Addr().String(), "example.com", nil, true)
if err != nil {
t.Fatalf("dialAndVerifyThumbprint(insecure=true): %v", err)
}
sum := sha1.Sum(srv.Certificate().Raw)
if want := hex.EncodeToString(sum[:]); got != want {
t.Fatalf("dialAndVerifyThumbprint thumbprint = %q, want %q", got, want)
}
}
// TestDialAndVerifyThumbprintAcceptsVerifiedCert is the positive
// counterpart: once the peer's certificate does verify (here, against an
// explicit pool containing the test server's own certificate, standing in
@@ -104,7 +125,7 @@ func TestDialAndVerifyThumbprintAcceptsVerifiedCert(t *testing.T) {
roots := x509.NewCertPool()
roots.AddCert(srv.Certificate())
got, err := dialAndVerifyThumbprint(context.Background(), srv.Listener.Addr().String(), "example.com", roots)
got, err := dialAndVerifyThumbprint(context.Background(), srv.Listener.Addr().String(), "example.com", roots, false)
if err != nil {
t.Fatalf("dialAndVerifyThumbprint: %v", err)
}
@@ -128,7 +149,7 @@ func TestFetchThumbprintSSRFGuard(t *testing.T) {
}
for _, host := range tests {
t.Run(host, func(t *testing.T) {
_, err := FetchThumbprint(context.Background(), host)
_, err := FetchThumbprint(context.Background(), host, OIDCEndpointPolicy{})
if err == nil {
t.Fatalf("FetchThumbprint(%q): expected SSRF guard error, got nil", host)
}
@@ -136,13 +157,86 @@ func TestFetchThumbprintSSRFGuard(t *testing.T) {
}
}
// TestFetchThumbprintAllowPrivateEndpoints confirms AllowPrivateEndpoints
// waives the address check rather than merely reordering it: with the guard
// off, a loopback provider gets as far as a real TLS handshake against a
// same-process server and yields that server's own thumbprint — something
// TestFetchThumbprintSSRFGuard shows is impossible by default.
func TestFetchThumbprintAllowPrivateEndpoints(t *testing.T) {
srv := httptest.NewTLSServer(nil)
defer srv.Close()
policy := OIDCEndpointPolicy{AllowPrivateEndpoints: true, AllowInsecureTransport: true}
// The listener's host:port becomes the provider Url's authority, which
// only parses as one because AllowPrivateEndpoints also permits a port.
got, err := FetchThumbprint(context.Background(), srv.Listener.Addr().String(), policy)
if err != nil {
t.Fatalf("FetchThumbprint(loopback, AllowPrivateEndpoints): %v", err)
}
sum := sha1.Sum(srv.Certificate().Raw)
if want := hex.EncodeToString(sum[:]); got != want {
t.Fatalf("FetchThumbprint thumbprint = %q, want %q", got, want)
}
}
// TestFetchThumbprintRejectsPlaintextProvider covers the defensive branch
// for an http provider Url: there is no handshake to observe a certificate
// in, so auto-fetch must report a failure rather than dial anything.
func TestFetchThumbprintRejectsPlaintextProvider(t *testing.T) {
policy := OIDCEndpointPolicy{AllowPrivateEndpoints: true, AllowInsecureTransport: true}
if _, err := FetchThumbprint(context.Background(), "http://127.0.0.1:8080", policy); err == nil {
t.Fatal("FetchThumbprint(http provider): expected an error, got nil")
}
}
func TestFetchThumbprintDNSFailure(t *testing.T) {
_, err := FetchThumbprint(context.Background(), "this-host-should-not-resolve.invalid")
_, err := FetchThumbprint(context.Background(), "this-host-should-not-resolve.invalid", OIDCEndpointPolicy{})
if err == nil {
t.Fatal("expected error for unresolvable host")
}
}
func TestSplitOIDCHostPort(t *testing.T) {
tests := []struct {
hostport string
wantHost string
wantPort string
}{
{"example.com", "example.com", "443"},
{"spire-oidc.spire.svc:8443", "spire-oidc.spire.svc", "8443"},
{"127.0.0.1", "127.0.0.1", "443"},
{"127.0.0.1:8443", "127.0.0.1", "8443"},
{"[::1]:8443", "::1", "8443"},
{"[::1]", "::1", "443"},
}
for _, tt := range tests {
t.Run(tt.hostport, func(t *testing.T) {
host, port := splitOIDCHostPort(tt.hostport)
if host != tt.wantHost || port != tt.wantPort {
t.Errorf("splitOIDCHostPort(%q) = (%q, %q), want (%q, %q)", tt.hostport, host, port, tt.wantHost, tt.wantPort)
}
})
}
}
func TestHostFromOIDCUrl(t *testing.T) {
tests := []struct{ providerURL, want string }{
{"example.com", "example.com"},
{"example.com/path", "example.com"},
{"spire-oidc.spire.svc:8443", "spire-oidc.spire.svc:8443"},
{"http://127.0.0.1:8080", "127.0.0.1:8080"},
{"http://127.0.0.1:8080/oidc", "127.0.0.1:8080"},
}
for _, tt := range tests {
t.Run(tt.providerURL, func(t *testing.T) {
if got := hostFromOIDCUrl(tt.providerURL); got != tt.want {
t.Errorf("hostFromOIDCUrl(%q) = %q, want %q", tt.providerURL, got, tt.want)
}
})
}
}
func TestIsDisallowedFetchTarget(t *testing.T) {
tests := []struct {
ip string
+69 -33
View File
@@ -222,11 +222,13 @@ func ParseWebIdentityClaims(tokenString string) (jwt.MapClaims, error) {
//
// Only an "https://" prefix is stripped — OIDC issuer identifiers are
// compared exactly, scheme included, and CreateOpenIDConnectProvider already
// requires every registered provider's Url to be https. An iss using any
// other scheme (or none at all) therefore can never legitimately equal a
// registered provider; returning it unstripped in that case (rather than
// also trimming a bare "http://") guarantees it stays distinguishable from a
// same-host https issuer instead of being silently treated as equivalent.
// stores every https provider's Url scheme-stripped. An iss using any other
// scheme is returned unstripped, which is exactly what makes it comparable:
// a plaintext provider (registrable only under
// OIDCEndpointPolicy.AllowInsecureTransport) is stored with its "http://"
// intact, so trimming it here would collapse "http://host" and
// "https://host" into the same value and let one issuer stand in for the
// other.
func WebIdentityIssuer(claims jwt.MapClaims) (string, bool) {
iss, ok := claims["iss"].(string)
if !ok || iss == "" {
@@ -416,12 +418,13 @@ func VerifyWebIdentityRequiredClaims(claims jwt.MapClaims, now time.Time) error
// thumbprints is the OIDC provider's registered ThumbprintList, used as a
// pinned-certificate fallback when the JWKS endpoint's TLS certificate
// doesn't chain to a trusted root (self-signed/private-CA providers).
// policy carries the deployment's endpoint relaxations, if any.
//
// If the cached key set doesn't contain the token's kid, the cache is
// bypassed for one forced refresh before giving up — the provider may have
// rotated its signing key since the cache entry was fetched.
func VerifyWebIdentitySignature(ctx context.Context, tokenString, issuerURL string, thumbprints []string) (jwt.MapClaims, error) {
keys, err := cachedJWKS(ctx, issuerURL, thumbprints)
func VerifyWebIdentitySignature(ctx context.Context, tokenString, issuerURL string, thumbprints []string, policy OIDCEndpointPolicy) (jwt.MapClaims, error) {
keys, err := cachedJWKS(ctx, issuerURL, thumbprints, policy)
if err != nil {
debuglogger.Logf("failed to fetch JWKS for web identity provider %q: %v", issuerURL, err)
return nil, iamerr.InvalidIdentityTokenIDPCommunicationError()
@@ -429,7 +432,7 @@ func VerifyWebIdentitySignature(ctx context.Context, tokenString, issuerURL stri
claims, err := verifySignatureWithKeys(tokenString, keys)
if err != nil && errors.Is(err, errUnknownKID) {
keys, refreshErr := forceRefreshJWKSCache(ctx, issuerURL, thumbprints)
keys, refreshErr := forceRefreshJWKSCache(ctx, issuerURL, thumbprints, policy)
if refreshErr != nil {
debuglogger.Logf("failed to refresh JWKS for web identity provider %q: %v", issuerURL, refreshErr)
return nil, iamerr.InvalidIdentityTokenIDPCommunicationError()
@@ -451,7 +454,7 @@ var errUnknownKID = errors.New("no matching JWKS key for kid")
// verifySignatureWithKeys is VerifyWebIdentitySignature's network-free core,
// split out so it can be exercised directly against an in-memory key set
// (the SSRF guard in fetchJWKS's dialer means it can never itself be
// exercised against a same-process test server — the same split
// exercised against a same-process test server by default — the same split
// FetchThumbprint/ThumbprintFromChain use). The returned error is the raw
// parse/verification failure (not yet converted to an iamerr), so callers
// can distinguish errUnknownKID from every other failure.
@@ -561,7 +564,7 @@ type oidcDiscoveryDoc struct {
// reachable from the provider's own URL — otherwise a provider could return,
// or be redirected/misdirected to, an entirely different issuer's metadata.
func validateDiscoveryIssuer(doc oidcDiscoveryDoc, issuerURL string) error {
want := "https://" + issuerURL
want := OIDCEndpointURL(issuerURL)
if doc.Issuer != want {
return fmt.Errorf("discovery document for %q has mismatched issuer %q", issuerURL, doc.Issuer)
}
@@ -616,7 +619,7 @@ func jwksCacheKey(issuerURL string, thumbprints []string) string {
// cachedJWKS returns issuerURL's key set from cache if a fresh-enough entry
// exists for the current thumbprints, otherwise fetches and caches a fresh
// one.
func cachedJWKS(ctx context.Context, issuerURL string, thumbprints []string) (*jwkSet, error) {
func cachedJWKS(ctx context.Context, issuerURL string, thumbprints []string, policy OIDCEndpointPolicy) (*jwkSet, error) {
key := jwksCacheKey(issuerURL, thumbprints)
jwksCacheMu.Lock()
entry, ok := jwksCache[key]
@@ -624,7 +627,7 @@ func cachedJWKS(ctx context.Context, issuerURL string, thumbprints []string) (*j
if ok && time.Now().Before(entry.expiresAt) {
return entry.keys, nil
}
return fetchAndCacheJWKS(ctx, issuerURL, thumbprints)
return fetchAndCacheJWKS(ctx, issuerURL, thumbprints, policy)
}
// forceRefreshJWKSCache is VerifyWebIdentitySignature's fallback when a
@@ -644,7 +647,7 @@ func cachedJWKS(ctx context.Context, issuerURL string, thumbprints []string) (*j
// backoff, since a failed attempt never set the timestamp that would have
// gated the next one. Recording the attempt up front bounds retries to one
// per jwksMinForcedRefreshInterval regardless of whether the fetch succeeds.
func forceRefreshJWKSCache(ctx context.Context, issuerURL string, thumbprints []string) (*jwkSet, error) {
func forceRefreshJWKSCache(ctx context.Context, issuerURL string, thumbprints []string, policy OIDCEndpointPolicy) (*jwkSet, error) {
key := jwksCacheKey(issuerURL, thumbprints)
jwksCacheMu.Lock()
entry, ok := jwksCache[key]
@@ -664,7 +667,7 @@ func forceRefreshJWKSCache(ctx context.Context, issuerURL string, thumbprints []
jwksCache[key] = entry
jwksCacheMu.Unlock()
return fetchAndCacheJWKS(ctx, issuerURL, thumbprints)
return fetchAndCacheJWKS(ctx, issuerURL, thumbprints, policy)
}
// fetchAndCacheJWKS fetches issuerURL's key set and, on success, replaces
@@ -672,10 +675,10 @@ func forceRefreshJWKSCache(ctx context.Context, issuerURL string, thumbprints []
// thumbprints via jwksFetchGroup (keyed identically to jwksCache, so a
// caller mid-fetch for one thumbprint configuration never receives a result
// coalesced from a differently-configured concurrent caller).
func fetchAndCacheJWKS(ctx context.Context, issuerURL string, thumbprints []string) (*jwkSet, error) {
func fetchAndCacheJWKS(ctx context.Context, issuerURL string, thumbprints []string, policy OIDCEndpointPolicy) (*jwkSet, error) {
key := jwksCacheKey(issuerURL, thumbprints)
v, err, _ := jwksFetchGroup.Do(key, func() (any, error) {
keys, err := fetchJWKS(ctx, issuerURL, thumbprints)
keys, err := fetchJWKS(ctx, issuerURL, thumbprints, policy)
if err != nil {
return nil, err
}
@@ -694,14 +697,13 @@ func fetchAndCacheJWKS(ctx context.Context, issuerURL string, thumbprints []stri
}
// fetchJWKS retrieves issuerURL's OIDC discovery document, then the JWKS it
// points to. issuerURL is the provider's stored Url (scheme stripped).
// thumbprints, if non-empty, lets the fetch's TLS connections succeed
// against a self-signed/private-CA certificate whose chain matches one of
// them, the same trust-pinning fallback real AWS documents for OIDC
// providers.
func fetchJWKS(ctx context.Context, issuerURL string, thumbprints []string) (*jwkSet, error) {
client := ssrfSafeHTTPClient(thumbprints)
base := "https://" + issuerURL
// points to. issuerURL is the provider's stored Url. thumbprints, if
// non-empty, lets the fetch's TLS connections succeed against a
// self-signed/private-CA certificate whose chain matches one of them, the
// same trust-pinning fallback real AWS documents for OIDC providers.
func fetchJWKS(ctx context.Context, issuerURL string, thumbprints []string, policy OIDCEndpointPolicy) (*jwkSet, error) {
client := ssrfSafeHTTPClient(thumbprints, policy)
base := OIDCEndpointURL(issuerURL)
var doc oidcDiscoveryDoc
if err := fetchJSON(ctx, client, strings.TrimRight(base, "/")+"/.well-known/openid-configuration", &doc); err != nil {
@@ -710,7 +712,7 @@ func fetchJWKS(ctx context.Context, issuerURL string, thumbprints []string) (*jw
if err := validateDiscoveryIssuer(doc, issuerURL); err != nil {
return nil, err
}
if !strings.HasPrefix(doc.JWKSUri, "https://") {
if !isFetchableOIDCEndpoint(doc.JWKSUri, policy) {
return nil, fmt.Errorf("discovery document for %q has non-https jwks_uri %q", issuerURL, doc.JWKSUri)
}
@@ -773,6 +775,24 @@ func fetchJSON(ctx context.Context, client *http.Client, url string, out any) er
return json.Unmarshal(body, out)
}
// isFetchableOIDCEndpoint reports whether a URL a discovery document points
// at may be fetched: https always, http only where policy has already
// accepted a plaintext IdP. Matched on the literal prefix, so a jwks_uri
// naming anything else — including a scheme net/http would otherwise
// happily dial — never reaches the client.
func isFetchableOIDCEndpoint(rawURL string, policy OIDCEndpointPolicy) bool {
if strings.HasPrefix(rawURL, "https://") {
return true
}
return policy.AllowInsecureTransport && strings.HasPrefix(rawURL, insecureOIDCScheme)
}
// isFetchableOIDCScheme is isFetchableOIDCEndpoint for an already-parsed
// URL, used on the redirect path where net/http hands over a *url.URL.
func isFetchableOIDCScheme(scheme string, policy OIDCEndpointPolicy) bool {
return scheme == "https" || (policy.AllowInsecureTransport && scheme == "http")
}
// ssrfSafeHTTPClient returns an http.Client whose transport resolves each
// dial target's DNS once and rejects loopback/private/link-local/multicast
// addresses before connecting, mirroring FetchThumbprint's SSRF guard. It
@@ -789,7 +809,13 @@ func fetchJSON(ctx context.Context, client *http.Client, url string, out any) er
// standard CA-based verification would otherwise reject it, and falls back
// to ordinary hostname+CA verification against the system root pool
// whenever thumbprints is empty or doesn't match.
func ssrfSafeHTTPClient(thumbprints []string) *http.Client {
//
// policy relaxes exactly two of those behaviors, and nothing else:
// AllowPrivateEndpoints drops the resolved-address check (the DNS-once,
// dial-the-resolved-IP shape stays, so a rebind still can't redirect the
// connection), and AllowInsecureTransport additionally admits http targets
// and redirects and makes verifyOIDCConnection accept any chain.
func ssrfSafeHTTPClient(thumbprints []string, policy OIDCEndpointPolicy) *http.Client {
dialer := &net.Dialer{}
return &http.Client{
Timeout: oidcFetchTimeout,
@@ -797,8 +823,8 @@ func ssrfSafeHTTPClient(thumbprints []string) *http.Client {
if len(via) >= maxOIDCFetchRedirects {
return fmt.Errorf("stopped after %d redirects", maxOIDCFetchRedirects)
}
if req.URL.Scheme != "https" {
return fmt.Errorf("refusing to follow non-https redirect to %q", req.URL)
if !isFetchableOIDCScheme(req.URL.Scheme, policy) {
return fmt.Errorf("refusing to follow redirect to %q: disallowed scheme", req.URL)
}
return nil
},
@@ -812,9 +838,11 @@ func ssrfSafeHTTPClient(thumbprints []string) *http.Client {
if err != nil || len(ips) == 0 {
return nil, fmt.Errorf("dns lookup failed for %q", host)
}
for _, ip := range ips {
if isDisallowedFetchTarget(ip) {
return nil, fmt.Errorf("refusing to dial disallowed address %q for host %q", ip, host)
if !policy.AllowPrivateEndpoints {
for _, ip := range ips {
if isDisallowedFetchTarget(ip) {
return nil, fmt.Errorf("refusing to dial disallowed address %q for host %q", ip, host)
}
}
}
return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].String(), port))
@@ -822,7 +850,7 @@ func ssrfSafeHTTPClient(thumbprints []string) *http.Client {
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true, // verified ourselves via VerifyConnection below
VerifyConnection: func(cs tls.ConnectionState) error {
return verifyOIDCConnection(cs, thumbprints)
return verifyOIDCConnection(cs, thumbprints, policy)
},
},
},
@@ -841,10 +869,18 @@ func ssrfSafeHTTPClient(thumbprints []string) *http.Client {
// cryptographically issue the leaf and the leaf must match cs.ServerName.
// Falls back to standard hostname+CA verification against the system root
// pool whenever thumbprints is empty or none matches.
func verifyOIDCConnection(cs tls.ConnectionState, thumbprints []string) error {
//
// policy.AllowInsecureTransport accepts any chain outright, pinning
// included: the operator has declared the network path itself to be the
// trust boundary, and a partial check that silently passed on some
// certificates and not others would only obscure that.
func verifyOIDCConnection(cs tls.ConnectionState, thumbprints []string, policy OIDCEndpointPolicy) error {
if len(cs.PeerCertificates) == 0 {
return errors.New("iamutil: no certificate presented")
}
if policy.AllowInsecureTransport {
return nil
}
if len(thumbprints) > 0 {
top := cs.PeerCertificates[len(cs.PeerCertificates)-1]
+50 -8
View File
@@ -269,14 +269,14 @@ func TestVerifyOIDCConnection(t *testing.T) {
// net/http/internal/testcert), and it is self-signed, so it forms a
// valid one-certificate chain rooted at itself for that name.
cs := tls.ConnectionState{PeerCertificates: chain, ServerName: "example.com"}
if err := verifyOIDCConnection(cs, []string{thumbprint}); err != nil {
if err := verifyOIDCConnection(cs, []string{thumbprint}, OIDCEndpointPolicy{}); err != nil {
t.Fatalf("expected pinned thumbprint to be accepted for a matching hostname: %v", err)
}
})
t.Run("matching pinned thumbprint does not bypass hostname verification", func(t *testing.T) {
cs := tls.ConnectionState{PeerCertificates: chain, ServerName: "totally-different-host.example"}
if err := verifyOIDCConnection(cs, []string{thumbprint}); err == nil {
if err := verifyOIDCConnection(cs, []string{thumbprint}, OIDCEndpointPolicy{}); err == nil {
t.Fatal("expected pinned thumbprint to still be rejected for a non-matching hostname")
}
})
@@ -290,30 +290,72 @@ func TestVerifyOIDCConnection(t *testing.T) {
forged := append([]*x509.Certificate{unrelatedLeaf}, chain...)
cs := tls.ConnectionState{PeerCertificates: forged, ServerName: "example.com"}
if err := verifyOIDCConnection(cs, []string{thumbprint}); err == nil {
if err := verifyOIDCConnection(cs, []string{thumbprint}, OIDCEndpointPolicy{}); err == nil {
t.Fatal("expected forged chain (unrelated leaf + appended pinned cert) to be rejected")
}
})
t.Run("non-matching thumbprint falls back to standard verification and fails", func(t *testing.T) {
cs := tls.ConnectionState{PeerCertificates: chain, ServerName: "example.com"}
if err := verifyOIDCConnection(cs, []string{"0000000000000000000000000000000000000000"}); err == nil {
if err := verifyOIDCConnection(cs, []string{"0000000000000000000000000000000000000000"}, OIDCEndpointPolicy{}); err == nil {
t.Fatal("expected standard verification to fail for a self-signed cert not in the system pool")
}
})
t.Run("no thumbprints falls back to standard verification and fails", func(t *testing.T) {
cs := tls.ConnectionState{PeerCertificates: chain, ServerName: "example.com"}
if err := verifyOIDCConnection(cs, nil); err == nil {
if err := verifyOIDCConnection(cs, nil, OIDCEndpointPolicy{}); err == nil {
t.Fatal("expected standard verification to fail for a self-signed cert not in the system pool")
}
})
t.Run("no certificates presented", func(t *testing.T) {
if err := verifyOIDCConnection(tls.ConnectionState{}, nil); err == nil {
if err := verifyOIDCConnection(tls.ConnectionState{}, nil, OIDCEndpointPolicy{}); err == nil {
t.Fatal("expected error when no certificate is presented")
}
})
t.Run("insecure transport accepts a chain every other case rejects", func(t *testing.T) {
insecure := OIDCEndpointPolicy{AllowInsecureTransport: true}
// Untrusted chain, wrong hostname, and no pinned thumbprint - each
// on its own is a rejection above.
cs := tls.ConnectionState{PeerCertificates: chain, ServerName: "totally-different-host.example"}
if err := verifyOIDCConnection(cs, nil, insecure); err != nil {
t.Fatalf("expected AllowInsecureTransport to accept any chain: %v", err)
}
})
t.Run("insecure transport still requires a certificate", func(t *testing.T) {
insecure := OIDCEndpointPolicy{AllowInsecureTransport: true}
if err := verifyOIDCConnection(tls.ConnectionState{}, nil, insecure); err == nil {
t.Fatal("expected error when no certificate is presented at all")
}
})
}
func TestIsFetchableOIDCEndpoint(t *testing.T) {
insecure := OIDCEndpointPolicy{AllowInsecureTransport: true}
tests := []struct {
rawURL string
policy OIDCEndpointPolicy
want bool
}{
{"https://example.com/keys", OIDCEndpointPolicy{}, true},
{"http://example.com/keys", OIDCEndpointPolicy{}, false},
{"http://127.0.0.1:8080/keys", insecure, true},
{"https://127.0.0.1:8080/keys", insecure, true},
{"file:///etc/passwd", insecure, false},
{"//example.com/keys", insecure, false},
// AllowPrivateEndpoints alone is about addresses, not schemes.
{"http://10.0.0.1/keys", OIDCEndpointPolicy{AllowPrivateEndpoints: true}, false},
}
for _, tt := range tests {
t.Run(tt.rawURL, func(t *testing.T) {
if got := isFetchableOIDCEndpoint(tt.rawURL, tt.policy); got != tt.want {
t.Errorf("isFetchableOIDCEndpoint(%q, %+v) = %v, want %v", tt.rawURL, tt.policy, got, tt.want)
}
})
}
}
func TestVerifySignatureWithKeys(t *testing.T) {
@@ -560,7 +602,7 @@ func TestForceRefreshJWKSCacheGatesFailedAttempts(t *testing.T) {
ctx := context.Background()
if _, err := forceRefreshJWKSCache(ctx, issuer, nil); err == nil {
if _, err := forceRefreshJWKSCache(ctx, issuer, nil, OIDCEndpointPolicy{}); err == nil {
t.Fatal("forceRefreshJWKSCache() = nil error, want an error for a disallowed loopback target")
}
@@ -575,7 +617,7 @@ func TestForceRefreshJWKSCacheGatesFailedAttempts(t *testing.T) {
// A second forced refresh within jwksMinForcedRefreshInterval must be
// gated - failing immediately with no cached keys to fall back on -
// rather than attempting another fetch.
if _, err := forceRefreshJWKSCache(ctx, issuer, nil); err == nil {
if _, err := forceRefreshJWKSCache(ctx, issuer, nil, OIDCEndpointPolicy{}); err == nil {
t.Fatal("forceRefreshJWKSCache() = nil error on gated retry, want an error (no cached keys available)")
}
jwksCacheMu.Lock()
+5 -5
View File
@@ -50,13 +50,13 @@ type IAMApiRouter struct {
Ctrl IAMApiController
actions map[string]ActionHandler
rootCreds *RootCredentials
// oidcThumbprintAutoFetchDisabled is threaded into the controller;
// see IAMApiController.oidcThumbprintAutoFetchDisabled.
oidcThumbprintAutoFetchDisabled bool
// oidc is threaded into the controller and the policy middleware, both
// of which validate caller-supplied OIDC provider URLs
oidc OIDCConfig
}
func (r *IAMApiRouter) Init() {
r.Ctrl = NewController(r.store, r.oidcThumbprintAutoFetchDisabled)
r.Ctrl = NewController(r.store, r.oidc)
r.actions = map[string]ActionHandler{
// User CRUD
@@ -114,7 +114,7 @@ func (r *IAMApiRouter) Init() {
iamRoute := ProcessHandlers(r.routeAction,
iammiddleware.VerifyIAMAuth(sigv4auth.ServiceIAM, r.rootCreds, r.store),
iammiddleware.VerifyIAMPolicy(r.store),
iammiddleware.VerifyIAMPolicy(r.store, r.oidc.endpointPolicy()),
)
stsAuthRoute := ProcessHandlers(r.routeAction,
iammiddleware.VerifyIAMAuth(sigv4auth.ServiceSTS, r.rootCreds, r.store),
+54 -5
View File
@@ -27,6 +27,7 @@ import (
"github.com/gofiber/fiber/v3/middleware/recover"
"github.com/versity/versitygw/debuglogger"
"github.com/versity/versitygw/iamapi/internal/iammiddleware"
"github.com/versity/versitygw/iamapi/internal/iamutil"
"github.com/versity/versitygw/iamapi/storage"
"github.com/versity/versitygw/internal/netutil"
)
@@ -59,13 +60,41 @@ type IAMApiServer struct {
maxRequests int
socketPerm os.FileMode
onListen func()
// oidcThumbprintAutoFetchDisabled disables CreateOpenIDConnectProvider's
// TLS auto-fetch fallback; see WithOIDCThumbprintAutoFetchDisabled.
oidcThumbprintAutoFetchDisabled bool
// oidc holds the OIDC provider settings threaded into the router,
// controller, and policy middleware; see OIDCConfig.
oidc OIDCConfig
// corsAllowOrigin is the single origin browsers may call this API from
corsAllowOrigin string
}
// OIDCConfig groups the settings that govern how this API treats OIDC
// identity providers: whether CreateOpenIDConnectProvider may reach out for
// a thumbprint at all, and how strictly a provider's endpoint is validated
// and fetched from. The zero value is the default AWS-matching posture.
type OIDCConfig struct {
// ThumbprintAutoFetchDisabled disables CreateOpenIDConnectProvider's
// TLS auto-fetch fallback when ThumbprintList is omitted; see
// WithOIDCThumbprintAutoFetchDisabled.
ThumbprintAutoFetchDisabled bool
// AllowPrivateEndpoints permits OIDC provider URLs that resolve to
// loopback/private/link-local addresses, and that carry an explicit
// port; see WithOIDCAllowPrivateEndpoints.
AllowPrivateEndpoints bool
// AllowInsecureTransport permits plaintext http OIDC provider URLs and
// drops TLS verification for https ones; see
// WithOIDCAllowInsecureTransport.
AllowInsecureTransport bool
}
// endpointPolicy projects the two endpoint relaxations into the form
// iamutil's URL-validation and fetch helpers take.
func (c OIDCConfig) endpointPolicy() iamutil.OIDCEndpointPolicy {
return iamutil.OIDCEndpointPolicy{
AllowPrivateEndpoints: c.AllowPrivateEndpoints,
AllowInsecureTransport: c.AllowInsecureTransport,
}
}
func New(store storage.Storer, root RootCredentials, opts ...Option) (*IAMApiServer, error) {
if store == nil {
return nil, fmt.Errorf("iamapi: storer is required")
@@ -96,7 +125,7 @@ func New(store storage.Storer, root RootCredentials, opts ...Option) (*IAMApiSer
server.app = app
server.Router.app = app
server.Router.rootCreds = server.rootCreds
server.Router.oidcThumbprintAutoFetchDisabled = server.oidcThumbprintAutoFetchDisabled
server.Router.oidc = server.oidc
app.Use("*", recover.New(recover.Config{
EnableStackTrace: true,
@@ -184,7 +213,27 @@ func WithOnListen(fn func()) Option {
// the gateway making an outbound TLS connection to the caller-supplied URL
// — an operational safety valve for restricted/air-gapped deployments.
func WithOIDCThumbprintAutoFetchDisabled() Option {
return func(s *IAMApiServer) { s.oidcThumbprintAutoFetchDisabled = true }
return func(s *IAMApiServer) { s.oidc.ThumbprintAutoFetchDisabled = true }
}
// WithOIDCAllowPrivateEndpoints permits an OIDC provider Url that resolves
// to a loopback/private/link-local address, and one carrying an explicit
// port. Both are refused by default, which makes an IdP that only exists on
// an internal network — a SPIFFE/SPIRE OIDC discovery provider on a cluster
// Service, say — impossible to register or verify tokens against. Transport
// is unaffected: still https, still fully verified.
func WithOIDCAllowPrivateEndpoints() Option {
return func(s *IAMApiServer) { s.oidc.AllowPrivateEndpoints = true }
}
// WithOIDCAllowInsecureTransport permits plaintext http OIDC provider URLs
// and drops TLS certificate verification (ThumbprintList pinning included)
// for https ones, leaving the network path as the only thing authenticating
// the IdP. Intended for an IdP reachable only over a path that is itself
// trusted — a discovery provider bound to loopback as a sidecar in this
// process's own pod.
func WithOIDCAllowInsecureTransport() Option {
return func(s *IAMApiServer) { s.oidc.AllowInsecureTransport = true }
}
func (s *IAMApiServer) ServeMultiPort(ports []string) error {
+1 -1
View File
@@ -1264,7 +1264,7 @@ func (s *InternalStore) CreateOIDCProvider(_ context.Context, provider types.OID
}
if _, ok := conf.OIDCProviders[provider.Url]; ok {
return nil, iamerr.EntityAlreadyExistsOIDCProvider("https://" + provider.Url)
return nil, iamerr.EntityAlreadyExistsOIDCProvider(iamutil.OIDCEndpointURL(provider.Url))
}
if len(conf.OIDCProviders) >= MaxOIDCProvidersPerAccount {
return nil, iamerr.OIDCProvidersPerAccountLimitExceeded(MaxOIDCProvidersPerAccount)
+1 -1
View File
@@ -1528,7 +1528,7 @@ func oidcProviderPathSegment(url string) string {
func (s *VaultStore) CreateOIDCProvider(_ context.Context, provider types.OIDCProvider) (*types.OIDCProvider, error) {
segment := oidcProviderPathSegment(provider.Url)
path := s.oidcProvidersPath() + "/" + segment
displayURL := "https://" + provider.Url
displayURL := iamutil.OIDCEndpointURL(provider.Url)
resp, err := s.client.Secrets.KvV2List(context.Background(), s.oidcProvidersPath(), s.kvReqOpts...)
if err != nil && !vault.IsErrorStatus(err, http.StatusNotFound) {
+6 -5
View File
@@ -24,11 +24,12 @@ package integration
// that mints a session in this codebase, and a real successful call requires
// the server to fetch a real JWKS from the token's issuer and verify a real
// cryptographic signature. The SSRF guard in iamutil's OIDC fetch path
// (isDisallowedFetchTarget) unconditionally rejects loopback, private
// (RFC1918), and link-local addresses as fetch targets — so no JWKS server
// this test process stands up on the same machine can ever be reachable,
// and a real successful AssumeRoleWithWebIdentity is unreachable from this
// suite by design. Every test below that needs to observe a trust-policy
// (isDisallowedFetchTarget) rejects loopback, private (RFC1918), and
// link-local addresses as fetch targets unless the gateway under test was
// started with --oidc-allow-private-endpoints, which this suite's gateway
// never is — so no JWKS server this test process stands up on the same
// machine can ever be reachable, and a real successful
// AssumeRoleWithWebIdentity is unreachable from this suite by design. Every test below that needs to observe a trust-policy
// "Allowed" decision instead uses the same technique the rest of this
// package's AssumeRoleWithWebIdentity tests already use (see
// IAMAssumeRoleWithWebIdentity_oaud_condition_matches in
@@ -374,8 +374,9 @@ func IAMAssumeRoleWithWebIdentity_empty_client_id_list(s *S3Conf) error {
// IAMAssumeRoleWithWebIdentity_idp_communication_error confirms the
// network-dependent signature-verification step is wired all the way
// through the real HTTP action handler: a provider Url that's a loopback IP
// literal is rejected by VerifyWebIdentitySignature's mandatory SSRF guard
// before any real network attempt, deterministically and without requiring
// literal is rejected by VerifyWebIdentitySignature's SSRF guard (on by
// default, and never waived for this suite's gateway) before any real
// network attempt, deterministically and without requiring
// outbound network access from the test environment — the same technique
// IAMCreateOpenIDConnectProvider_thumbprint_autofetch_communication_error
// uses for CreateOpenIDConnectProvider's own auto-fetch path.
@@ -194,8 +194,9 @@ func IAMCreateOpenIDConnectProvider_already_exists(s *S3Conf) error {
// IAMCreateOpenIDConnectProvider_thumbprint_autofetch_communication_error
// confirms the network-dependent auto-fetch fallback (triggered by
// omitting ThumbprintList) is wired all the way through the real HTTP
// action handler: a loopback URL is rejected by the fetch's mandatory
// SSRF guard before any real network attempt, deterministically and
// action handler: a loopback URL is rejected by the fetch's SSRF guard (on
// by default, and never waived for this suite's gateway) before any real
// network attempt, deterministically and
// without requiring outbound network access from the test environment.
func IAMCreateOpenIDConnectProvider_thumbprint_autofetch_communication_error(s *S3Conf) error {
testName := "IAMCreateOpenIDConnectProvider_thumbprint_autofetch_communication_error"
+7 -3
View File
@@ -195,7 +195,7 @@ under the License.
<div>
<label class="block text-sm font-medium text-charcoal mb-2">Provider URL <span class="text-red-500">*</span></label>
<input type="text" id="create-provider-url" maxlength="255" placeholder="https://token.example.com" class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-lg text-charcoal font-mono text-sm placeholder:font-sans placeholder:text-charcoal-300 focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all">
<p class="mt-2 text-xs text-charcoal-300">Must start with https://. No port, user info, query string or fragment. The URL cannot be changed after creation.</p>
<p class="mt-2 text-xs text-charcoal-300">Must start with https://. No user info, query string or fragment. A port, or an http:// URL, is accepted only if the service was started with --oidc-allow-private-endpoints / --oidc-allow-insecure-transport. The URL cannot be changed after creation.</p>
</div>
<div>
<div class="flex items-center justify-between mb-2">
@@ -454,9 +454,14 @@ under the License.
openModal('create-provider-modal');
}
// Whether http:// and an explicit port are accepted depends on the IAM
// service's own --oidc-allow-private-endpoints/--oidc-allow-insecure-transport
// settings, which this page has no way to read. Those two rules are left
// to the server, whose rejection surfaces as a toast like any other API
// error; everything checked here holds regardless of configuration.
function validateProviderUrl(url) {
if (!url) return 'Provider URL is required.';
if (!url.startsWith('https://')) return 'Provider URL must start with https://.';
if (!url.startsWith('https://') && !url.startsWith('http://')) return 'Provider URL must start with https://.';
if (url.length > IAM_LIMITS.oidcUrlChars) return `Provider URL must be ${IAM_LIMITS.oidcUrlChars} characters or fewer.`;
let parsed;
try {
@@ -464,7 +469,6 @@ under the License.
} catch (e) {
return 'Provider URL is not a valid URL.';
}
if (parsed.port) return 'Provider URL must not include a port.';
if (parsed.username || parsed.password) return 'Provider URL must not include user info.';
if (parsed.search) return 'Provider URL must not include a query string.';
if (parsed.hash) return 'Provider URL must not include a fragment.';