gitcorsproxy

package module
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Oct 4, 2026 License: BSD-3-Clause Imports: 13 Imported by: 0

README

go-browserhttp/gitcorsproxy

go-browserhttp / gitcorsproxy

CI Go Reference

A tiny, sovereign CORS reverse proxy for git smart-HTTP. It lets a browser — specifically a Go/wasm git client such as the go-tex playground's git-worker.wasm — reach git remotes that answer the smart-HTTP protocol correctly but send no Access-Control-Allow-Origin (GitHub, ghcr-style hosts, a bare Forgejo). The proxy forwards the git bytes both ways and adds the CORS headers the browser needs, scoped to an explicit origin list.

Pure-Go, CGO=0, zero dependencies (standard library only). It stores nothing: the browser holds the user's PAT and sends it on each request; the proxy relays it and forgets it (auth passthrough).

browser (wasm go-git) ──▶ gitcorsproxy ──▶ https://github.com/owner/repo.git/…
        Fetch + PAT          + CORS               real smart-HTTP

Route

The upstream host is carried as the first path segment; the rest is the git smart-HTTP endpoint verbatim:

GET  /<host>/<owner>/<repo>.git/info/refs?service=git-upload-pack
GET  /<host>/<owner>/<repo>.git/info/refs?service=git-receive-pack
POST /<host>/<owner>/<repo>.git/git-upload-pack
POST /<host>/<owner>/<repo>.git/git-receive-pack

is proxied to https://<host>/<owner>/<repo>.git/…. The <owner>/<repo> part may contain nested groups (Forgejo/GitLab subgroups). Anything that does not match this shape is rejected 400. info/refs must be a GET with a valid service query; the pack endpoints must be POST.

This is exactly the client contract of the go-tex playground's browsergit package, whose panel accepts a proxy-prefixed base URL like https://gitproxy.<host>/github.com/owner/repo.git.

CORS

  • Access-Control-Allow-Origin echoes the request Origin only when it is in the configured allow-list — it is never *, because the client's Authorization (a PAT) is forwarded upstream and a wildcard origin with credential-bearing requests would let any web page drive the user's token.
  • Access-Control-Allow-Methods: GET,POST,OPTIONS
  • Access-Control-Allow-Headers: Content-Type,Git-Protocol,Authorization
  • Access-Control-Expose-Headers: Content-Type,Content-Length
  • OPTIONS preflight is answered 204 with the headers above.

Request bodies and response bodies are streamed (never fully buffered — git packs are large). Content-Type (application/x-git-*), Git-Protocol, Authorization, Content-Encoding, Accept-Encoding and User-Agent are forwarded upstream; the relevant response headers and the upstream status code are preserved on the way back.

Security: SSRF guard + no token logging

  • An upstream-host allow-list is the primary control: only the exact hosts an operator lists (e.g. github.com, the sovereign Forgejo host) are reachable — everything else is 403.
  • As defence in depth the target host is resolved and every IP is checked against a private/loopback/link-local/multicast/unspecified/cloud-metadata/ IPv6-ULA deny-list (ported from the loom server's checkRemoteIP), so an allow-listed host that resolves into an internal range is still refused.
  • The Authorization header is never logged. Nothing in this package writes a token, a header dump, or a request body to the logger.

Library

proxy, err := gitcorsproxy.New(gitcorsproxy.Config{
    AllowedOrigins: []string{"https://go-tex.github.io"},
    UpstreamHosts:  []string{"github.com", "sources.example.net"},
})
if err != nil {
    log.Fatal(err)
}
log.Fatal(http.ListenAndServe(":8181", proxy))

*Proxy is an http.Handler, so it drops into any server or middleware chain.

Command

go run ./cmd/gitcorsproxy -origins https://go-tex.github.io -hosts github.com

Each flag falls back to an environment variable:

Flag Env Default Meaning
-listen GITCORSPROXY_LISTEN :8181 listen address
-origins GITCORSPROXY_ORIGINS — comma-separated allowed origins
-hosts GITCORSPROXY_HOSTS — comma-separated upstream host allow-list
-tls-cert GITCORSPROXY_TLS_CERT — optional TLS certificate file
-tls-key GITCORSPROXY_TLS_KEY — optional TLS key file
-rate GITCORSPROXY_RATE 120 requests per minute per client IP (0 disables)
-burst GITCORSPROXY_BURST 30 back-to-back request burst per client IP
-trusted-hops GITCORSPROXY_TRUSTED_HOPS 1 trusted reverse-proxy hops for X-Forwarded-For
-max-response-bytes GITCORSPROXY_MAX_RESPONSE_BYTES 1073741824 cap on one relayed response in bytes (0 unlimited)
-timeout GITCORSPROXY_TIMEOUT 5m0s per-request timeout (0 = none)

-origins and -hosts are required.

Abuse / DoS hardening

A browser CORS proxy is publicly reachable and cannot source-restrict — the playground runs in end-users' browsers, so the proxy cannot allow-list callers. It therefore defends itself:

  • Per-client-IP rate limiting. A concurrency-safe token bucket (a tiny internal implementation — the package keeps its zero-dependency posture) keyed on the real client IP. Over budget → 429 with a Retry-After header. Idle buckets are swept and the live set is capped, so a spray of forged IPs cannot grow memory without bound.
  • Trustworthy client IP. Behind Caddy the real client is the rightmost X-Forwarded-For entry Caddy appended — a client-forged leftmost entry is ignored. -trusted-hops N reads the (1+N)-th entry from the right of the [X-Forwarded-For…, RemoteAddr] chain (default 1 = one Caddy hop), falling back to the connection RemoteAddr when the header is absent or too short.
  • Response-size cap. -max-response-bytes bounds a single relayed response so the proxy cannot be used to shift unbounded bandwidth. A declared Content-Length over the cap is refused 502 before any body streams; an undeclared (chunked) body is truncated at the cap.
  • Per-request timeout. -timeout bounds the whole proxied request via a context deadline, so a slow-loris or hung upstream cannot pin resources.

None of these paths log the Authorization header or the token.

Deployment (behind the sovereign EU Caddy)

Run the proxy behind Caddy, which terminates Let's Encrypt TLS, so the two TLS flags stay unused in production:

gitproxy.example.net {
    reverse_proxy 127.0.0.1:8181
}
gitcorsproxy \
  -listen 127.0.0.1:8181 \
  -origins https://go-tex.github.io \
  -hosts github.com

The go-tex playground is then configured with a proxy-prefixed remote, e.g. https://gitproxy.example.net/github.com/owner/repo.git. The proxy is auth-passthrough: the browser sends the user's PAT on each request; the proxy relays it upstream and never stores it. Keep the allow-lists tight — one or two origins, one or two upstream hosts.

License

BSD-3-Clause — see LICENSE. Copyright (c) 2026, the go-browserhttp/gitcorsproxy authors.

Documentation

Overview

Package gitcorsproxy is a small, sovereign reverse proxy that lets a browser reach git smart-HTTP remotes that do not send CORS headers.

A browser-hosted git client (for example the go-tex playground's wasm git-worker, github.com/go-tex/go-tex.github.io playground/internal/browsergit) speaks the git smart-HTTP protocol over the Fetch API. GitHub and most ghcr-style hosts answer that protocol correctly but send no Access-Control-Allow-Origin, so the browser blocks the response. This proxy sits in front: the browser talks to it same-origin-friendly (with an explicit allowed-origin list), and the proxy streams the git bytes to and from the real upstream, adding the CORS headers the browser needs.

Route

The proxy accepts exactly the git smart-HTTP endpoint shape, with the upstream host carried as the first path segment:

GET  /<host>/<owner>/<repo>.git/info/refs?service=git-upload-pack
GET  /<host>/<owner>/<repo>.git/info/refs?service=git-receive-pack
POST /<host>/<owner>/<repo>.git/git-upload-pack
POST /<host>/<owner>/<repo>.git/git-receive-pack

and forwards them to https://<host>/<owner>/<repo>.git/… . The <owner>/<repo> portion may contain nested groups (Forgejo/GitLab subgroups). Any path that does not match this shape is rejected 400.

Security posture

  • CORS is scoped to a CONFIGURED explicit origin list. It is never "*", because the client's Authorization header (a PAT) is forwarded upstream; a wildcard origin with credential-bearing requests would let any web page drive the user's token.
  • An upstream-host ALLOWLIST is the primary SSRF control: only the exact hosts an operator lists (github.com, the sovereign Forgejo host, …) are reachable. Everything else is 403.
  • As defence in depth the target host is resolved and every IP is checked against a private/loopback/link-local/cloud-metadata denylist (ported from the loom server's checkRemoteIP), so an allowlisted host that resolves into an internal range is still refused.
  • The Authorization header is forwarded upstream but NEVER logged. Nothing in this package writes a token, a header dump, or a request body to the logger.
  • Because a browser CORS proxy is publicly reachable and cannot source-restrict, it defends itself against abuse/DoS: a per-client-IP token-bucket rate limit (429 + Retry-After), a cap on the size of a single relayed response, and a per-request timeout. The client IP is read from X-Forwarded-For counting from the right by a trusted-hops count, so a client-forged leftmost entry cannot spoof the limiter key. These paths never log the token either. See Config for the knobs; all default to off in the library (the command sets production defaults).

The proxy stores nothing: it is a pure auth-passthrough. The browser holds the user's PAT and sends it on each request; the proxy relays it and forgets it.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Config

type Config struct {
	// AllowedOrigins is the explicit list of browser origins permitted to read
	// proxied responses, e.g. []string{"https://go-tex.github.io"}. It must be
	// non-empty and must not contain "*" — see the package doc for why a
	// wildcard is refused when Authorization is forwarded.
	AllowedOrigins []string

	// UpstreamHosts is the allowlist of upstream hosts the proxy will reach,
	// matched case-insensitively against the first path segment exactly as it
	// appears in the URL (including any :port), e.g.
	// []string{"github.com", "sources.example.net"}. It must be non-empty.
	UpstreamHosts []string

	// UpstreamScheme is the scheme used to reach the upstream. It defaults to
	// "https" and should stay that way in production; tests set "http" to reach
	// a local httptest server.
	UpstreamScheme string

	// Logger receives request/decision logs. Defaults to slog.Default(). The
	// proxy never logs the Authorization header, any token, or a request body.
	Logger *slog.Logger

	// Transport performs the upstream round-trip. Defaults to an SSRF-agnostic
	// http.Transport with compression passthrough (DisableCompression) so git
	// bytes are relayed verbatim. Primarily an injection seam for tests; the
	// SSRF decision itself lives in the ServeHTTP pre-flight, not here.
	Transport http.RoundTripper

	// LookupIP resolves a host to its IPs for the SSRF pre-flight. Defaults to
	// net.LookupIP. Injectable for tests.
	LookupIP func(host string) ([]net.IP, error)

	// RatePerMinute is the sustained request budget per client IP. When it is
	// <= 0 rate limiting is disabled (the library default); the command sets a
	// production default. On exceed the proxy answers 429 with Retry-After.
	RatePerMinute int

	// Burst is the token-bucket ceiling: the number of requests a single client
	// IP may make back-to-back before the per-minute rate throttles it. When
	// <= 0 it falls back to RatePerMinute (a one-minute burst). Ignored when
	// rate limiting is disabled.
	Burst int

	// TrustedProxyHops is the number of trusted reverse proxies (Caddy, plus any
	// trusted CDN in front of it) between the client and this process. The real
	// client IP is read as the (1+hops)-th entry from the right of the
	// [X-Forwarded-For…, RemoteAddr] chain, so a client-forged LEFTMOST
	// X-Forwarded-For entry cannot spoof the limiter key. Default 0 means "trust
	// nothing, key on RemoteAddr"; the command defaults it to 1 for the
	// behind-Caddy deployment.
	TrustedProxyHops int

	// MaxResponseBytes caps the size of a single relayed upstream response so
	// the proxy cannot be used to shift unbounded bandwidth. 0 means unlimited
	// (the library default); the command sets a production default. A response
	// whose declared Content-Length exceeds the cap is refused 502 before any
	// body is streamed; an undeclared (chunked) body is truncated at the cap.
	MaxResponseBytes int64

	// Timeout bounds the whole proxied request (upstream round-trip plus body
	// relay) via a context deadline, so a slow-loris or hung upstream cannot pin
	// resources. 0 means no deadline (the library default); the command sets a
	// production default.
	Timeout time.Duration

	// Now is the limiter's clock. Defaults to time.Now; injectable for tests.
	Now func() time.Time

	// MaxTrackedIPs bounds the number of live per-IP buckets. Defaults to 65536.
	// Injectable for tests.
	MaxTrackedIPs int

	// IdleEvictAfter is how long a per-IP bucket may go unseen before it is
	// swept. Defaults to 10 minutes. Injectable for tests.
	IdleEvictAfter time.Duration
}

Config configures a Proxy. AllowedOrigins and UpstreamHosts are required; the rest have production-safe defaults.

type Proxy

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

Proxy is an http.Handler implementing the CORS git smart-HTTP proxy. It is safe for concurrent use.

func New

func New(cfg Config) (*Proxy, error)

New validates cfg and returns a ready Proxy. It errors when the required allowlists are empty or when AllowedOrigins contains a wildcard.

func (*Proxy) ServeHTTP

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

ServeHTTP routes, guards and proxies a single request.

Directories

Path Synopsis
cmd
gitcorsproxy command
Command gitcorsproxy runs the sovereign CORS git smart-HTTP proxy.
Command gitcorsproxy runs the sovereign CORS git smart-HTTP proxy.

Jump to

Keyboard shortcuts

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