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 ¶
- Variables
- func Contains(set []Origin, o Origin) bool
- func HasBearer(r *http.Request) bool
- func HostAllowed(host string, names []string) bool
- func IsJSON(contentType string) bool
- func IsMultipartForm(contentType string) bool
- func PeerAddr(r *http.Request) (netip.Addr, bool)
- func SplitHostPort(hostport string) (host string, port int, err error)
- func Unsafe(method string) bool
- type Origin
- type Proxies
- type Reason
- type Verdict
- type View
Constants ¶
This section is empty.
Variables ¶
var ErrNotAnOrigin = errors.New("not an http(s) origin")
ErrNotAnOrigin is wrapped by every parse failure.
Functions ¶
func HasBearer ¶
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 ¶
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 IsMultipartForm ¶
IsMultipartForm reports a Content-Type of multipart/form-data.
func SplitHostPort ¶
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.
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 ¶
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 ¶
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 ¶
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 ¶
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.
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 ¶
MustParseProxies is ParseProxies for literals in tests and defaults.
func ParseProxies ¶
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 ¶
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.
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 ¶
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 ¶
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 ¶
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.