sameorigin

package
v0.0.5 Latest Latest
Warning

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

Go to latest
Published: Oct 12, 2026 License: MIT Imports: 9 Imported by: 0

Documentation

Overview

Package sameorigin decides whether a request to a cloop hub was made by one of the hub's own pages, and what the request says about where it came from (Task 20394).

Why a hub has to ask

A browser attaches a site's cookies to every request it sends there, whichever page asked it to. SameSite narrows that to the *site* — scheme and registrable domain — not the origin, so another port on the hub's host, or a sibling subdomain of an enterprise domain, still gets the hub's session cookie sent. And a hub with no credential at all has nothing to withhold: every page the developer opens can post to it. A form with enctype=text/plain, or fetch in no-cors mode, sends a body without a preflight, so the only things standing between a page elsewhere and a queued agent task are the checks in this package:

  • CheckUnsafe: a state-changing request must come from the hub's own origin. The browser says so in Sec-Fetch-Site, which a page cannot set; a browser too old to send it still sends Origin, which is compared exactly.
  • CheckUpgrade: the same question for a WebSocket handshake, which the same-origin policy does not cover at all.
  • HostAllowed: on a hub with no credential, DNS rebinding makes a page on attacker.example same-origin with the hub itself — the browser's own judgement then says "same-origin", truthfully — so the hub has to refuse host names it does not answer to.
  • IsJSON: a cross-origin page can send a body only as form data or text/plain without a preflight, so a JSON API that accepts nothing else cannot be reached by one.

And because every one of these turns on what the request says about its scheme and host, Proxies decides whose X-Forwarded-* headers are believed.

The package is stdlib-only on purpose: the executor agent's WebSocket endpoint (pkg/executor/remote) answers the same question, and the hub and the dashboard must not be able to give two answers to it.

Index

Constants

This section is empty.

Variables

View Source
var ErrNotAnOrigin = errors.New("not an http(s) origin")

ErrNotAnOrigin is wrapped by every parse failure.

Functions

func Contains

func Contains(set []Origin, o Origin) bool

Contains reports whether o is one of set.

func HasBearer

func HasBearer(r *http.Request) bool

HasBearer reports an Authorization header carrying a Bearer credential.

Only Bearer: Basic, Digest and Negotiate are schemes a browser remembers and attaches by itself — a reverse proxy asking for Basic auth, or Kerberos on an intranet, makes them as ambient as a cookie. A bearer token is only ever attached by script, and script on another origin cannot attach it without a preflight.

func HostAllowed

func HostAllowed(host string, names []string) bool

HostAllowed reports whether a hub with no credential answers to host (a Host header value), given the names it was configured with.

Admitted:

  • no host at all, which no browser sends;
  • an IP address, any IP address. DNS rebinding needs a *name* the attacker controls to point at the hub; a browser sends an IP-literal Host only to a page whose own origin is that address, which is the hub itself;
  • localhost and *.localhost, which browsers resolve to loopback without asking DNS (RFC 6761);
  • an entry of names: a host name (any port) or host:port, compared case-insensitively.

Everything else is a name somebody else's DNS answers for, and a page there is same-origin with whatever that name points at.

func IsJSON

func IsJSON(contentType string) bool

IsJSON reports a Content-Type of application/json, with or without parameters.

func IsMultipartForm

func IsMultipartForm(contentType string) bool

IsMultipartForm reports a Content-Type of multipart/form-data.

func PeerAddr

func PeerAddr(r *http.Request) (netip.Addr, bool)

PeerAddr is the address of r's direct TCP peer.

func SplitHostPort

func SplitHostPort(hostport string) (host string, port int, err error)

SplitHostPort splits a Host header value into a lower-cased host and a port, 0 when there is none. Unlike net.SplitHostPort it accepts a value without a port, and it refuses a host that could not appear in a URL.

func Unsafe

func Unsafe(method string) bool

Unsafe reports a method that can change state: anything but GET, HEAD and OPTIONS (RFC 9110 §9.2.1).

Types

type Origin

type Origin struct {
	// Scheme is "http" or "https".
	Scheme string
	// Host is the lower-cased host name or IP address, an IPv6 address
	// without its brackets.
	Host string
	// Port is the TCP port, filled in from the scheme when the source left
	// it out.
	Port int
}

Origin is a web origin as RFC 6454 defines it: a scheme, a host and a port. Two origins are the same exactly when all three are equal, which is what == on this struct compares: Host is lower-case and Port is always explicit, so "https://Hub.example.com" and "https://hub.example.com:443" are one value.

func FromHost

func FromHost(scheme, hostport string) (Origin, error)

FromHost builds the origin a request was addressed to from its scheme and the host it named (a Host or X-Forwarded-Host value: host, host:port, or a bracketed IPv6 address with an optional port).

func Parse

func Parse(s string) (Origin, error)

Parse reads an Origin header value: exactly scheme://host[:port], as a browser sends it. Anything else — a path, a query, credentials, another scheme, the opaque origin "null" — is refused, so that nothing a page can put in the header is read more generously than a browser would write it.

func ParseEntries

func ParseEntries(entries []string) (origins []Origin, bad []string)

ParseEntries parses each configuration entry with ParseEntry, skipping blanks. It returns the origins that parsed and the entries that did not, so a caller can name the latter rather than silently drop them.

func ParseEntry

func ParseEntry(s string) (Origin, error)

ParseEntry reads an origin from configuration: ui.external_url, ui.allowed_origins and the like. More forgiving than Parse in the two ways operators write these: a full URL may carry a path ("https://hub.example.com/" or the OIDC callback URL), which is ignored; and an entry without a scheme ("hub.example.com", "hub.example.com:8443") means https. Plaintext has to be asked for by name, because an http origin is one anybody on the network path can serve a page from.

func (Origin) IsZero

func (o Origin) IsZero() bool

IsZero reports the zero Origin, which matches nothing.

func (Origin) String

func (o Origin) String() string

String renders the origin the way a browser writes it in an Origin header: the scheme's default port is left out.

type Proxies

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

Proxies is the set of peers whose X-Forwarded-* headers are believed. Loopback is always in it: a proxy on the hub's own machine is the default deployment, and anything that can connect from loopback can already reach the hub's plaintext port directly. The zero value is loopback only.

func MustParseProxies

func MustParseProxies(entries ...string) Proxies

MustParseProxies is ParseProxies for literals in tests and defaults.

func ParseProxies

func ParseProxies(entries []string) (Proxies, error)

ParseProxies reads ui.trusted_proxies: CIDR prefixes or single addresses. A prefix that covers every address (0.0.0.0/0, ::/0) is refused: it would let any client choose the scheme, host and address the hub believes, which is the behaviour this setting exists to replace.

func (Proxies) ClientIP

func (p Proxies) ClientIP(r *http.Request) string

ClientIP is the address of the client that made r: its direct peer, unless that is a trusted proxy, in which case X-Forwarded-For is walked from the right — each proxy appends the peer it saw — past every trusted proxy, and the first address that is not one is the client. Entries to the left of it were written by the client and are not consulted.

func (Proxies) Entries

func (p Proxies) Entries() []string

Entries renders the configured prefixes, loopback not included.

func (Proxies) Trusts

func (p Proxies) Trusts(a netip.Addr) bool

Trusts reports whether a is loopback or inside a configured prefix.

func (Proxies) TrustsPeer

func (p Proxies) TrustsPeer(r *http.Request) bool

TrustsPeer reports whether r's direct TCP peer is a trusted proxy.

type Reason

type Reason string

Reason says why a request was admitted or refused. The refusal reasons are a closed set, which is what lets them label a metric.

const (
	// ReasonSafeMethod: GET, HEAD or OPTIONS, which change nothing.
	ReasonSafeMethod Reason = "safe_method"
	// ReasonBearer: the request carries an Authorization: Bearer credential.
	// A page on another origin can attach one only after a CORS preflight,
	// and a hub that grants no preflight never lets it.
	ReasonBearer Reason = "bearer"
	// ReasonSameOrigin: the browser says Sec-Fetch-Site: same-origin.
	ReasonSameOrigin Reason = "same_origin"
	// ReasonUserInitiated: Sec-Fetch-Site: none — the user typed or
	// bookmarked it; no page asked.
	ReasonUserInitiated Reason = "user_initiated"
	// ReasonNoOrigin: neither Sec-Fetch-Site nor Origin — not a browser, or
	// one old enough to send neither on a same-origin request.
	ReasonNoOrigin Reason = "no_origin"
	// ReasonOwnOrigin: no Sec-Fetch-Site, and an Origin exactly equal to one
	// of the hub's own.
	ReasonOwnOrigin Reason = "own_origin"
)

Admissions.

const (
	// ReasonCrossSite: Sec-Fetch-Site: cross-site — a page on another site.
	ReasonCrossSite Reason = "cross_site"
	// ReasonSameSite: Sec-Fetch-Site: same-site — another origin of the same
	// site: another port of the hub's host, or a sibling subdomain. SameSite
	// cookies are sent to it; this is the case they do not stop.
	ReasonSameSite Reason = "same_site"
	// ReasonUnknownFetchSite: a Sec-Fetch-Site value this check does not
	// know. Refused rather than guessed at.
	ReasonUnknownFetchSite Reason = "unknown_fetch_site"
	// ReasonForeignOrigin: no Sec-Fetch-Site, and an Origin that is not the
	// hub's own — including the opaque origin "null" that sandboxed frames,
	// data: URLs and cross-origin redirects carry.
	ReasonForeignOrigin Reason = "foreign_origin"
	// ReasonMediaType: a body that is neither JSON nor, on the upload routes,
	// multipart form data — what a cross-origin form can send without a
	// preflight.
	ReasonMediaType Reason = "media_type"
	// ReasonUnknownHost: on a hub with no credential, a Host the hub does not
	// answer to — the shape DNS rebinding takes.
	ReasonUnknownHost Reason = "unknown_host"
)

Refusals.

func RefusalReasons

func RefusalReasons() []Reason

RefusalReasons is every refusal reason, for a metric's label set and for tests.

type Verdict

type Verdict struct {
	// Allowed reports the request may proceed.
	Allowed bool
	// Reason says why.
	Reason Reason
	// FetchSite is the Sec-Fetch-Site the browser sent, "" when absent.
	FetchSite string
	// Origin is the Origin header as sent, "" when absent.
	Origin string
}

Verdict is one decision.

func CheckUnsafe

func CheckUnsafe(r *http.Request, own []Origin) Verdict

CheckUnsafe decides whether a state-changing request may proceed, given the origins the hub calls its own.

  • A bearer credential admits it (see HasBearer).
  • Sec-Fetch-Site, which every current browser sends and no page can set, must be same-origin or none.
  • Without it, an Origin header — which browsers have sent on every POST for longer — must equal one of own exactly, scheme and port included.
  • With neither, the request is not from a browser that could have been made to send it, and is admitted.

The caller decides which requests are subject to this at all; CheckUnsafe admits a safe method without looking further.

func CheckUpgrade

func CheckUpgrade(r *http.Request, own []Origin) Verdict

CheckUpgrade decides whether a WebSocket handshake may proceed. A WebSocket is exempt from the same-origin policy — a page anywhere may open one to any host, cookies included — so its Origin is the only defence: absent, it is not a browser (an executor agent, a CLI) and is admitted; present, it must equal one of own exactly. Loopback is not special: http://localhost:3000 is another origin than a hub on :8080, and any page a developer serves there is as foreign to the hub as one on the Internet.

type View

type View struct {
	// Scheme is "https" or "http".
	Scheme string
	// Host is the host[:port] the browser addressed, as sent.
	Host string
}

View is what a request says about the hop between the browser and whatever it connected to: the scheme it used and the host it addressed.

func ViewOf

func ViewOf(r *http.Request, trusted bool) View

ViewOf resolves r's client-facing scheme and host. trusted says whether r came from a proxy whose X-Forwarded-Proto and X-Forwarded-Host are believed; when it did not, they are ignored and the connection's own TLS state and Host header decide.

Only the first entry of each header is read: a chain of proxies appends, and the first is the hop the browser made.

func (View) Origin

func (v View) Origin() (Origin, bool)

Origin is the origin the browser addressed, if the view names one.

func (View) TLS

func (v View) TLS() bool

TLS reports that the browser's hop was encrypted.

Jump to

Keyboard shortcuts

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