diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 36ba5e30b..535335c26 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -725,6 +725,12 @@ the origin root. Runtime SAML metadata refresh, runtime OIDC discovery, and admin-side SSO test/preview fetches must all use that same restricted outbound transport policy, including same-origin redirect validation and checked regular-file loads for any configured SSO credential or CA-bundle path. +That same restricted outbound transport is also the canonical cross-product +egress boundary. It is exported via `pkg/securityutil`, with +`internal/securityutil` retaining Pulse-local wrappers, so adjacent products +such as `pulse-enterprise` can reuse the same DNS-rebinding-safe dial and +redirect policy for operator-configured audit webhooks instead of reintroducing +raw `http.Client` egress paths. That same SSO boundary also owns manual SAML endpoint validation payloads. `internal/api/identity_sso_handlers.go`, `internal/api/saml_service.go`, and `internal/api/contract_test.go` must preserve both `idpSsoUrl` and optional diff --git a/internal/securityutil/httpurl.go b/internal/securityutil/httpurl.go index 22aa94f24..1f375bb10 100644 --- a/internal/securityutil/httpurl.go +++ b/internal/securityutil/httpurl.go @@ -2,318 +2,45 @@ package securityutil import ( "context" - "fmt" "io" - "net" "net/http" "net/url" - "path" - "strconv" - "strings" + + pubsec "github.com/rcourtman/pulse-go-rewrite/pkg/securityutil" ) -const requestPlaceholderURL = "http://pulse.invalid" - -func cloneURL(u *url.URL) *url.URL { - if u == nil { - return nil - } - cloned := *u - return &cloned -} - -func joinURLPath(basePath, relativePath string) string { - parts := []string{basePath} - if trimmed := strings.Trim(relativePath, "/"); trimmed != "" { - parts = append(parts, trimmed) - } - - joined := path.Join(parts...) - switch joined { - case ".", "/": - return "" - default: - if strings.HasPrefix(joined, "/") { - return joined - } - return "/" + joined - } -} - -// NormalizeAbsoluteHTTPURL validates a fully-qualified HTTP(S) URL. func NormalizeAbsoluteHTTPURL(raw string) (*url.URL, error) { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return nil, fmt.Errorf("URL is required") - } - - parsed, err := url.Parse(trimmed) - if err != nil { - return nil, fmt.Errorf("invalid URL: %w", err) - } - if parsed.Scheme != "http" && parsed.Scheme != "https" { - return nil, fmt.Errorf("URL scheme must be http or https") - } - if parsed.Host == "" { - return nil, fmt.Errorf("URL host is required") - } - if parsed.User != nil { - return nil, fmt.Errorf("URL userinfo is not allowed") - } - if parsed.Hostname() == "" { - return nil, fmt.Errorf("URL hostname is required") - } - - return parsed, nil + return pubsec.NormalizeAbsoluteHTTPURL(raw) } -// NormalizeHTTPBaseURL validates a base HTTP(S) URL and optionally adds a default scheme. func NormalizeHTTPBaseURL(raw string, defaultScheme string) (*url.URL, error) { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return nil, fmt.Errorf("base URL is required") - } - if defaultScheme != "" && !strings.Contains(trimmed, "://") { - trimmed = defaultScheme + "://" + trimmed - } - - parsed, err := NormalizeAbsoluteHTTPURL(trimmed) - if err != nil { - return nil, err - } - if parsed.RawQuery != "" || parsed.Fragment != "" { - return nil, fmt.Errorf("base URL must not include query or fragment") - } - - cleanedPath := path.Clean(parsed.Path) - switch cleanedPath { - case ".", "/": - parsed.Path = "" - default: - if !strings.HasPrefix(cleanedPath, "/") { - cleanedPath = "/" + cleanedPath - } - parsed.Path = cleanedPath - } - parsed.RawPath = "" - - return parsed, nil + return pubsec.NormalizeHTTPBaseURL(raw, defaultScheme) } -// IsLoopbackHost reports whether host resolves to localhost or a loopback IP literal. func IsLoopbackHost(host string) bool { - normalized := strings.ToLower(strings.Trim(host, "[]")) - if normalized == "" { - return false - } - if normalized == "localhost" || strings.HasSuffix(normalized, ".localhost") { - return true - } - - ip := net.ParseIP(normalized) - return ip != nil && ip.IsLoopback() + return pubsec.IsLoopbackHost(host) } -// NormalizePulseHTTPBaseURL validates a Pulse control-plane base URL. -// HTTPS is required for non-loopback hosts; loopback localhost may use HTTP. func NormalizePulseHTTPBaseURL(raw string) (*url.URL, error) { - parsed, err := normalizePulseBaseURL(raw, false) - if err != nil { - return nil, err - } - return parsed, nil + return pubsec.NormalizePulseHTTPBaseURL(raw) } -// NormalizePulseWebSocketBaseURL validates a Pulse command-channel base URL. -// Non-loopback hosts are normalized to WSS; loopback localhost may use WS. func NormalizePulseWebSocketBaseURL(raw string) (*url.URL, error) { - parsed, err := normalizePulseBaseURL(raw, true) - if err != nil { - return nil, err - } - return parsed, nil + return pubsec.NormalizePulseWebSocketBaseURL(raw) } -func normalizePulseBaseURL(raw string, websocket bool) (*url.URL, error) { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return nil, fmt.Errorf("Pulse URL is required") - } - - parsed, err := url.Parse(trimmed) - if err != nil { - return nil, fmt.Errorf("Pulse URL %q is invalid: %w", raw, err) - } - if parsed.Scheme == "" { - if websocket { - return nil, fmt.Errorf("Pulse URL %q must include scheme (https://, wss://, or loopback http:// / ws://)", raw) - } - return nil, fmt.Errorf("Pulse URL %q must include scheme (https:// or loopback http://)", raw) - } - if parsed.Host == "" { - return nil, fmt.Errorf("Pulse URL %q must include host", raw) - } - if parsed.Hostname() == "" { - return nil, fmt.Errorf("Pulse URL %q must include host", raw) - } - if parsed.User != nil { - return nil, fmt.Errorf("Pulse URL %q must not include user credentials", raw) - } - if parsed.RawQuery != "" || parsed.Fragment != "" { - return nil, fmt.Errorf("Pulse URL %q must not include query or fragment", raw) - } - - if port := parsed.Port(); port != "" { - portNum, err := strconv.Atoi(port) - if err != nil || portNum < 1 || portNum > 65535 { - return nil, fmt.Errorf("invalid port %q: must be between 1 and 65535", port) - } - } - - scheme := strings.ToLower(parsed.Scheme) - switch scheme { - case "https": - if websocket { - parsed.Scheme = "wss" - } else { - parsed.Scheme = "https" - } - case "http": - if !IsLoopbackHost(parsed.Hostname()) { - if websocket { - return nil, fmt.Errorf("Pulse URL %q must use https/wss unless host is loopback", raw) - } - return nil, fmt.Errorf("Pulse URL %q must use https unless host is loopback", raw) - } - if websocket { - parsed.Scheme = "ws" - } else { - parsed.Scheme = "http" - } - case "wss": - if !websocket { - return nil, fmt.Errorf("Pulse URL %q has unsupported scheme %q", raw, parsed.Scheme) - } - parsed.Scheme = "wss" - case "ws": - if !websocket { - return nil, fmt.Errorf("Pulse URL %q has unsupported scheme %q", raw, parsed.Scheme) - } - if !IsLoopbackHost(parsed.Hostname()) { - return nil, fmt.Errorf("Pulse URL %q must use https/wss unless host is loopback", raw) - } - parsed.Scheme = "ws" - default: - return nil, fmt.Errorf("Pulse URL %q has unsupported scheme %q", raw, parsed.Scheme) - } - - parsed.Host = strings.ToLower(parsed.Host) - parsed.Path = strings.TrimRight(parsed.Path, "/") - parsed.RawPath = strings.TrimRight(parsed.RawPath, "/") - parsed.RawQuery = "" - parsed.Fragment = "" - - return parsed, nil -} - -// AppendURLPath appends path segments onto a validated base URL. func AppendURLPath(base *url.URL, segments ...string) *url.URL { - cloned := cloneURL(base) - if cloned == nil { - return nil - } - - parts := []string{cloned.Path} - for _, segment := range segments { - trimmed := strings.Trim(segment, "/") - if trimmed == "" { - continue - } - parts = append(parts, trimmed) - } - - joined := path.Join(parts...) - if joined == "." || joined == "/" { - cloned.Path = "" - } else if strings.HasPrefix(joined, "/") { - cloned.Path = joined - } else { - cloned.Path = "/" + joined - } - cloned.RawPath = "" - cloned.Fragment = "" - - return cloned + return pubsec.AppendURLPath(base, segments...) } -// ResolveRelativeURL validates a rooted relative path and resolves it against base. func ResolveRelativeURL(base *url.URL, relativePath string) (*url.URL, error) { - if base == nil { - return nil, fmt.Errorf("base URL is required") - } - - trimmed := strings.TrimSpace(relativePath) - if trimmed == "" { - return nil, fmt.Errorf("relative path is required") - } - if strings.Contains(trimmed, `\`) { - return nil, fmt.Errorf("relative path must not contain backslashes") - } - - ref, err := url.Parse(trimmed) - if err != nil { - return nil, fmt.Errorf("invalid relative path: %w", err) - } - if ref.IsAbs() || ref.Host != "" || ref.User != nil { - return nil, fmt.Errorf("relative path must not include scheme or host") - } - if !strings.HasPrefix(ref.Path, "/") { - return nil, fmt.Errorf("relative path must start with '/'") - } - - cleanedPath := path.Clean(ref.Path) - if !strings.HasPrefix(cleanedPath, "/") { - cleanedPath = "/" + cleanedPath - } - target := cloneURL(base) - if target == nil { - return nil, fmt.Errorf("base URL is required") - } - target.Path = joinURLPath(base.Path, cleanedPath) - escapedPath := path.Clean(ref.EscapedPath()) - if !strings.HasPrefix(escapedPath, "/") { - escapedPath = "/" + escapedPath - } - target.RawPath = joinURLPath(base.EscapedPath(), escapedPath) - if target.RawPath == target.Path { - target.RawPath = "" - } - target.RawQuery = ref.RawQuery - target.Fragment = "" - return target, nil + return pubsec.ResolveRelativeURL(base, relativePath) } -// NewValidatedRequestWithContext builds an HTTP request from a pre-validated URL. func NewValidatedRequestWithContext(ctx context.Context, method string, target *url.URL, body io.Reader) (*http.Request, error) { - if target == nil { - return nil, fmt.Errorf("target URL is required") - } - - req, err := http.NewRequestWithContext(ctx, method, requestPlaceholderURL, body) - if err != nil { - return nil, err - } - req.URL = cloneURL(target) - req.Host = req.URL.Host - req.RequestURI = "" - return req, nil + return pubsec.NewValidatedRequestWithContext(ctx, method, target, body) } -// NewRelativeRequestWithContext validates a rooted relative path and builds a request from it. func NewRelativeRequestWithContext(ctx context.Context, method string, base *url.URL, relativePath string, body io.Reader) (*http.Request, error) { - target, err := ResolveRelativeURL(base, relativePath) - if err != nil { - return nil, err - } - return NewValidatedRequestWithContext(ctx, method, target, body) + return pubsec.NewRelativeRequestWithContext(ctx, method, base, relativePath, body) } diff --git a/internal/securityutil/outbound_http.go b/internal/securityutil/outbound_http.go index 90057a398..bcbedb3c0 100644 --- a/internal/securityutil/outbound_http.go +++ b/internal/securityutil/outbound_http.go @@ -2,239 +2,22 @@ package securityutil import ( "context" - "crypto/tls" - "fmt" "net" "net/http" "net/url" - "strings" "time" + + pubsec "github.com/rcourtman/pulse-go-rewrite/pkg/securityutil" ) -const defaultRestrictedRedirectLimit = 10 +type RestrictedOutboundHTTPOptions = pubsec.RestrictedOutboundHTTPOptions -// RestrictedOutboundHTTPOptions controls outbound HTTP validation and transport policy. -type RestrictedOutboundHTTPOptions struct { - AllowedSchemes []string - AllowPrivateIPs bool - AllowLoopback bool - TLSConfig *tls.Config - ResolveIPAddrs func(ctx context.Context, host string) ([]net.IPAddr, error) +func ValidateOutboundFetchURL(ctx context.Context, raw string, opts RestrictedOutboundHTTPOptions) (*url.URL, error) { + return pubsec.ValidateOutboundFetchURL(ctx, raw, opts) +} + +func NewRestrictedOutboundHTTPClient(timeout time.Duration, opts RestrictedOutboundHTTPOptions) *http.Client { + return pubsec.NewRestrictedOutboundHTTPClient(timeout, opts) } var resolveOutboundFetchIPs = net.DefaultResolver.LookupIPAddr - -func allowedOutboundSchemes(opts RestrictedOutboundHTTPOptions) []string { - if len(opts.AllowedSchemes) == 0 { - return []string{"http", "https"} - } - return opts.AllowedSchemes -} - -func isAllowedOutboundScheme(scheme string, allowed []string) bool { - for _, candidate := range allowed { - if strings.EqualFold(strings.TrimSpace(candidate), scheme) { - return true - } - } - return false -} - -func validateOutboundIP(ip net.IP, opts RestrictedOutboundHTTPOptions) error { - if ip == nil { - return fmt.Errorf("invalid IP address") - } - if ip.IsLoopback() && !opts.AllowLoopback { - return fmt.Errorf("loopback addresses are not allowed") - } - if ip.Equal(net.ParseIP("169.254.169.254")) { - return fmt.Errorf("metadata service address is not allowed") - } - if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { - return fmt.Errorf("link-local addresses are not allowed") - } - if ip.IsMulticast() { - return fmt.Errorf("multicast addresses are not allowed") - } - if ip.IsUnspecified() { - return fmt.Errorf("unspecified addresses are not allowed") - } - if !opts.AllowPrivateIPs && ip.IsPrivate() { - return fmt.Errorf("private addresses are not allowed") - } - return nil -} - -func resolveOutboundIPAddrs(ctx context.Context, host string, opts RestrictedOutboundHTTPOptions) ([]net.IPAddr, error) { - if resolver := opts.ResolveIPAddrs; resolver != nil { - return resolver(ctx, host) - } - return resolveOutboundFetchIPs(ctx, host) -} - -func resolvePermittedOutboundIP(ctx context.Context, host string, opts RestrictedOutboundHTTPOptions) (net.IP, error) { - host = strings.TrimSpace(host) - if host == "" { - return nil, fmt.Errorf("URL hostname is required") - } - - switch strings.ToLower(host) { - case "metadata.google.internal", "metadata.goog": - return nil, fmt.Errorf("metadata service host is not allowed") - } - - if ip := net.ParseIP(host); ip != nil { - if err := validateOutboundIP(ip, opts); err != nil { - return nil, err - } - return ip, nil - } - - baseCtx := ctx - if baseCtx == nil { - baseCtx = context.Background() - } - resolveCtx, cancel := context.WithTimeout(baseCtx, 5*time.Second) - defer cancel() - - addrs, err := resolveOutboundIPAddrs(resolveCtx, host, opts) - if err != nil { - return nil, fmt.Errorf("failed to resolve hostname %s: %w", host, err) - } - if len(addrs) == 0 { - return nil, fmt.Errorf("hostname %s did not resolve", host) - } - - var blockedErr error - for _, addr := range addrs { - if err := validateOutboundIP(addr.IP, opts); err != nil { - blockedErr = err - continue - } - return addr.IP, nil - } - - if blockedErr != nil { - return nil, fmt.Errorf("hostname %s resolves only to blocked addresses: %w", host, blockedErr) - } - return nil, fmt.Errorf("hostname %s did not resolve", host) -} - -// ValidateOutboundFetchURL validates a fully-qualified HTTP(S) URL against the restricted outbound policy. -func ValidateOutboundFetchURL(ctx context.Context, raw string, opts RestrictedOutboundHTTPOptions) (*url.URL, error) { - parsed, err := NormalizeAbsoluteHTTPURL(raw) - if err != nil { - return nil, err - } - - allowedSchemes := allowedOutboundSchemes(opts) - if !isAllowedOutboundScheme(parsed.Scheme, allowedSchemes) { - return nil, fmt.Errorf("URL scheme must be one of: %s", strings.Join(allowedSchemes, ", ")) - } - if parsed.Fragment != "" { - return nil, fmt.Errorf("URL fragments are not allowed") - } - - if _, err := resolvePermittedOutboundIP(ctx, parsed.Hostname(), opts); err != nil { - return nil, err - } - - return parsed, nil -} - -func canonicalOriginHost(u *url.URL) string { - if u == nil { - return "" - } - - host := strings.ToLower(u.Hostname()) - port := u.Port() - if port == "" { - switch strings.ToLower(u.Scheme) { - case "http": - port = "80" - case "https": - port = "443" - } - } - if host == "" || port == "" { - return strings.ToLower(u.Host) - } - return net.JoinHostPort(host, port) -} - -func sameOriginRedirectPolicy(opts RestrictedOutboundHTTPOptions) func(req *http.Request, via []*http.Request) error { - return func(req *http.Request, via []*http.Request) error { - if len(via) == 0 { - return nil - } - if len(via) >= defaultRestrictedRedirectLimit { - return fmt.Errorf("stopped after %d redirects", defaultRestrictedRedirectLimit) - } - - validated, err := ValidateOutboundFetchURL(req.Context(), req.URL.String(), opts) - if err != nil { - return err - } - - origin := via[0].URL - if !strings.EqualFold(validated.Scheme, origin.Scheme) || canonicalOriginHost(validated) != canonicalOriginHost(origin) { - return fmt.Errorf("redirects must stay on the same origin") - } - return nil - } -} - -func cloneRestrictedTransport(tlsConfig *tls.Config) *http.Transport { - transport, ok := http.DefaultTransport.(*http.Transport) - var clone *http.Transport - if ok && transport != nil { - clone = transport.Clone() - } else { - clone = &http.Transport{Proxy: http.ProxyFromEnvironment} - } - - switch { - case tlsConfig != nil: - clone.TLSClientConfig = tlsConfig.Clone() - case clone.TLSClientConfig != nil: - clone.TLSClientConfig = clone.TLSClientConfig.Clone() - default: - clone.TLSClientConfig = &tls.Config{} - } - - if clone.TLSClientConfig.MinVersion < tls.VersionTLS12 { - clone.TLSClientConfig.MinVersion = tls.VersionTLS12 - } - - return clone -} - -// NewRestrictedOutboundHTTPClient returns an HTTP client that validates redirects and pins direct outbound dials -// to the first permitted resolved IP for the requested host. -func NewRestrictedOutboundHTTPClient(timeout time.Duration, opts RestrictedOutboundHTTPOptions) *http.Client { - transport := cloneRestrictedTransport(opts.TLSConfig) - transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { - host, port, err := net.SplitHostPort(addr) - if err != nil { - return nil, fmt.Errorf("parse outbound address %q: %w", addr, err) - } - - permittedIP, err := resolvePermittedOutboundIP(ctx, host, opts) - if err != nil { - return nil, err - } - - dialer := net.Dialer{Timeout: 10 * time.Second} - return dialer.DialContext(ctx, network, net.JoinHostPort(permittedIP.String(), port)) - } - - client := &http.Client{ - Transport: transport, - CheckRedirect: sameOriginRedirectPolicy(opts), - } - if timeout > 0 { - client.Timeout = timeout - } - return client -} diff --git a/internal/securityutil/websocket_origin.go b/internal/securityutil/websocket_origin.go index b5d9cebcd..656d7a2c3 100644 --- a/internal/securityutil/websocket_origin.go +++ b/internal/securityutil/websocket_origin.go @@ -1,62 +1,15 @@ package securityutil -import ( - "fmt" - "net" - "net/url" - "strings" -) +import pubsec "github.com/rcourtman/pulse-go-rewrite/pkg/securityutil" -// NormalizeWebSocketOriginHost normalizes Origin/Host values for same-origin comparison. func NormalizeWebSocketOriginHost(host string) string { - normalized := strings.TrimSpace(strings.ToLower(host)) - if normalized == "" { - return normalized - } - - parsedHost, parsedPort, err := net.SplitHostPort(normalized) - if err != nil { - return normalized - } - if parsedPort == "80" || parsedPort == "443" { - return parsedHost - } - return net.JoinHostPort(parsedHost, parsedPort) + return pubsec.NormalizeWebSocketOriginHost(host) } -// SameHostWebSocketOrigin validates that an Origin header is http(s) and matches the request host. func SameHostWebSocketOrigin(origin string, requestHost string) bool { - parsed, err := url.Parse(strings.TrimSpace(origin)) - if err != nil || parsed.Host == "" { - return false - } - if parsed.Scheme != "http" && parsed.Scheme != "https" { - return false - } - - return NormalizeWebSocketOriginHost(parsed.Host) == NormalizeWebSocketOriginHost(requestHost) + return pubsec.SameHostWebSocketOrigin(origin, requestHost) } -// HTTPOriginForWebSocketBaseURL returns the http(s) Origin header for a Pulse websocket base URL. func HTTPOriginForWebSocketBaseURL(raw string) (string, error) { - parsed, err := NormalizePulseWebSocketBaseURL(raw) - if err != nil { - return "", err - } - - switch parsed.Scheme { - case "ws": - parsed.Scheme = "http" - case "wss": - parsed.Scheme = "https" - default: - return "", fmt.Errorf("unsupported websocket origin scheme %q", parsed.Scheme) - } - - parsed.Path = "" - parsed.RawPath = "" - parsed.RawQuery = "" - parsed.Fragment = "" - - return parsed.String(), nil + return pubsec.HTTPOriginForWebSocketBaseURL(raw) } diff --git a/pkg/audit/webhook.go b/pkg/audit/webhook.go index 0f5181ae4..b472d7ab1 100644 --- a/pkg/audit/webhook.go +++ b/pkg/audit/webhook.go @@ -12,8 +12,9 @@ import ( "sync" "time" - "github.com/rcourtman/pulse-go-rewrite/internal/securityutil" "github.com/rs/zerolog/log" + + "github.com/rcourtman/pulse-go-rewrite/pkg/securityutil" ) const ( diff --git a/pkg/securityutil/httpurl.go b/pkg/securityutil/httpurl.go new file mode 100644 index 000000000..7e34ea7ac --- /dev/null +++ b/pkg/securityutil/httpurl.go @@ -0,0 +1,307 @@ +package securityutil + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "net/url" + "path" + "strconv" + "strings" +) + +const requestPlaceholderURL = "http://pulse.invalid" + +func cloneURL(u *url.URL) *url.URL { + if u == nil { + return nil + } + cloned := *u + return &cloned +} + +func joinURLPath(basePath, relativePath string) string { + parts := []string{basePath} + if trimmed := strings.Trim(relativePath, "/"); trimmed != "" { + parts = append(parts, trimmed) + } + + joined := path.Join(parts...) + switch joined { + case ".", "/": + return "" + default: + if strings.HasPrefix(joined, "/") { + return joined + } + return "/" + joined + } +} + +// NormalizeAbsoluteHTTPURL validates a fully-qualified HTTP(S) URL. +func NormalizeAbsoluteHTTPURL(raw string) (*url.URL, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil, fmt.Errorf("URL is required") + } + + parsed, err := url.Parse(trimmed) + if err != nil { + return nil, fmt.Errorf("invalid URL: %w", err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return nil, fmt.Errorf("URL scheme must be http or https") + } + if parsed.Host == "" { + return nil, fmt.Errorf("URL host is required") + } + if parsed.User != nil { + return nil, fmt.Errorf("URL userinfo is not allowed") + } + if parsed.Hostname() == "" { + return nil, fmt.Errorf("URL hostname is required") + } + + return parsed, nil +} + +// NormalizeHTTPBaseURL validates a base HTTP(S) URL and optionally adds a default scheme. +func NormalizeHTTPBaseURL(raw string, defaultScheme string) (*url.URL, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil, fmt.Errorf("base URL is required") + } + if defaultScheme != "" && !strings.Contains(trimmed, "://") { + trimmed = defaultScheme + "://" + trimmed + } + + parsed, err := NormalizeAbsoluteHTTPURL(trimmed) + if err != nil { + return nil, err + } + if parsed.RawQuery != "" || parsed.Fragment != "" { + return nil, fmt.Errorf("base URL must not include query or fragment") + } + + cleanedPath := path.Clean(parsed.Path) + switch cleanedPath { + case ".", "/": + parsed.Path = "" + default: + if !strings.HasPrefix(cleanedPath, "/") { + cleanedPath = "/" + cleanedPath + } + parsed.Path = cleanedPath + } + parsed.RawPath = "" + + return parsed, nil +} + +// IsLoopbackHost reports whether host resolves to localhost or a loopback IP literal. +func IsLoopbackHost(host string) bool { + normalized := strings.ToLower(strings.Trim(host, "[]")) + if normalized == "" { + return false + } + if normalized == "localhost" || strings.HasSuffix(normalized, ".localhost") { + return true + } + + ip := net.ParseIP(normalized) + return ip != nil && ip.IsLoopback() +} + +// NormalizePulseHTTPBaseURL validates a Pulse control-plane base URL. +// HTTPS is required for non-loopback hosts; loopback localhost may use HTTP. +func NormalizePulseHTTPBaseURL(raw string) (*url.URL, error) { + return normalizePulseBaseURL(raw, false) +} + +// NormalizePulseWebSocketBaseURL validates a Pulse command-channel base URL. +// Non-loopback hosts are normalized to WSS; loopback localhost may use WS. +func NormalizePulseWebSocketBaseURL(raw string) (*url.URL, error) { + return normalizePulseBaseURL(raw, true) +} + +func normalizePulseBaseURL(raw string, websocket bool) (*url.URL, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil, fmt.Errorf("Pulse URL is required") + } + + parsed, err := url.Parse(trimmed) + if err != nil { + return nil, fmt.Errorf("Pulse URL %q is invalid: %w", raw, err) + } + if parsed.Scheme == "" { + if websocket { + return nil, fmt.Errorf("Pulse URL %q must include scheme (https://, wss://, or loopback http:// / ws://)", raw) + } + return nil, fmt.Errorf("Pulse URL %q must include scheme (https:// or loopback http://)", raw) + } + if parsed.Host == "" || parsed.Hostname() == "" { + return nil, fmt.Errorf("Pulse URL %q must include host", raw) + } + if parsed.User != nil { + return nil, fmt.Errorf("Pulse URL %q must not include user credentials", raw) + } + if parsed.RawQuery != "" || parsed.Fragment != "" { + return nil, fmt.Errorf("Pulse URL %q must not include query or fragment", raw) + } + + if port := parsed.Port(); port != "" { + portNum, err := strconv.Atoi(port) + if err != nil || portNum < 1 || portNum > 65535 { + return nil, fmt.Errorf("invalid port %q: must be between 1 and 65535", port) + } + } + + switch scheme := strings.ToLower(parsed.Scheme); scheme { + case "https": + if websocket { + parsed.Scheme = "wss" + } else { + parsed.Scheme = "https" + } + case "http": + if !IsLoopbackHost(parsed.Hostname()) { + if websocket { + return nil, fmt.Errorf("Pulse URL %q must use https/wss unless host is loopback", raw) + } + return nil, fmt.Errorf("Pulse URL %q must use https unless host is loopback", raw) + } + if websocket { + parsed.Scheme = "ws" + } else { + parsed.Scheme = "http" + } + case "wss": + if !websocket { + return nil, fmt.Errorf("Pulse URL %q has unsupported scheme %q", raw, parsed.Scheme) + } + parsed.Scheme = "wss" + case "ws": + if !websocket { + return nil, fmt.Errorf("Pulse URL %q has unsupported scheme %q", raw, parsed.Scheme) + } + if !IsLoopbackHost(parsed.Hostname()) { + return nil, fmt.Errorf("Pulse URL %q must use https/wss unless host is loopback", raw) + } + parsed.Scheme = "ws" + default: + return nil, fmt.Errorf("Pulse URL %q has unsupported scheme %q", raw, parsed.Scheme) + } + + parsed.Host = strings.ToLower(parsed.Host) + parsed.Path = strings.TrimRight(parsed.Path, "/") + parsed.RawPath = strings.TrimRight(parsed.RawPath, "/") + parsed.RawQuery = "" + parsed.Fragment = "" + + return parsed, nil +} + +// AppendURLPath appends path segments onto a validated base URL. +func AppendURLPath(base *url.URL, segments ...string) *url.URL { + cloned := cloneURL(base) + if cloned == nil { + return nil + } + + parts := []string{cloned.Path} + for _, segment := range segments { + trimmed := strings.Trim(segment, "/") + if trimmed == "" { + continue + } + parts = append(parts, trimmed) + } + + joined := path.Join(parts...) + if joined == "." || joined == "/" { + cloned.Path = "" + } else if strings.HasPrefix(joined, "/") { + cloned.Path = joined + } else { + cloned.Path = "/" + joined + } + cloned.RawPath = "" + cloned.Fragment = "" + + return cloned +} + +// ResolveRelativeURL validates a rooted relative path and resolves it against base. +func ResolveRelativeURL(base *url.URL, relativePath string) (*url.URL, error) { + if base == nil { + return nil, fmt.Errorf("base URL is required") + } + + trimmed := strings.TrimSpace(relativePath) + if trimmed == "" { + return nil, fmt.Errorf("relative path is required") + } + if strings.Contains(trimmed, `\`) { + return nil, fmt.Errorf("relative path must not contain backslashes") + } + + ref, err := url.Parse(trimmed) + if err != nil { + return nil, fmt.Errorf("invalid relative path: %w", err) + } + if ref.IsAbs() || ref.Host != "" || ref.User != nil { + return nil, fmt.Errorf("relative path must not include scheme or host") + } + if !strings.HasPrefix(ref.Path, "/") { + return nil, fmt.Errorf("relative path must start with '/'") + } + + cleanedPath := path.Clean(ref.Path) + if !strings.HasPrefix(cleanedPath, "/") { + cleanedPath = "/" + cleanedPath + } + target := cloneURL(base) + if target == nil { + return nil, fmt.Errorf("base URL is required") + } + target.Path = joinURLPath(base.Path, cleanedPath) + escapedPath := path.Clean(ref.EscapedPath()) + if !strings.HasPrefix(escapedPath, "/") { + escapedPath = "/" + escapedPath + } + target.RawPath = joinURLPath(base.EscapedPath(), escapedPath) + if target.RawPath == target.Path { + target.RawPath = "" + } + target.RawQuery = ref.RawQuery + target.Fragment = "" + return target, nil +} + +// NewValidatedRequestWithContext builds an HTTP request from a pre-validated URL. +func NewValidatedRequestWithContext(ctx context.Context, method string, target *url.URL, body io.Reader) (*http.Request, error) { + if target == nil { + return nil, fmt.Errorf("target URL is required") + } + + req, err := http.NewRequestWithContext(ctx, method, requestPlaceholderURL, body) + if err != nil { + return nil, err + } + req.URL = cloneURL(target) + req.Host = req.URL.Host + req.RequestURI = "" + return req, nil +} + +// NewRelativeRequestWithContext validates a rooted relative path and builds a request from it. +func NewRelativeRequestWithContext(ctx context.Context, method string, base *url.URL, relativePath string, body io.Reader) (*http.Request, error) { + target, err := ResolveRelativeURL(base, relativePath) + if err != nil { + return nil, err + } + return NewValidatedRequestWithContext(ctx, method, target, body) +} diff --git a/pkg/securityutil/outbound_http.go b/pkg/securityutil/outbound_http.go new file mode 100644 index 000000000..90057a398 --- /dev/null +++ b/pkg/securityutil/outbound_http.go @@ -0,0 +1,240 @@ +package securityutil + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "net/http" + "net/url" + "strings" + "time" +) + +const defaultRestrictedRedirectLimit = 10 + +// RestrictedOutboundHTTPOptions controls outbound HTTP validation and transport policy. +type RestrictedOutboundHTTPOptions struct { + AllowedSchemes []string + AllowPrivateIPs bool + AllowLoopback bool + TLSConfig *tls.Config + ResolveIPAddrs func(ctx context.Context, host string) ([]net.IPAddr, error) +} + +var resolveOutboundFetchIPs = net.DefaultResolver.LookupIPAddr + +func allowedOutboundSchemes(opts RestrictedOutboundHTTPOptions) []string { + if len(opts.AllowedSchemes) == 0 { + return []string{"http", "https"} + } + return opts.AllowedSchemes +} + +func isAllowedOutboundScheme(scheme string, allowed []string) bool { + for _, candidate := range allowed { + if strings.EqualFold(strings.TrimSpace(candidate), scheme) { + return true + } + } + return false +} + +func validateOutboundIP(ip net.IP, opts RestrictedOutboundHTTPOptions) error { + if ip == nil { + return fmt.Errorf("invalid IP address") + } + if ip.IsLoopback() && !opts.AllowLoopback { + return fmt.Errorf("loopback addresses are not allowed") + } + if ip.Equal(net.ParseIP("169.254.169.254")) { + return fmt.Errorf("metadata service address is not allowed") + } + if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + return fmt.Errorf("link-local addresses are not allowed") + } + if ip.IsMulticast() { + return fmt.Errorf("multicast addresses are not allowed") + } + if ip.IsUnspecified() { + return fmt.Errorf("unspecified addresses are not allowed") + } + if !opts.AllowPrivateIPs && ip.IsPrivate() { + return fmt.Errorf("private addresses are not allowed") + } + return nil +} + +func resolveOutboundIPAddrs(ctx context.Context, host string, opts RestrictedOutboundHTTPOptions) ([]net.IPAddr, error) { + if resolver := opts.ResolveIPAddrs; resolver != nil { + return resolver(ctx, host) + } + return resolveOutboundFetchIPs(ctx, host) +} + +func resolvePermittedOutboundIP(ctx context.Context, host string, opts RestrictedOutboundHTTPOptions) (net.IP, error) { + host = strings.TrimSpace(host) + if host == "" { + return nil, fmt.Errorf("URL hostname is required") + } + + switch strings.ToLower(host) { + case "metadata.google.internal", "metadata.goog": + return nil, fmt.Errorf("metadata service host is not allowed") + } + + if ip := net.ParseIP(host); ip != nil { + if err := validateOutboundIP(ip, opts); err != nil { + return nil, err + } + return ip, nil + } + + baseCtx := ctx + if baseCtx == nil { + baseCtx = context.Background() + } + resolveCtx, cancel := context.WithTimeout(baseCtx, 5*time.Second) + defer cancel() + + addrs, err := resolveOutboundIPAddrs(resolveCtx, host, opts) + if err != nil { + return nil, fmt.Errorf("failed to resolve hostname %s: %w", host, err) + } + if len(addrs) == 0 { + return nil, fmt.Errorf("hostname %s did not resolve", host) + } + + var blockedErr error + for _, addr := range addrs { + if err := validateOutboundIP(addr.IP, opts); err != nil { + blockedErr = err + continue + } + return addr.IP, nil + } + + if blockedErr != nil { + return nil, fmt.Errorf("hostname %s resolves only to blocked addresses: %w", host, blockedErr) + } + return nil, fmt.Errorf("hostname %s did not resolve", host) +} + +// ValidateOutboundFetchURL validates a fully-qualified HTTP(S) URL against the restricted outbound policy. +func ValidateOutboundFetchURL(ctx context.Context, raw string, opts RestrictedOutboundHTTPOptions) (*url.URL, error) { + parsed, err := NormalizeAbsoluteHTTPURL(raw) + if err != nil { + return nil, err + } + + allowedSchemes := allowedOutboundSchemes(opts) + if !isAllowedOutboundScheme(parsed.Scheme, allowedSchemes) { + return nil, fmt.Errorf("URL scheme must be one of: %s", strings.Join(allowedSchemes, ", ")) + } + if parsed.Fragment != "" { + return nil, fmt.Errorf("URL fragments are not allowed") + } + + if _, err := resolvePermittedOutboundIP(ctx, parsed.Hostname(), opts); err != nil { + return nil, err + } + + return parsed, nil +} + +func canonicalOriginHost(u *url.URL) string { + if u == nil { + return "" + } + + host := strings.ToLower(u.Hostname()) + port := u.Port() + if port == "" { + switch strings.ToLower(u.Scheme) { + case "http": + port = "80" + case "https": + port = "443" + } + } + if host == "" || port == "" { + return strings.ToLower(u.Host) + } + return net.JoinHostPort(host, port) +} + +func sameOriginRedirectPolicy(opts RestrictedOutboundHTTPOptions) func(req *http.Request, via []*http.Request) error { + return func(req *http.Request, via []*http.Request) error { + if len(via) == 0 { + return nil + } + if len(via) >= defaultRestrictedRedirectLimit { + return fmt.Errorf("stopped after %d redirects", defaultRestrictedRedirectLimit) + } + + validated, err := ValidateOutboundFetchURL(req.Context(), req.URL.String(), opts) + if err != nil { + return err + } + + origin := via[0].URL + if !strings.EqualFold(validated.Scheme, origin.Scheme) || canonicalOriginHost(validated) != canonicalOriginHost(origin) { + return fmt.Errorf("redirects must stay on the same origin") + } + return nil + } +} + +func cloneRestrictedTransport(tlsConfig *tls.Config) *http.Transport { + transport, ok := http.DefaultTransport.(*http.Transport) + var clone *http.Transport + if ok && transport != nil { + clone = transport.Clone() + } else { + clone = &http.Transport{Proxy: http.ProxyFromEnvironment} + } + + switch { + case tlsConfig != nil: + clone.TLSClientConfig = tlsConfig.Clone() + case clone.TLSClientConfig != nil: + clone.TLSClientConfig = clone.TLSClientConfig.Clone() + default: + clone.TLSClientConfig = &tls.Config{} + } + + if clone.TLSClientConfig.MinVersion < tls.VersionTLS12 { + clone.TLSClientConfig.MinVersion = tls.VersionTLS12 + } + + return clone +} + +// NewRestrictedOutboundHTTPClient returns an HTTP client that validates redirects and pins direct outbound dials +// to the first permitted resolved IP for the requested host. +func NewRestrictedOutboundHTTPClient(timeout time.Duration, opts RestrictedOutboundHTTPOptions) *http.Client { + transport := cloneRestrictedTransport(opts.TLSConfig) + transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, fmt.Errorf("parse outbound address %q: %w", addr, err) + } + + permittedIP, err := resolvePermittedOutboundIP(ctx, host, opts) + if err != nil { + return nil, err + } + + dialer := net.Dialer{Timeout: 10 * time.Second} + return dialer.DialContext(ctx, network, net.JoinHostPort(permittedIP.String(), port)) + } + + client := &http.Client{ + Transport: transport, + CheckRedirect: sameOriginRedirectPolicy(opts), + } + if timeout > 0 { + client.Timeout = timeout + } + return client +} diff --git a/pkg/securityutil/websocket_origin.go b/pkg/securityutil/websocket_origin.go new file mode 100644 index 000000000..b5d9cebcd --- /dev/null +++ b/pkg/securityutil/websocket_origin.go @@ -0,0 +1,62 @@ +package securityutil + +import ( + "fmt" + "net" + "net/url" + "strings" +) + +// NormalizeWebSocketOriginHost normalizes Origin/Host values for same-origin comparison. +func NormalizeWebSocketOriginHost(host string) string { + normalized := strings.TrimSpace(strings.ToLower(host)) + if normalized == "" { + return normalized + } + + parsedHost, parsedPort, err := net.SplitHostPort(normalized) + if err != nil { + return normalized + } + if parsedPort == "80" || parsedPort == "443" { + return parsedHost + } + return net.JoinHostPort(parsedHost, parsedPort) +} + +// SameHostWebSocketOrigin validates that an Origin header is http(s) and matches the request host. +func SameHostWebSocketOrigin(origin string, requestHost string) bool { + parsed, err := url.Parse(strings.TrimSpace(origin)) + if err != nil || parsed.Host == "" { + return false + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return false + } + + return NormalizeWebSocketOriginHost(parsed.Host) == NormalizeWebSocketOriginHost(requestHost) +} + +// HTTPOriginForWebSocketBaseURL returns the http(s) Origin header for a Pulse websocket base URL. +func HTTPOriginForWebSocketBaseURL(raw string) (string, error) { + parsed, err := NormalizePulseWebSocketBaseURL(raw) + if err != nil { + return "", err + } + + switch parsed.Scheme { + case "ws": + parsed.Scheme = "http" + case "wss": + parsed.Scheme = "https" + default: + return "", fmt.Errorf("unsupported websocket origin scheme %q", parsed.Scheme) + } + + parsed.Path = "" + parsed.RawPath = "" + parsed.RawQuery = "" + parsed.Fragment = "" + + return parsed.String(), nil +}