Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ task license-fix # Add missing license headers
| `mcpcompat/mcp` | Drop-in shim for `mark3labs/mcp-go/mcp` data types; re-exports via aliases, backed by go-sdk elsewhere (Alpha) |
| `mcpcompat/client` | Drop-in shim for `mark3labs/mcp-go/client` (+ `client/transport`) reimplemented on the official go-sdk (Alpha) |
| `mcpcompat/server` | Drop-in shim for `mark3labs/mcp-go/server` reimplemented on the official go-sdk (Alpha) |
| `networking` | Outbound HTTP client construction with SSRF egress policy: private-IP/link-local dial blocking, redirect policy, body-capped JSON fetch, endpoint/issuer URL + private-IP validation helpers, and port allocation/validation utilities (Alpha) |
| `telemetry/metrics` | Shared OTel histogram bucket presets, label-key constants, and emitter-ownership vocabulary (Alpha) |
| `telemetry/reconcile` | Unified operator reconcile metric emitter (Alpha) |
| `oci/artifact` | Artifact-agnostic OCI tar/gzip/extraction/platform primitives shared by oci/skills and oci/plugins (Alpha) |
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ The ToolHive ecosystem spans multiple Go repositories, and several of these proj
| `telemetry/reconcile` | Alpha | Unified operator reconcile metric emitter |
| `oci/skills` | Alpha | OCI artifact types, media types, and registry operations for skills |
| `oci/plugins` | Alpha | OCI artifact types, media types, and registry operations for plugins |
| `networking` | Alpha | Outbound HTTP client construction with SSRF egress policy: private-IP/link-local dial blocking, redirect policy, body-capped JSON fetch, endpoint/issuer URL + private-IP validation helpers, and port allocation/validation utilities |
| `postgres` | Alpha | PostgreSQL connection pool with optional AWS RDS IAM dynamic auth |
| `recovery` | Beta | HTTP panic recovery middleware |
| `validation/http` | Stable | RFC 7230/8707 compliant HTTP header and URI validation |
Expand Down
7 changes: 6 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,14 @@ require (

require (
github.com/prometheus/client_golang v1.24.1
github.com/shirou/gopsutil/v4 v4.26.7
github.com/sigstore/sigstore v1.10.9
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.45.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0
go.opentelemetry.io/otel/exporters/prometheus v0.67.0
go.opentelemetry.io/otel/sdk v1.45.0
go.opentelemetry.io/otel/trace v1.45.0
golang.org/x/oauth2 v0.36.0
)

require (
Expand Down Expand Up @@ -67,9 +69,11 @@ require (
github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 // indirect
github.com/docker/cli v29.6.2+incompatible // indirect
github.com/docker/docker-credential-helpers v0.9.3 // indirect
github.com/ebitengine/purego v0.10.2 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-openapi/analysis v0.25.5 // indirect
github.com/go-openapi/errors v0.22.8 // indirect
github.com/go-openapi/jsonpointer v1.0.0 // indirect
Expand Down Expand Up @@ -112,6 +116,7 @@ require (
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect
Expand All @@ -136,6 +141,7 @@ require (
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 // indirect
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
Expand All @@ -144,7 +150,6 @@ require (
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/mod v0.38.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/term v0.45.0 // indirect
Expand Down
12 changes: 12 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ github.com/docker/cli v29.6.2+incompatible h1:/bjePvcbbFTnRrMfWJBY7AjfICdsiLVgHn
github.com/docker/cli v29.6.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8=
github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo=
github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
Expand All @@ -122,6 +124,8 @@ github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-openapi/analysis v0.25.5 h1:xPYEvTb90o1y0epuiOPAoG4QqahjP3cdp5xNlHeKJRI=
github.com/go-openapi/analysis v0.25.5/go.mod h1:d3UGtQC5uq5Kqqqis2VH09Km/v3vwsWrYkbp4gdm+Rc=
github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I=
Expand Down Expand Up @@ -294,6 +298,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
Expand Down Expand Up @@ -325,6 +331,8 @@ github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI=
github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE=
github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
github.com/sigstore/protobuf-specs v0.5.1 h1:/5OPaNuolRJmQfeZLayJGFXMpsRJEdgC6ah1/+7Px7U=
github.com/sigstore/protobuf-specs v0.5.1/go.mod h1:DRBzpFuE+LnvQMN10/dU6nBeKwVLGEQ6o2FovN2Rats=
github.com/sigstore/rekor v1.5.3 h1:0Tyolw3zreRgm7PUW8dccFLXGBThi08278jI8EXNSr4=
Expand Down Expand Up @@ -395,6 +403,8 @@ github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU
github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms=
github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
Expand Down Expand Up @@ -455,6 +465,8 @@ golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
Expand Down
9 changes: 9 additions & 0 deletions networking/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

// Package networking provides outbound HTTP client construction with an
// SSRF/egress policy: private-IP and link-local dial blocking, a same-host
// redirect policy, and a body-capped JSON fetch helper.
//
// Status: Alpha. The API may change without notice.
package networking
196 changes: 196 additions & 0 deletions networking/fetch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package networking

import (
"context"
"encoding/json"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"strings"
)

const (
// maxResponseSize is the maximum response body size (1MB).
maxResponseSize = 1024 * 1024

// contentTypeJSON is the JSON content type.
contentTypeJSON = "application/json"

// contentTypeFormURLEncoded is the form-urlencoded content type.
contentTypeFormURLEncoded = "application/x-www-form-urlencoded"
)

// FetchResult contains the result of a successful JSON fetch operation.
type FetchResult[T any] struct {
// Data is the parsed JSON response body.
Data T

// Headers are the response headers.
Headers http.Header
}

// FetchOption configures a fetch request.
type FetchOption func(*fetchOptions)

// fetchOptions holds the configuration for a fetch request.
type fetchOptions struct {
method string
headers http.Header
body io.Reader
errorHandler func(*http.Response, []byte) error
maxResponseSize int64
}

// newFetchOptions creates default fetch options.
func newFetchOptions() *fetchOptions {
return &fetchOptions{
method: http.MethodGet,
headers: make(http.Header),
maxResponseSize: maxResponseSize,
}
}

// WithMethod sets the HTTP method for the request.
func WithMethod(method string) FetchOption {
return func(opts *fetchOptions) {
opts.method = method
}
}

// WithHeader adds a single header to the request.
func WithHeader(key, value string) FetchOption {
return func(opts *fetchOptions) {
opts.headers.Set(key, value)
}
}

// WithBody sets the request body.
func WithBody(body io.Reader) FetchOption {
return func(opts *fetchOptions) {
opts.body = body
}
}

// WithMaxResponseSize sets a custom maximum response body size in bytes.
// The default is 1 MB. Use this to enforce tighter limits for endpoints that
// are expected to return small documents (e.g. OAuth metadata, CIMD documents).
func WithMaxResponseSize(size int64) FetchOption {
return func(opts *fetchOptions) {
opts.maxResponseSize = size
}
}

// WithErrorHandler sets a custom error handler for non-200 responses.
// The handler receives the response and body, and should return an error.
// If the handler returns nil, the default HTTPError will be returned.
// This is useful for parsing structured error responses (e.g., OAuth error responses).
func WithErrorHandler(handler func(*http.Response, []byte) error) FetchOption {
return func(opts *fetchOptions) {
opts.errorHandler = handler
}
}

// FetchJSON performs an HTTP request and parses the JSON response body.
// It sets the Accept header to application/json by default.
// For non-200 responses, it returns an HTTPError or the result of a custom error handler.
func FetchJSON[T any](
ctx context.Context,
client HTTPClient,
requestURL string,
opts ...FetchOption,
) (*FetchResult[T], error) {
options := newFetchOptions()
for _, opt := range opts {
opt(options)
}

// Set default Accept header if not already set
if options.headers.Get("Accept") == "" {
options.headers.Set("Accept", contentTypeJSON)
}

req, err := http.NewRequestWithContext(ctx, options.method, requestURL, options.body)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}

// Apply headers
for key, values := range options.headers {
for _, value := range values {
req.Header.Add(key, value)
}
}

resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()

// Read body with size limit
body, err := io.ReadAll(io.LimitReader(resp.Body, options.maxResponseSize))
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}

// Handle non-200 responses
if resp.StatusCode != http.StatusOK {
// Try custom error handler first
if options.errorHandler != nil {
if customErr := options.errorHandler(resp, body); customErr != nil {
return nil, customErr
}
}

// Fall back to default HTTPError using status text to avoid leaking sensitive body content
return nil, NewHTTPError(resp.StatusCode, requestURL, resp.Status)
}

// Validate Content-Type for successful responses. Accept application/json
// and application/*+json subtypes (RFC 6839) — the old strings.Contains check
// incorrectly rejected valid subtypes like application/ld+json.
contentType := resp.Header.Get("Content-Type")
mediaType, _, _ := mime.ParseMediaType(contentType)
if mediaType != contentTypeJSON && (!strings.HasPrefix(mediaType, "application/") || !strings.HasSuffix(mediaType, "+json")) {
return nil, fmt.Errorf("unexpected content type: %s", contentType)
}

// Parse JSON response
var data T
if err := json.Unmarshal(body, &data); err != nil {
return nil, fmt.Errorf("failed to parse JSON response: %w", err)
}

return &FetchResult[T]{
Data: data,
Headers: resp.Header,
}, nil
}

// FetchJSONWithForm performs a POST request with form-urlencoded body and parses JSON response.
// This is a convenience wrapper around FetchJSON for token endpoints and similar APIs.
// It sets Content-Type to application/x-www-form-urlencoded and Accept to application/json.
func FetchJSONWithForm[T any](
ctx context.Context,
client HTTPClient,
requestURL string,
formData url.Values,
opts ...FetchOption,
) (*FetchResult[T], error) {
// Prepend form-specific options
formOpts := []FetchOption{
WithMethod(http.MethodPost),
WithHeader("Content-Type", contentTypeFormURLEncoded),
WithBody(strings.NewReader(formData.Encode())),
}

// Append user options (they can override form options if needed)
allOpts := append(formOpts, opts...)

return FetchJSON[T](ctx, client, requestURL, allOpts...)
}
Loading