local

package
v0.9.4 Latest Latest
Warning

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

Go to latest
Published: Aug 20, 2026 License: MIT Imports: 9 Imported by: 0

Documentation

Overview

Package local implements the loopback transport: a listener bound to 127.0.0.1 and authenticated by a token file, an Origin allowlist, and a Host check.

How a credential reaches the browser

The session token — the contents of $XDG_CONFIG_HOME/flue/token — is the permanent credential, and it is never put in a URL. A URL is argv the moment it is handed to open(1) or xdg-open(1), and argv is readable by any local user at Linux's default hidepid=0 and by ps(1) on macOS. So flue open asks the daemon to Mint a one-time handoff token, puts *that* in the URL, and the first load exchanges it here for the HttpOnly session cookie. The handoff token is single-use, expires in HandoffTTL, and buys nothing on a second presentation.

Three credential carriers, three different meanings:

  • The flue_token cookie authenticates the browser after the exchange. It is attached automatically, which is exactly why it must not be sufficient for anything privileged: SameSite is blind to the port, so a co-resident untrusted origin on another loopback port can cause the victim's browser to send it.
  • The X-Flue-Token header authenticates a non-browser local client — the flue CLI — that can read the token file. A browser cannot be induced to send it cross-origin without a CORS preflight, and the daemon answers every OPTIONS with 405, so the preflight can never succeed.
  • The handoff query parameter authenticates exactly one first-load navigation and is then gone.

Index

Constants

View Source
const HandoffParam = "h"

HandoffParam is the query parameter flue open puts a handoff token in. The session token is never accepted from a URL under any parameter name.

View Source
const HandoffTTL = 10 * time.Second

HandoffTTL is how long a minted handoff token stays redeemable.

Seconds, not minutes, because flue open launches the browser on the very next line: the budget is one process spawn plus a browser's startup-to-first- request, not a human's attention span. Ten seconds is comfortable for a cold browser start and still short enough that a token found afterwards — in a terminal buffer, in a screenshot, in a strace — is inert.

View Source
const HeaderName = "X-Flue-Token"

HeaderName is the request header a non-browser local client presents the session token in. It is the only way to authenticate a mint, and one of two ways (with the cookie) to authenticate an ordinary request.

Variables

View Source
var (
	ErrNoToken      = errors.New("local: missing or invalid token")
	ErrBadOrigin    = errors.New("local: origin not allowed")
	ErrBadHost      = errors.New("local: host not allowed")
	ErrBadFetchSite = errors.New("local: fetch metadata not allowed")

	// ErrBadHandoff is deliberately indistinguishable between "never existed",
	// "expired" and "already spent": all three mean the same thing to the
	// caller — run flue open again — and telling them apart would confirm to
	// whoever presented it that a token they guessed or found had once been
	// real.
	ErrBadHandoff = errors.New("local: handoff token is invalid, expired, or already used — run flue open again")

	// ErrNotLocalClient rejects a mint attempt from anything that looks like a
	// browser. Only a local process that can read the token file may mint.
	ErrNotLocalClient = errors.New("local: minting is not available to browsers")
)

Functions

func CookieNameFor

func CookieNameFor(port int) string

CookieNameFor is the session cookie's name for a daemon on port. The token lives there after the first load, so it never stays in the URL where history and referrers would leak it.

The port is in the name because the browser will not keep it anywhere else: cookies are scoped to the host and blind to the port, so two daemons on one machine — an installed one on 7717, a dev one on another port — would otherwise share one name and each login would evict the other's session.

Types

type Auth

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

Auth enforces loopback authentication. All three checks are required: a valid token, an allowed Origin, and an allowed Host. The Host check defends against DNS rebinding, where a name the attacker controls resolves to 127.0.0.1.

It also owns the handoff token store; see handoff.go.

func NewAuth

func NewAuth(token string, port int) *Auth

func NewAuthWithClock

func NewAuthWithClock(token string, port int, now func() time.Time) *Auth

NewAuthWithClock is NewAuth with the clock the handoff TTL is measured against supplied explicitly. It exists so a test can drive expiry without sleeping; production code calls NewAuth.

func (*Auth) Check

func (a *Auth) Check(r *http.Request) error

Check reports whether r is authenticated.

It never redeems a handoff token. This is the path handleWS takes, and a handoff token must not be able to authenticate a WebSocket upgrade — the one GET that leads to every state change flue has. A handoff token buys the session cookie through Middleware and nothing else.

func (*Auth) CheckMint

func (a *Auth) CheckMint(r *http.Request) error

CheckMint reports whether r may mint a handoff token.

Minting converts "I can read the token file" into "here is a fresh credential", so the set of principals allowed to do it must be exactly the set that can read $XDG_CONFIG_HOME/flue/token — mode 0600 in a 0700 directory. Two rules get there:

  • The session token must arrive in HeaderName. Not the cookie, which the browser attaches by itself and which a co-resident untrusted origin can therefore cause the victim's browser to send; and not the query string, which is the exposure this whole mechanism exists to remove. A browser cannot send a custom header cross-origin without a CORS preflight, and the daemon answers every OPTIONS with 405.

  • The request must carry no Sec-Fetch-Site header at all, with any value — including "none", the one value a redirect can launder. Every modern browser sends that header on every request and the flue CLI never does, so requiring its absence states the actual policy (only a non-browser local process may mint) rather than enumerating browser cases to block. A local process can forge its absence, but a local process that can forge it still needs the session token, which is the thing being protected.

func (*Auth) CheckProvenance

func (a *Auth) CheckProvenance(r *http.Request) error

CheckProvenance reports whether r came from somewhere this daemon accepts — its own Host, its own Origin, and a fetch site that is not another page's — without asking for any credential at all.

It exists for the one endpoint whose credential is not the session token: the pairing POST, where the device being enrolled by definition holds nothing this daemon issued yet. That endpoint still has to refuse anything cross-origin before it so much as looks at the pairing token, so it needs exactly this half of Check and no more. Exported so it is the same code, audited once, rather than a second copy in package daemon that could drift from this one.

It is never sufficient on its own. A caller that uses this must have some other credential in hand; nothing that authenticates with the session token may call it in place of Check.

func (*Auth) Middleware

func (a *Auth) Middleware(next http.Handler) http.Handler

Middleware enforces the request checks, exchanges a one-time handoff token for the session cookie, and sets the response headers every flue response needs.

The order is load-bearing:

  1. Provenance first, so a request from a disallowed Host, Origin or fetch site can neither be issued a cookie nor spend a handoff token.
  2. Then the handoff exchange, because a first load has no cookie yet and would fail the token check it has not reached.
  3. Then the ordinary token check.

There is no fallback in either direction. A handoff token that is unknown, expired or already spent fails the request outright — it does not fall through to the cookie, and it certainly does not fall back to accepting a session token from the URL, which would put the exposure straight back where it started. Failing loudly also makes "a second presentation fails" a property of this endpoint rather than a property of the client's cookie jar.

func (*Auth) Mint

func (a *Auth) Mint() (string, error)

Mint issues a fresh single-use handoff token.

The caller must have proved it may mint — see CheckMint. Mint itself performs no authentication, so it must never be reachable from a handler that has not run that check.

func (*Auth) Redeem

func (a *Auth) Redeem(tok string) bool

Redeem spends tok, reporting whether it was a live handoff token.

Single use is enforced by doing the lookup and the removal inside one critical section. Two concurrent redemptions of the same token therefore serialise: the first removes it, the second scans a store that no longer holds it, and exactly one of them can observe true. There is no read-then-write window and no compare-and-swap to get wrong.

A presented token is removed whether or not it was still valid. Leaving an expired one in place would break the rule that "presented once" implies "gone" — and would make it live again if the clock ever moved backwards under it (a manual date(1), an NTP step).

The scan compares every entry with crypto/subtle and does not stop at the first match, so the work done depends on the store's size rather than on how close tok is to anything in it. At a few dozen entries that costs nothing.

Jump to

Keyboard shortcuts

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