mirror of
https://github.com/versity/versitygw.git
synced 2026-09-23 16:34:18 +00:00
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.
312 lines
13 KiB
Go
312 lines
13 KiB
Go
// 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 (
|
|
"fmt"
|
|
"net"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/gofiber/fiber/v3"
|
|
"github.com/versity/versitygw/debuglogger"
|
|
"github.com/versity/versitygw/iamapi/iamerr"
|
|
)
|
|
|
|
const (
|
|
MinOIDCProviderArnLen = 20
|
|
MaxOIDCProviderArnLen = 2048
|
|
MaxOIDCProviderURLLen = 255
|
|
MaxOIDCClientIDLen = 255
|
|
MaxThumbprintsPerOIDCProvider = 5
|
|
OIDCThumbprintLen = 40
|
|
|
|
oidcProviderResourceType = "oidc-provider"
|
|
)
|
|
|
|
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
|
|
// ClientIDList/ThumbprintList) — stopping at the first missing index.
|
|
// Returns nil if no entries are present.
|
|
func ParseStringList(ctx fiber.Ctx, paramName string) []string {
|
|
var values []string
|
|
for i := 1; ; i++ {
|
|
value, ok := RequestParam(ctx, fmt.Sprintf("%s.member.%d", paramName, i))
|
|
if !ok {
|
|
break
|
|
}
|
|
values = append(values, value)
|
|
}
|
|
return values
|
|
}
|
|
|
|
// BuildOIDCProviderArn constructs the ARN for an IAM OIDC identity
|
|
// 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 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.
|
|
//
|
|
// Beyond the length and account-id checks, real AWS produces several more
|
|
// specific messages for structurally-malformed ARNs this function does not
|
|
// reproduce byte-for-byte — e.g. "Invalid service in ARN" for a non-iam
|
|
// service segment (a check this function does not perform at all), and a
|
|
// bare "Invalid ARN" (no echoed value) for a present-but-empty resource —
|
|
// this function falls back to a generic "Invalid ARN: %s" for those cases
|
|
// instead.
|
|
func ParseOIDCProviderArn(arn string) (string, error) {
|
|
if len(arn) < MinOIDCProviderArnLen {
|
|
debuglogger.Logf("invalid OpenIDConnectProviderArn length: %d", len(arn))
|
|
return "", iamerr.ValueTooShort("openIDConnectProviderArn", MinOIDCProviderArnLen)
|
|
}
|
|
if len(arn) > MaxOIDCProviderArnLen {
|
|
debuglogger.Logf("invalid OpenIDConnectProviderArn length: %d", len(arn))
|
|
return "", iamerr.ValueTooLong("openIDConnectProviderArn", MaxOIDCProviderArnLen)
|
|
}
|
|
|
|
const prefix = "arn:aws:iam::"
|
|
if !strings.HasPrefix(arn, prefix) {
|
|
debuglogger.Logf("malformed OpenIDConnectProviderArn: %q", arn)
|
|
return "", iamerr.ValidationError(fmt.Sprintf("Invalid ARN: %s", arn))
|
|
}
|
|
|
|
rest := strings.SplitN(arn[len(prefix):], ":", 2)
|
|
if len(rest) != 2 || rest[0] == "" {
|
|
debuglogger.Logf("malformed OpenIDConnectProviderArn: %q", arn)
|
|
return "", iamerr.ValidationError(fmt.Sprintf("Invalid ARN: %s", arn))
|
|
}
|
|
if rest[0] != DefaultAccountID {
|
|
debuglogger.Logf("OpenIDConnectProviderArn account id mismatch: %q", arn)
|
|
return "", iamerr.AccessDeniedOIDCProvider(DefaultAccountID, arn)
|
|
}
|
|
|
|
resourceType, resource, ok := strings.Cut(rest[1], "/")
|
|
if !ok || resource == "" {
|
|
debuglogger.Logf("malformed OpenIDConnectProviderArn: %q", arn)
|
|
return "", iamerr.ValidationError(fmt.Sprintf("Invalid ARN: %s", arn))
|
|
}
|
|
if resourceType != oidcProviderResourceType {
|
|
debuglogger.Logf("wrong resource type in ARN: %q", arn)
|
|
return "", iamerr.ValidationError("Invalid resource type in ARN")
|
|
}
|
|
|
|
return resource, nil
|
|
}
|
|
|
|
// GetOIDCProviderArn resolves the OpenIDConnectProviderArn request
|
|
// parameter, validates its shape via ParseOIDCProviderArn, and returns the
|
|
// ARN exactly as supplied by the caller (used verbatim in NoSuchEntity
|
|
// messages, which echo the full ARN, not just the url). A missing
|
|
// parameter is rejected with iamerr.MissingValue — every OIDC action
|
|
// taking this parameter reports it identically.
|
|
func GetOIDCProviderArn(ctx fiber.Ctx, operation string) (string, error) {
|
|
arn, ok := RequestParam(ctx, "OpenIDConnectProviderArn")
|
|
if !ok || arn == "" {
|
|
debuglogger.Logf("missing required %s parameter: OpenIDConnectProviderArn", operation)
|
|
return "", iamerr.MissingValue("openIDConnectProviderArn")
|
|
}
|
|
if _, err := ParseOIDCProviderArn(arn); err != nil {
|
|
return "", err
|
|
}
|
|
return arn, nil
|
|
}
|
|
|
|
// ValidateOIDCProviderURL validates the Url parameter of
|
|
// 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
|
|
// syntactically plausible RFC-1123-ish hostname or IP literal, overall
|
|
// 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.
|
|
//
|
|
// 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")
|
|
}
|
|
if len(rawURL) > MaxOIDCProviderURLLen {
|
|
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 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")
|
|
}
|
|
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 != wantScheme || parsed.Host == "" {
|
|
return "", iamerr.ValidationError("Invalid Open ID Connect Provider URL")
|
|
}
|
|
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
|
|
}
|
|
|
|
func isValidOIDCHostname(host string) bool {
|
|
if net.ParseIP(host) != nil {
|
|
return true
|
|
}
|
|
if host == "" || len(host) > 253 {
|
|
return false
|
|
}
|
|
for _, label := range strings.Split(host, ".") {
|
|
if !oidcHostLabelPattern.MatchString(label) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ValidateThumbprintList validates a parsed ThumbprintList: at most
|
|
// MaxThumbprintsPerOIDCProvider entries, each exactly OIDCThumbprintLen
|
|
// characters (no hex-charset check — any 40-char string is accepted). If
|
|
// required is true, an empty list is rejected
|
|
// (UpdateOpenIDConnectProviderThumbprint, no auto-fetch fallback exists
|
|
// there); if false, an empty list passes through untouched
|
|
// (CreateOpenIDConnectProvider, whose caller handles empty via auto-fetch
|
|
// before calling this).
|
|
func ValidateThumbprintList(thumbprints []string, required bool) error {
|
|
if required && len(thumbprints) == 0 {
|
|
return iamerr.ThumbprintListEmpty()
|
|
}
|
|
if len(thumbprints) > MaxThumbprintsPerOIDCProvider {
|
|
return iamerr.ThumbprintListTooLong(MaxThumbprintsPerOIDCProvider)
|
|
}
|
|
for _, tp := range thumbprints {
|
|
if len(tp) != OIDCThumbprintLen {
|
|
return iamerr.InvalidInput(fmt.Sprintf("Thumbprint must be exactly %d characters.", OIDCThumbprintLen))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// NormalizeThumbprintList lowercases every entry: AWS stores/returns
|
|
// thumbprints lowercased regardless of submitted case.
|
|
func NormalizeThumbprintList(thumbprints []string) []string {
|
|
out := make([]string, len(thumbprints))
|
|
for i, tp := range thumbprints {
|
|
out[i] = strings.ToLower(tp)
|
|
}
|
|
return out
|
|
}
|