httpsec

package
v0.7.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 14, 2026 License: Apache-2.0 Imports: 14 Imported by: 0

Documentation

Overview

Package httpsec is the HTTP security layer shared by this module's two network transports: the client they build, the client they will accept from a caller, and the RoundTripper through which every request and every response passes.

It exists as one package because there is exactly one right answer to each of its questions, and two copies of an answer is two things to keep right. The hazard is not the writing, it is the drift: a second copy that is correct on the day it is written and a release behind six months later is worse than no copy at all, because it looks like it was thought about. So Streamable HTTP and legacy SSE differ in their protocol and share every one of their guarantees:

  • TLS is verified, always, with a 1.2 floor — on a client this package built and on a client a caller supplied (see VetTransport).
  • Credentials are attached per request, so an expiring one is refreshed without a connection noticing, and are never sent to an origin other than the configured one (see RoundTripper.Origin).
  • Nothing unbounded is buffered: a non-streaming body is capped whole, and a stream is capped per frame, because a total on a stream designed to live for a session is not a limit but an expiry date.
  • A server that starts a frame and stops is on a clock (see frames.go).

The RoundTripper is where the guarantees actually live. The SDK above it composes MCP out of HTTP requests; this is the one place that sees all of them, so it is the only place that can attach a credential to each one, bound what each one may return, and record why one failed.

This package must not import the MCP go-sdk, and does not: what it owns is HTTP, and the SDK's business is the protocol carried over it.

Index

Constants

View Source
const (
	// DefaultMaxBodyBytes caps one non-streaming response body.
	DefaultMaxBodyBytes = 16 << 20
	// DefaultMaxFrameBytes caps one SSE event.
	DefaultMaxFrameBytes = 4 << 20
)

Defaults applied when a WireLimits field is not positive.

They exist because a transport may legitimately be driven without the client above it — a test, or an application composing its own — and limits.BoundedReader treats a non-positive bound as a programmer error, not as "unbounded". The values match client.DefaultLimits; they are restated rather than imported because pkg/client imports this direction, not the other.

View Source
const MaxRedirects = 5

MaxRedirects caps a redirect chain. A legitimate MCP endpoint does not need one at all — every hop here stays on the configured origin, so a chain is at most a path or scheme normalization.

View Source
const MinTLSVersion = tls.VersionTLS12

MinTLSVersion is the floor for every HTTPS connection a transport built on this package makes, on a client it built and on a client it was given alike.

Variables

View Source
var ErrForeignOrigin = errors.New("httpsec: refusing a request to a foreign origin")

ErrForeignOrigin reports a request aimed somewhere other than the configured origin. Transports classify it into their own taxonomy; this package has none.

Functions

func DefaultTransport

func DefaultTransport(t Timeouts) *http.Transport

DefaultTransport builds the transport used when the caller supplies no client. It is http.DefaultTransport's shape with this package's bounds and a TLS floor, rather than http.DefaultTransport itself: that one is shared with the whole process, and editing it would be editing everyone's.

func RedirectGuard

func RedirectGuard(origin string) func(*http.Request, []*http.Request) error

RedirectGuard returns the http.Client CheckRedirect that refuses to leave origin, or to go round forever.

This is not a hardening nicety; without it a transport here is strictly worse than the http.Client it wraps. The stdlib strips Authorization when a redirect crosses origins — and then this package's RoundTripper, which runs *below* that logic and attaches credentials to every request it sees, puts it back. A 302 from a configured endpoint to an attacker's host would hand over the bearer token, in cleartext if the redirect said http. A test proves both halves, because the failure is invisible from the happy path.

The policy is origin-pinning, and it is deliberately stricter than the scheme check pkg/auth's defaultHTTPClient applies to its own flows:

  • An MCP endpoint is configuration. The caller named one server; a server that answers "I am actually over there" is not a redirect to follow, it is a different server, and only the caller can decide to talk to it.
  • The credential is keyed by origin. auth.Key binds a token to the origin it was minted for, so sending it to another origin is incoherent by construction — whatever the scheme, whatever the trust.
  • Allowing cross-origin https "because TLS" would still send a token for server A to server B. The scheme is not what makes that wrong.

So same-origin redirects pass — "/mcp" to "/mcp/" is an ordinary thing for a server to do, and the credential is not going anywhere new — and everything else is refused. Refusing costs a caller nothing but an explicit config change, which is the point: it makes the move deliberate.

Canonicalization is auth.CanonicalOrigin, the same function a transport's New validated its endpoint with, which is what makes config time and run time agree. A hop to a non-loopback http host does not need its own check: it fails to match the origin, and would fail CanonicalOrigin too.

It is belt to RoundTripper.Origin's braces, and both are wanted. This one refuses the hop before it is made, which is the better error and the earlier stop; the RoundTripper's catches a URL that reached the client without ever being a redirect. Even a same-origin chain is bounded: a server that bounces a request between two of its own paths forever is a hang, and a hang is what every bound here exists to prevent.

func ResolveEndpoint

func ResolveEndpoint(rawURL string) (endpoint, origin string, err error)

ResolveEndpoint validates rawURL and splits it into the URL to request and the origin to display.

The validation is auth.CanonicalOrigin's, not a second opinion: it is the function that decides what an origin is for the token store, and a transport that accepted a URL the store cannot key — or refused one it can — would put a credential and the server it is for in disagreement about what "the server" means. It brings the loopback rule with it, which is the rule that matters: cleartext is for a server on this machine, and tokens do not cross a network unencrypted.

The request URL keeps its path and query; only the origin is derived. The two are separate values because they have separate audiences: one is sent, the other is shown.

func VetTransport

func VetTransport(c *http.Client, t Timeouts) (*http.Transport, error)

VetTransport returns the *http.Transport every connection's client is built on, from the caller's client if it supplied one.

A supplied client is neither trusted nor mutated. It is read, refused if it contradicts this package's guarantees, and otherwise cloned — so a caller that supplies a client to pin a CA or route through a proxy gets that, without also getting the ability to turn certificate verification off by accident, and without this package reaching into a value the caller still holds.

Types

type Diagnostics

type Diagnostics struct {
	// contains filtered or unexported fields
}

Diagnostics is what one connection recorded about its own failure, on the way past, at the point where the cause was still a value.

It is a record and not a channel: nothing waits on it, and nothing acts on it except classify, after something has already failed.

Its fields fall into two lifetimes, because a failure describes either one request or the whole session. The status is request-scoped: it is written synchronously for a single request's response line and cleared at the head of the next request (see resetPerRequest), so a later transport loss is never labelled with an earlier request's 401. The limit, stall and auth causes are session-scoped and first-writer-wins: a stall or over-limit body ends the session it streams on, and a refused credential recurs on every request until it changes — so the first of each is the one that explains why the session is over, and it is kept. (The limit and stall are also written asynchronously, from the SDK's body-read goroutines, which is the second reason not to clear them per request: doing so would erase a live stream's genuine cause the moment a concurrent request began.)

It is safe for concurrent use: the SDK reads its streams on goroutines of its own, so a status and a body can be recorded from two places at once.

func (*Diagnostics) AuthError

func (d *Diagnostics) AuthError() *auth.Error

AuthError returns the recorded auth failure as an *auth.Error, or nil.

A provider is application code and may return anything. A failure that is not an *auth.Error is still an auth failure — the provider was asked for a credential and did not produce one — so it is reported as one, with the class that says exactly that much and no more.

func (*Diagnostics) LimitError

func (d *Diagnostics) LimitError() error

func (*Diagnostics) RecordAuthError

func (d *Diagnostics) RecordAuthError(err error)

RecordAuthError keeps the first auth failure, not the last: once credentials are refused, every request after it fails the same way, and the first one is the one that explains the session.

func (*Diagnostics) RecordLimitError

func (d *Diagnostics) RecordLimitError(err error)

func (*Diagnostics) RecordStallError

func (d *Diagnostics) RecordStallError(err error)

RecordStallError keeps the first stalled frame. Like the others it is a record of a cause, written where the cause was still a value.

func (*Diagnostics) RecordStatus

func (d *Diagnostics) RecordStatus(status int)

func (*Diagnostics) StallError

func (d *Diagnostics) StallError() error

func (*Diagnostics) Status

func (d *Diagnostics) Status() (int, bool)

type FrameReader

type FrameReader struct {
	// contains filtered or unexported fields
}

FrameReader bounds each SSE frame in a stream, independently.

It is a byte filter, not a parser. It does not decode events — the SDK does that, downstream — and it deliberately understands only one thing about the wire format: where a frame ends. Anything more would be a second, divergent implementation of a parser this package already delegates.

The bound is on the frame as it appears on the wire, including its field names and its line endings, which slightly over-counts the payload. That is the safe direction, and the difference is tens of bytes against a bound of megabytes.

func NewFrameReader

func NewFrameReader(r io.Reader, limit int, d *Diagnostics, timeout time.Duration, interrupt func()) *FrameReader

NewFrameReader returns a reader over r that fails once any single SSE frame exceeds limit bytes, or takes longer than timeout to arrive once it has started.

interrupt must close whatever r is reading from; it is what unblocks a parked Read when the timeout fires. It may be nil, which disables the time bound and is for callers with no body to close — the byte bound still applies.

limit and timeout must be positive; Timeouts and WireLimits guarantee both. A non-positive bound is not "unbounded" anywhere in this module, and here it has no sensible meaning at all — it would reject the empty frame.

func (*FrameReader) Read

func (f *FrameReader) Read(p []byte) (int, error)

Read passes bytes through, counting them against the current frame's budget and resetting that budget at each frame boundary.

It never reads ahead and never buffers: the bytes go straight to the caller, and the only state kept is the counter and where the frame delimiter got to. That matters for a streaming transport — a reader that buffered a frame to measure it would add the very latency SSE exists to avoid, and would have to hold the frame it is trying not to hold.

type RoundTripper

type RoundTripper struct {
	// Base is the vetted transport underneath. Required.
	Base http.RoundTripper
	// Headers are static application-supplied headers, attached to every
	// request. A value may be a credential and is treated as one.
	Headers []auth.Header
	// Provider supplies credential headers per request. Nil means none.
	Provider auth.HeaderProvider
	// Wire bounds what may be buffered off the network.
	Wire WireLimits
	// Request bounds a whole non-streaming exchange; see a transport's
	// Timeouts.Request.
	Request time.Duration
	// Frame bounds one frame's arrival on a stream; see Timeouts.Frame.
	Frame time.Duration
	// Diags records a failure's cause on its way past. Required.
	Diags *Diagnostics
	// Origin, when non-empty, is the only origin this RoundTripper will send a
	// request to. Anything else is refused before a credential is attached.
	//
	// It is not a duplicate of the redirect guard, and the difference is the
	// reason it exists. CheckRedirect sees an origin change the *stdlib* makes,
	// following a 3xx. It does not see one a *server* makes by handing the
	// transport above it a new URL to send to — which is exactly what the legacy
	// SSE transport's "endpoint" event is: the SDK resolves that event's data as
	// a URL reference against the endpoint, and an absolute one lands wherever
	// the server says, with this module's credentials on it.
	//
	// This is the guard that covers both, because every request passes through
	// here however its URL was chosen.
	Origin string
}

RoundTripper attaches credentials to every request and bounds every response.

It is one connection's, not one factory's, because the Diagnostics it writes belong to one session's failure.

func (*RoundTripper) RoundTrip

func (rt *RoundTripper) RoundTrip(req *http.Request) (*http.Response, error)

RoundTrip implements http.RoundTripper.

type Timeouts

type Timeouts struct {
	// Dial bounds the TCP connect.
	//
	// It is applied to every transport this package builds, and to a supplied
	// transport that has no DialContext of its own. A caller who installs a
	// DialContext owns its timeout: that field is how a proxy or a custom
	// resolver is configured, so this package fills it in when it is absent
	// rather than overwriting a dialing policy someone chose on purpose.
	Dial time.Duration
	// TLSHandshake bounds the TLS handshake.
	TLSHandshake time.Duration
	// ResponseHeader bounds the wait for a response's headers. It stops before
	// the body, so it costs a stream nothing.
	ResponseHeader time.Duration
	// Frame bounds how long one wire frame may take to arrive, measured from
	// its first byte to its last. It is a completion deadline and not an idle
	// one: a deadline any byte resets is a deadline a server dribbling one byte
	// at a time never trips, which is the attack.
	Frame time.Duration
	// IdleConn bounds how long a pooled connection is kept alive unused.
	IdleConn time.Duration
	// Request bounds a whole request that cannot stream.
	Request time.Duration
}

Timeouts bounds every wait the HTTP layer performs. It is this package's narrow view: each transport exports its own documented Timeouts and maps it onto this, because the defaults and the prose belong to the transport a caller configures, while the enforcement belongs here.

Every field is expected positive; a transport passes a defaulted value.

type WireLimits

type WireLimits struct {
	MaxBody  int
	MaxFrame int
}

WireLimits is this package's normalized view of protocol.WireLimits: every field is positive.

func NewWireLimits

func NewWireLimits(w protocol.WireLimits) WireLimits

NewWireLimits normalizes what the client passed. A non-positive field means the caller did not set one — client.Connect always passes normalized values — and gets this package's default; it never means unbounded, which is not a setting.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL