gitproxy

package
v0.0.1 Latest Latest
Warning

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

Go to latest
Published: Sep 13, 2026 License: MIT Imports: 22 Imported by: 0

Documentation

Overview

Package gitproxy is a git smart-HTTP interception proxy that runs outside the sandbox and decides, per ref, what a sandbox is allowed to push.

The hole it closes

pkg/executor already requires every write-back branch to live under "cloop/", and pkg/writeback re-verifies the branch after the fact. Both of those checks run in code the sandbox itself executes or in code that inspects the result after the push has already landed. Neither is a boundary. A workload that ignores the SDK and runs `git push --force origin HEAD:main` with the credential it was handed reaches main, and the first anyone hears of it is the force-push notification.

The credential is the reason. In push mode the hub leases a GitHub PAT and delivers it *into* the sandbox, because that is where git runs. Everything downstream of that delivery is advisory: the sandbox holds an authority scoped to whole repositories and is asked politely to use a fraction of it.

The inversion

This proxy moves git's authenticated leg out of the sandbox. The sandbox's remote is rewritten to point here, and it authenticates with an ephemeral session token that is worth nothing anywhere else. The proxy holds the real credential, parses the receive-pack command list before forwarding a byte of it, checks every ref update against the session's policy, and only then presents the credential upstream.

sandbox ──session token──▶ gitproxy ──PAT──▶ github.com
                             │
                        Policy.Decide per ref

So "push to whitelisted branches only" stops being a convention the sandbox is trusted to honour and becomes a property of the network path. A sandbox that tries refs/heads/main gets a refusal in git's own vocabulary and an audit row; a sandbox that leaks its token leaks the right to push to refs/heads/cloop/** for the rest of the task, and nothing else.

What it does not decide

Whether an update is a fast-forward. That needs the object graph, which the proxy does not have and would have to index a pack to get. Fast-forward enforcement belongs to the forge, where the graph already is — GitHub branch protection, or receive.denyNonFastForwards. This package is deliberately explicit about the boundary rather than implying a check it cannot make: what it enforces is *which refs* a sandbox may touch and *in which direction* (create, update, delete), which is exactly what stops a sandbox reaching a branch a human owns.

Signed pushes are refused outright. See ParseReceivePack.

Transport

Serve() takes a listener, so TLS is the caller's to configure through pkg/tlsconf. The base URL must be https: the sandbox presents its session token as an Authorization header on every request, and a sandbox is by construction something that may be sharing a host with whatever else is listening on loopback.

Index

Constants

View Source
const DefaultMaxCommands = 16

DefaultMaxCommands caps the ref updates one push may carry.

A write-back moves one branch. Anything with more than a handful of commands is either a mirror push or an attempt to find a ref the allowlist forgot, and neither is what a sandbox is for.

View Source
const DefaultMaxPackBytes int64 = 2 << 30 // 2 GiB

DefaultMaxPackBytes bounds one pushed pack.

The proxy streams the pack rather than buffering it, so this is not a memory bound — it is a bound on what one sandbox session can push through the hub's credential in a single request.

View Source
const DefaultSessionTTL = time.Hour

DefaultSessionTTL bounds one sandbox's access to the proxy.

It is the length of a task's git traffic, not the length of a task: the session is minted when a workload is dispatched and the clone and the write-back push both happen inside it. An hour covers a slow clone of a large repository over a slow link and still expires long before anything that leaked the token could be used at leisure.

View Source
const MaxRefPatterns = 256

MaxRefPatterns bounds an allowlist. A policy with thousands of patterns is a configuration mistake, and matching every one of them against every ref in every push is work an attacker would otherwise get to choose the size of.

View Source
const MaxSessionTTL = 12 * time.Hour

MaxSessionTTL is the ceiling an operator cannot configure past. A git proxy session is a standing authority to push, and a long-lived one is indistinguishable from having handed the sandbox the credential itself.

Variables

View Source
var (
	// ErrUnauthenticated covers every authentication failure — unknown
	// session, wrong token, expired, closed. They are one error on purpose: a
	// caller that could tell "no such session" from "wrong token" would be an
	// oracle for enumerating session IDs.
	ErrUnauthenticated = errors.New("gitproxy: unauthenticated")
	// ErrSessionNotFound is for management operations, where the caller is the
	// operator rather than the sandbox and precision is the point.
	ErrSessionNotFound = errors.New("gitproxy: session not found")
	// ErrWrongRepo means an authenticated session addressed a repository it
	// was not minted for.
	ErrWrongRepo = errors.New("gitproxy: session is not scoped to this repository")
)

Errors a caller distinguishes.

AllDenyReasons is every classification, for tests that assert the docs and dashboards cover the whole set.

View Source
var DefaultAllowedRef = "refs/heads/" + executor.WriteBackBranchPrefix + "**"

DefaultAllowedRef is the allowlist a policy gets when it names none.

It is the write-back namespace and nothing else. pkg/executor already requires every write-back branch to sit under this prefix so that the hub can force-update it without being able to overwrite a branch a human owns — but that rule is enforced by code running *inside* the sandbox, which makes it a convention rather than a boundary. Restating it here, outside, is what turns it into one.

View Source
var ErrRefDenied = errors.New("ref update denied by policy")

ErrRefDenied is the refusal. Every rejected command wraps it, so a caller can distinguish "the policy said no" — an expected, reportable outcome — from a malformed request or a broken upstream.

Functions

func UpstreamRepoPath

func UpstreamRepoPath(upstream string) (string, error)

UpstreamRepoPath extracts the "owner/name" a proxy serves an upstream under.

The shape is not cosmetic. pkg/executor's Workspace.RepoPath parses exactly this out of a clone URL to match a GitHub grant's repository allowlist, so a proxy URL that did not preserve it would break grant matching for every workspace routed through the proxy.

func ValidateRefName

func ValidateRefName(ref string) error

ValidateRefName applies git's ref naming rules to a name arriving from a sandbox.

The proxy forwards this string to a real git server, so a name that git would refuse must be refused here rather than passed along: the rules below are what stop "refs/heads/../../x" and "refs/heads/ --upload-pack=sh" from being this proxy's problem to reason about.

Types

type Credential

type Credential struct {
	Username string
	Password string
	// GrantID and LeaseID are audit bookkeeping. Neither is secret.
	GrantID string
	LeaseID string
}

Credential is what the proxy presents upstream on the sandbox's behalf.

It is the whole point of the design. The sandbox never holds this — it holds a session token that works against this proxy, for this repository, for this TTL, under this policy, and nowhere else. A token that escapes a sandbox buys an attacker exactly the authority the policy already granted, which is "push to refs/heads/cloop/**", rather than everything the underlying PAT can reach.

func (Credential) Empty

func (c Credential) Empty() bool

Empty reports whether there is nothing to present upstream.

type Decision

type Decision struct {
	Update RefUpdate
	Err    error
}

Decision pairs one command with its verdict, for reporting and for audit.

func (Decision) Allowed

func (d Decision) Allowed() bool

Allowed reports whether the command passed.

func (Decision) Reason

func (d Decision) Reason() string

Reason renders the refusal as a single line fit for git's status report, which is newline-delimited and would otherwise be split by a multi-line message into a status line and garbage.

type DenyReason

type DenyReason string

DenyReason classifies a refusal into a closed set.

The prose in a refusal names the ref and the allowlist that excluded it, so it is exactly as varied as the branch names a sandbox chooses to push. That makes it unusable as a metric label: a sandbox pushing refs/heads/1, refs/heads/2, ... would mint one Prometheus series per attempt, turning a probe of the allowlist into memory pressure on the hub watching it. The reason code is chosen by this package from the constants below, so the number of buckets is fixed no matter what a sandbox sends.

const (
	// DenyRefNotAllowed: the ref is outside the session's branch allowlist.
	// A sandbox hitting this repeatedly is enumerating the allowlist.
	DenyRefNotAllowed DenyReason = "ref_not_allowed"
	// DenyDelete: deleting refs is not permitted for this session.
	DenyDelete DenyReason = "delete_denied"
	// DenyCreate: creating refs is not permitted for this session.
	DenyCreate DenyReason = "create_denied"
	// DenyUpdate: updating existing refs is not permitted for this session.
	DenyUpdate DenyReason = "update_denied"
	// DenyNoWrite: the session's policy carries no write capability at all.
	DenyNoWrite DenyReason = "no_write"
	// DenyTooManyCommands: the push carries more ref updates than allowed.
	DenyTooManyCommands DenyReason = "too_many_commands"
	// DenyPushCert: signed pushes are not supported.
	DenyPushCert DenyReason = "push_cert"
)

func DenyReasonOf

func DenyReasonOf(err error) (DenyReason, bool)

DenyReasonOf extracts the classification from anything in an error's chain, falling back to DenyRefNotAllowed for a refusal that predates classification — the conservative choice, since that is the reason a security-minded operator most wants counted rather than dropped.

type Event

type Event struct {
	Kind      EventKind `json:"kind"`
	SessionID string    `json:"session_id,omitempty"`
	RepoPath  string    `json:"repo_path,omitempty"`
	ProjectID string    `json:"project_id,omitempty"`
	TaskID    string    `json:"task_id,omitempty"`
	Actor     string    `json:"actor,omitempty"`
	// Refs are the ref names the request touched.
	Refs []string `json:"refs,omitempty"`
	// Detail is a human-readable reason or summary.
	Detail string    `json:"detail,omitempty"`
	At     time.Time `json:"at"`
}

Event is one observation for the compliance trail.

It carries no credential and no object content: the fields are IDs, ref names and reasons, all of which are safe to write to a log an operator reads. The audit value is in the refusals — a push_denied naming refs/heads/main is a sandbox that tried, and nothing else in the system would have recorded it.

func (Event) String

func (e Event) String() string

String renders the event as one log line.

type EventKind

type EventKind string

EventKind names what happened.

const (
	// EventSessionMinted records a session coming into existence.
	EventSessionMinted EventKind = "session_minted"
	// EventSessionClosed records one going away, by revocation or expiry.
	EventSessionClosed EventKind = "session_closed"
	// EventPushAllowed records a push whose every command passed policy. It is
	// emitted before forwarding, so it describes what was authorised, not what
	// the remote ultimately accepted.
	EventPushAllowed EventKind = "push_allowed"
	// EventPushDenied records a push refused by policy. This is the row that
	// matters: it is the evidence that the boundary held, and the only place a
	// sandbox's attempt to reach a protected branch is written down.
	EventPushDenied EventKind = "push_denied"
	// EventFetch records a read through the proxy.
	EventFetch EventKind = "fetch"
	// EventRejected records a request refused before policy ran at all —
	// unauthenticated, wrong repository, malformed, or a route this proxy does
	// not serve.
	EventRejected EventKind = "rejected"
)

type MintRequest

type MintRequest struct {
	// Upstream is the real https repository URL the proxy will forward to.
	Upstream string
	// Credential authenticates the proxy upstream. Optional: a public
	// repository needs none, though pushing to one generally does.
	Credential Credential
	// Policy is what the sandbox may do. Zero value gets WriteBackPolicy.
	Policy Policy
	// TTL bounds the session. 0 means DefaultSessionTTL; anything above
	// MaxSessionTTL is an error rather than a silent clamp, because a caller
	// that asked for a day and got twelve hours would discover it as a failed
	// push halfway through.
	TTL time.Duration

	ProjectID  string
	TaskID     string
	ExecutorID string
	Actor      string
}

MintRequest describes the session to create.

type Minted

type Minted struct {
	Session *Session
	// Token is the plaintext session token. The registry keeps only its hash,
	// so this is the only copy that will ever exist.
	Token string
	// RepoURL is what the sandbox uses as its remote: the proxy's public base
	// with the session's repository path. It carries no credential.
	RepoURL string
}

Minted is a new session plus the one-time secret.

func (Minted) Credential

func (m Minted) Credential() Credential

Credential renders what a driver delivers into the sandbox: a basic credential whose username is the session ID and whose password is the token.

type Options

type Options struct {
	// Transport carries the proxy's requests to the forge. nil gets a
	// hardened default. It exists so an operator whose forge sits behind a
	// private CA can supply one — the same case pkg/tlsconf exists for — and
	// so a test can point the upstream leg at a self-signed fixture without
	// weakening the default.
	Transport http.RoundTripper
}

Options tunes the upstream leg.

type Policy

type Policy struct {
	// AllowedRefs are glob patterns over full ref names. A pattern without a
	// refs/ prefix is read as a branch name and gets refs/heads/ prepended, so
	// "cloop/*" and "refs/heads/cloop/*" mean the same thing.
	//
	// Matching follows path.Match: "*" does not cross a "/". A trailing "/**"
	// matches any depth below the prefix, which is what a per-task namespace
	// like cloop/task-42/fixup needs.
	AllowedRefs []string `json:"allowed_refs,omitempty"`

	// AllowCreate permits a push that brings a ref into existence. Creating a
	// new branch under an allowed namespace is the ordinary write-back case, so
	// this is the one authority a caller almost always wants.
	AllowCreate bool `json:"allow_create,omitempty"`

	// AllowUpdate permits moving an existing allowed ref to a new commit. A
	// retried task legitimately replaces its own predecessor.
	AllowUpdate bool `json:"allow_update,omitempty"`

	// AllowDelete permits removing an allowed ref. Off by default: deleting is
	// not something a write-back ever needs, and a name-only allowlist that
	// forgot about deletes would let a sandbox destroy the very branches it was
	// scoped to.
	AllowDelete bool `json:"allow_delete,omitempty"`

	// AllowFetch permits the read half of the protocol — git-upload-pack — over
	// the same session. A sandbox that clones through the proxy needs it; one
	// that only pushes back a tree it was handed does not.
	AllowFetch bool `json:"allow_fetch,omitempty"`

	// MaxCommands caps ref updates per push. 0 means DefaultMaxCommands.
	MaxCommands int `json:"max_commands,omitempty"`

	// MaxPackBytes caps the request body. 0 means DefaultMaxPackBytes.
	MaxPackBytes int64 `json:"max_pack_bytes,omitempty"`
}

Policy is what a sandbox is allowed to do to a repository's refs.

It is deny-by-default in every dimension. An empty AllowedRefs is not "no restriction", it is the built-in write-back namespace; an unset AllowDelete is a refusal, not an omission. The failure mode of a permissive default here is a sandbox force-pushing over main, which is exactly the outcome the subsystem exists to make impossible, so there is no default that trades safety for convenience.

func WriteBackPolicy

func WriteBackPolicy() Policy

WriteBackPolicy returns the policy the executor write-back path needs: create and update inside the cloop/ namespace, no deletes, no fetch.

It is a constructor rather than a documented recipe because every caller that hand-assembled these four booleans would be a caller that could get one wrong, and three of the four failures are silent.

func (Policy) AllowsRef

func (p Policy) AllowsRef(ref string) bool

AllowsRef reports whether any pattern admits the ref name.

func (Policy) Decide

func (p Policy) Decide(u RefUpdate) error

Decide returns nil if the update is permitted, or an error wrapping ErrRefDenied whose text is shown to the person running git push.

The two checks are ordered name-first deliberately. A sandbox probing for a ref it is not allowed to touch learns the same thing either way, but an operator reading the audit trail wants "main is not in the allowlist" rather than "delete is not permitted", because the first names the real problem.

func (Policy) DecideAll

func (p Policy) DecideAll(cmds []RefUpdate) ([]Decision, bool)

DecideAll evaluates every command.

A push is atomic here whatever the client asked for: if any command is refused, none is forwarded. Partially applying a push would mean the sandbox discovers the allowlist by watching which half of its commands landed, and leaves the repository in a state neither side asked for.

func (Policy) IsZero

func (p Policy) IsZero() bool

IsZero reports whether the policy grants nothing and names nothing.

It distinguishes "the caller never filled this in" from "the caller deliberately wrote a policy that permits nothing", which Validate refuses. Only the first is safe to replace with a default; treating the second as an invitation to substitute WriteBackPolicy would silently widen a policy somebody wrote to be narrow.

func (*Policy) Normalize

func (p *Policy) Normalize()

Normalize fills defaults and canonicalises patterns. It is idempotent.

func (Policy) Validate

func (p Policy) Validate() error

Validate reports whether the policy is usable. Call Normalize first.

type Proxy

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

Proxy serves the git smart-HTTP protocol for the sessions in a Registry.

The zero value is not usable; call New.

func New

func New(reg *Registry, opts Options) (*Proxy, error)

New returns a proxy over reg.

func (*Proxy) Addr

func (p *Proxy) Addr() string

Addr returns the address the proxy is listening on, or "".

func (*Proxy) Close

func (p *Proxy) Close() error

Close stops serving and waits briefly for in-flight requests.

func (*Proxy) Registry

func (p *Proxy) Registry() *Registry

Registry returns the session registry the proxy serves.

func (*Proxy) Serve

func (p *Proxy) Serve(ln net.Listener) error

Serve accepts connections on ln until Close. The caller supplies the listener, so a TLS listener from pkg/tlsconf drops straight in.

func (*Proxy) ServeHTTP

func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP implements the git smart-HTTP routes.

Everything outside those routes is a 404. The dumb-HTTP protocol in particular is not served: it reads objects and packs straight out of the repository, which would let a sandbox fetch by path and bypass every check below.

type ReceivePack

type ReceivePack struct {
	// Commands are the ref updates, in wire order.
	Commands []RefUpdate
	// Capabilities are the tokens the client advertised after the NUL on the
	// first command line — "report-status", "side-band-64k", "agent=git/2.43"
	// and so on.
	Capabilities []string
	// Shallow lists the object names from any shallow lines. They are carried
	// so a caller can see them; nothing here decides on them.
	Shallow []string
}

ReceivePack is the head of a push: everything before the pack file.

func ParseReceivePack

func ParseReceivePack(body io.Reader) (*ReceivePack, io.Reader, error)

ParseReceivePack reads the command section of a git-receive-pack request.

It returns the parsed head and a reader that replays the entire body — the consumed command bytes followed by the untouched pack — so a caller that decides to allow the push forwards exactly what the client sent. See pktReader for why replay rather than re-encode.

A push carrying a push certificate is refused. With push-cert the command list lives inside a signed block and the remote applies *that* copy, so enforcing a branch allowlist against the unsigned lines would be enforcing it against bytes nothing acts on. Refusing is the only honest answer available without full certificate parsing, and a proxy that silently passed one through would be a branch allowlist with a documented bypass.

func (*ReceivePack) HasCapability

func (r *ReceivePack) HasCapability(name string) bool

HasCapability reports whether the client advertised a bare capability token.

func (*ReceivePack) WantsReportStatus

func (r *ReceivePack) WantsReportStatus() bool

WantsReportStatus reports whether the client will read a status report.

This decides how a refusal is delivered. A client that asked for a report can be told which ref was refused and why, in git's own vocabulary, and will exit non-zero having printed it. A client that did not ask has no channel for that and must be refused at the HTTP layer instead — a silent 200 to a client that cannot read a report is a push that reports success and moved nothing.

func (*ReceivePack) WantsSideband

func (r *ReceivePack) WantsSideband() bool

WantsSideband reports whether the status report must be side-band framed.

type RefDenial

type RefDenial struct {
	Reason  DenyReason
	Message string
}

RefDenial is a policy refusal carrying both its prose and its class.

Error renders exactly as the fmt.Errorf it replaced — the sentinel's text, a colon, then the message — because Decision.Reason strips that prefix to build git's status line, and the sandbox-facing wording of a refusal is covered by tests.

func (*RefDenial) Error

func (e *RefDenial) Error() string

func (*RefDenial) Unwrap

func (e *RefDenial) Unwrap() error

Unwrap keeps errors.Is(err, ErrRefDenied) true for every existing caller.

type RefUpdate

type RefUpdate struct {
	Old string
	New string
	Ref string
}

RefUpdate is one line of a push: move Ref from Old to New.

The SHAs are the client's claim, not a verified fact. Old in particular is what the client believes the remote currently holds; the remote checks it, this proxy does not, and no decision here may depend on it being true. What the proxy does depend on is the *shape* — whether either side is the zero SHA — because that is what distinguishes a create from a delete from an update, and those are three different authorities.

func (RefUpdate) Action

func (u RefUpdate) Action() string

Action names the authority this update needs, for policy and for audit rows.

func (RefUpdate) IsCreate

func (u RefUpdate) IsCreate() bool

IsCreate reports whether the ref does not exist yet.

func (RefUpdate) IsDelete

func (u RefUpdate) IsDelete() bool

IsDelete reports whether the push removes the ref.

func (RefUpdate) IsUpdate

func (u RefUpdate) IsUpdate() bool

IsUpdate reports whether an existing ref moves to a new commit.

func (RefUpdate) String

func (u RefUpdate) String() string

String renders the update for a log line. Object names are not secret.

type Registry

type Registry struct {
	// BaseURL is the https base the sandbox reaches the proxy at, e.g.
	// "https://hub.internal:8443". Sessions render their RepoURL beneath it.
	BaseURL string
	// Now is the clock, injectable for tests. nil means time.Now.
	Now func() time.Time
	// OnEvent, if set, receives every authorisation decision. It runs on the
	// request goroutine, so an implementation that blocks blocks a push.
	OnEvent func(Event)
	// contains filtered or unexported fields
}

Registry holds live sessions.

It is the hub-side half of the proxy and has no HTTP in it, so the policy decisions it gates can be tested without sockets — the same split pkg/egressbroker draws between Broker and Proxy, for the same reason.

func NewRegistry

func NewRegistry(baseURL string) (*Registry, error)

NewRegistry returns a registry serving sessions beneath baseURL.

func (*Registry) Authenticate

func (r *Registry) Authenticate(id, token string) (*Session, error)

Authenticate resolves a session from the basic credential a sandbox presented. Every failure returns ErrUnauthenticated.

func (*Registry) Close

func (r *Registry) Close(id, reason string)

Close revokes a session. It is idempotent, so a driver can call it from a defer without checking whether the run already ended.

func (*Registry) Mint

func (r *Registry) Mint(req MintRequest) (*Minted, error)

Mint creates a session and returns its one-time token.

func (*Registry) ReapExpired

func (r *Registry) ReapExpired() int

ReapExpired drops sessions past their TTL and returns how many went.

Authenticate already refuses an expired session, so this is hygiene rather than enforcement: without it the map grows for the life of the process.

func (*Registry) Session

func (r *Registry) Session(id string) (*Session, error)

Session returns a session by ID, for management and for tests.

func (*Registry) Sessions

func (r *Registry) Sessions() []*Session

Sessions returns every live session.

type Session

type Session struct {
	// ID identifies the session and is the username half of the basic
	// credential the sandbox presents. Not secret.
	ID string
	// RepoPath is the "owner/name" the sandbox addresses, and the path the
	// proxy serves this session under. Pinning it means a session minted for
	// one repository cannot be replayed against another the same proxy happens
	// to be serving.
	RepoPath string
	// Upstream is the real https clone URL. It never reaches the sandbox.
	Upstream string
	// Policy is what this session may do.
	Policy Policy

	// Labels for the audit trail.
	ProjectID  string
	TaskID     string
	ExecutorID string
	Actor      string

	IssuedAt  time.Time
	ExpiresAt time.Time
	// contains filtered or unexported fields
}

Session is one sandbox's brokered access to one repository.

func (*Session) CloseReason

func (s *Session) CloseReason() string

CloseReason returns why the session was revoked, or "".

func (*Session) Closed

func (s *Session) Closed() bool

Closed reports whether the session was revoked.

func (*Session) Expired

func (s *Session) Expired(now time.Time) bool

Expired reports whether the session's TTL has run out.

func (*Session) Stats

func (s *Session) Stats() Stats

Stats returns the session's counters.

type Stats

type Stats struct {
	Pushes    int64 `json:"pushes"`
	Fetches   int64 `json:"fetches"`
	Denied    int64 `json:"denied"`
	BytesUp   int64 `json:"bytes_up"`
	BytesDown int64 `json:"bytes_down"`
}

Stats is a snapshot of one session's traffic, for the UI and audit rows.

Jump to

Keyboard shortcuts

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