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
- Variables
- func DefaultTransport(t Timeouts) *http.Transport
- func RedirectGuard(origin string) func(*http.Request, []*http.Request) error
- func ResolveEndpoint(rawURL string) (endpoint, origin string, err error)
- func VetTransport(c *http.Client, t Timeouts) (*http.Transport, error)
- type Diagnostics
- func (d *Diagnostics) AuthError() *auth.Error
- func (d *Diagnostics) LimitError() error
- func (d *Diagnostics) RecordAuthError(err error)
- func (d *Diagnostics) RecordLimitError(err error)
- func (d *Diagnostics) RecordStallError(err error)
- func (d *Diagnostics) RecordStatus(status int)
- func (d *Diagnostics) StallError() error
- func (d *Diagnostics) Status() (int, bool)
- type FrameReader
- type RoundTripper
- type Timeouts
- type WireLimits
Constants ¶
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.
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.
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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.
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 ¶
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.