localproxy — Guide & Tips

localproxy is a lightweight local HTTP proxy for browser-based tools — download, start, configuration and endpoints to fetch URLs without CORS.

localproxy is a lightweight, secure local HTTP proxy for browser-based online tools. It binds exclusively to 127.0.0.1 and lets your web applications fetch external URLs — safely bypassing the browser's CORS restrictions.

Guide

Requirements

  • A pre-built binary for your platform (Linux, macOS, Windows, FreeBSD, OpenBSD, NetBSD — common architectures each)
  • Alternatively, to build from source: Go 1.27+ (opens in a new tab)

Download the binary

Pre-built binaries for all platforms are available under Releases in the repository (e.g. localproxy-linux-amd64, localproxy-macos-apple-silicon, localproxy-windows-amd64.exe).

Since v1.2.0 the binaries are built with Go 1.27 and therefore require macOS 13 Ventura or later on macOS. On macOS 12 Monterey and older, stay on v1.1.1 (opens in a new tab) — it is functionally identical and built with Go 1.26.6. Everywhere else — on Linux, Windows and macOS 13 or later — use at least v1.2.4: it is built with Go 1.27.3, which brings a security fix for crypto/x509 (CVE-2026-111943): Go no longer trusts a small set of intermediate certificates, and certificate chains through one of them are no longer considered valid. Since the proxy verifies the certificate of every target, such a connection now ends as a tls_error; every older binary would have accepted it. On top of that come the fixes from Go 1.27.2, for crypto/tls and net/http among others, included since v1.2.3. v1.1.1 lacks both: on the 1.26 line the fixes only arrived with Go 1.26.9 and 1.26.10, and there is no rebuilt 1.1.x release.

For the JPKCom Tools, use v1.3.0 (built with the same Go 1.27.3 as v1.2.4). Their frontend names v1.3.0 as the minimum version and shows a notice for an older binary, but does not block it. An older version therefore keeps working, except for redirects: there /proxy still passes the Location header to the browser, which follows the 3xx itself. A redirect to another site then usually fails at the CORS preflight ("Failed to fetch"). If the target lets the preflight through, though, your X-Proxy-Token goes to the target along with it — only v1.3.0 closes that gap.

Make it executable (macOS / Linux)

chmod +x ./localproxy-macos-apple-silicon
# macOS: clear the Gatekeeper warning for the unsigned binary:
xattr -dr com.apple.quarantine ./localproxy-macos-apple-silicon

Start it

In the simplest case, with no further configuration:

./localproxy

On startup a banner shows the key values: the address (http://127.0.0.1:PORT — a random free port without --port), the token regenerated on every start, the DNS servers in use and the allowed origins.

Configuration

--port            int     TCP port to listen on (0 = random free port)          [default: 0]
--origin          string  Allowed origins, comma-separated
--timeout         int     Upstream request timeout in seconds                    [default: 30]
--max-mb          int64   Maximum upstream response size in MB (0 = unlimited)   [default: 50]
--dns             string  DNS servers, comma-separated, or "system" for OS DNS   [default: "1.1.1.1,8.8.8.8"]
--allow-any-host          Accept a non-localhost Host header (not recommended)
--version                 Print version information and exit

--port, --timeout and --max-mb are validated at startup — an out-of-range value aborts with a message instead of failing later in a confusing way.

Examples:

Terminal
# Production: allow only one specific tool
./localproxy --origin https://yourtool.example.com

# Custom DNS servers (Quad9 + Cloudflare)
./localproxy --dns "9.9.9.9,1.1.1.1"

# Use the operating system's DNS resolver
./localproxy --dns system

By default localproxy resolves names via Cloudflare (1.1.1.1) and Google (8.8.8.8) — independent of the host's DNS configuration, which keeps results consistent across machines. If the --dns value omits a port, :53 is appended automatically.

--allow-any-host

By default localproxy only answers requests whose Host header names the loopback interface (127.0.0.0/8, localhost or [::1], with any port). This blocks DNS rebinding: without the check, a web page could point a hostname it controls at 127.0.0.1 and address the proxy from your browser.

You only need --allow-any-host if you reach the proxy under some other local name — through a container alias, for example. Prefer --origin over disabling the check.

Endpoints

Endpoint Auth Description
GET/POST/HEAD /proxy?url=... yes (X-Proxy-Token) Forward the request to the target URL (body streaming, metadata headers); &follow=1 (since 1.3.0, GET/HEAD only): the proxy follows redirects itself
GET /inspect?url=... yes Connection metadata as JSON (SSL, timing, IP, headers, http3); &body=1 includes the body; since 1.3.0 &get=1 (headers via GET instead of HEAD) and &diag=1 (failures inline with certificate details)
GET /page?url=... yes Full page analysis: redirect chain + body + SSL + timing + http3 in one JSON; since 1.3.0 &diag=1
`OPTIONS /proxy /inspect /page`
GET /ping no Health check (returns localproxy ok)
GET /version no Version info as JSON

Integrating it in a tool

  1. The user enters the address (http://127.0.0.1:PORT) and token in the online tool.
  2. The tool verifies the connection via /ping.
  3. All further requests go through /proxy?url=... with the X-Proxy-Token header. Example:
const response = await fetch(
  `${PROXY_BASE}/proxy?url=${encodeURIComponent(targetUrl)}&follow=1`,
  { headers: { "X-Proxy-Token": PROXY_TOKEN } }
);

Every /proxy response also carries upstream metadata as headers: X-Upstream-Protocol, X-Upstream-IP, X-Upstream-Timing (e.g. dns=12;tcp=45;ssl=23;ttfb=156;total=234), X-Upstream-Content-Encoding, X-Upstream-Content-Length and — only when the body exceeded the --max-mb limit and was cut short — X-Upstream-Truncated: 1. Since 1.3.0 this also applies to a compressed body without Content-Length, which the proxy used to cut silently; only an uncompressed body without Content-Length still lacks the header, because the headers are already sent when the cap hits. Up to 1.2.4, X-Upstream-IP could name the address of a losing connection attempt under Happy Eyeballs. All upstream headers are readable from JavaScript.

Redirects: Since 1.3.0 /proxy never passes the target's Location header to the browser. fetch() would otherwise have followed it straight to the target, sending X-Proxy-Token to a third party whenever that party's CORS policy let the preflight through. A 3xx therefore arrives as a plain response; the resolved, absolute target (http/https only, userinfo dropped) is in X-Upstream-Location. With &follow=1 the proxy follows it itself: on 301, 302, 303, 307 and 308, up to 5 redirects, with all hops sharing one --timeout. GET and HEAD keep their method; POST is never followed. Every target passes the SSRF check, and a private one ends with 403 blocked_target. If Location is missing, unusable, not http/https, or points to a URL already visited, the 3xx is returned unchanged — and so is the sixth. With follow=1 the response also carries X-Upstream-Final-URL (where the content came from) and X-Upstream-Redirects (how many redirects were followed, 0 if none); all other metadata headers then describe the last hop.

Errors are machine-readable: All 4xx responses carry CORS headers and a JSON body with a machine-readable code (bad_request, forbidden, blocked_target, method_not_allowed) — so a wrong token is distinguishable from an unreachable proxy. Only rejected origins get a bare 403 with no CORS headers, by design. When the target fails, /proxy and /inspect (without diag=1) answer with 502 or 504 and dns_error, tls_error, connection_error, upstream_error or timeout; /page and /inspect?diag=1 deliver the same error in the error field of an HTTP 200 response instead. Since 1.3.0 every upstream failure also carries type, a human-readable class with the texts of the JPKCom Tools' PHP proxy (e.g. Certificate expired, Could not resolve host). Also since 1.3.0, every timeout is a timeout with 504 — including timeouts while connecting, in the TLS handshake, while waiting for the response headers and in the DNS lookup, which used to arrive as connection_error, upstream_error or dns_error with 502. Since 1.2.1 tls_error covers every TLS failure — not just certificate problems but failed handshakes too (server stuck on TLS 1.0/1.1, no shared cipher suite, a peer alert, a target that does not speak TLS at all). Before, these cases ended up as upstream_error or connection_error and could not be told apart from an unreachable host. One gap remained up to 1.2.4: a plain-HTTP server behind an https:// URL still produced upstream_error; since 1.3.0 that is a tls_error as well (TLS protocol not supported). Also only since 1.3.0 are the specific certificate messages ("TLS certificate hostname mismatch" etc.) reachable — before, every one read "TLS certificate verification failed".

These headers never reach the target: Cookie, Cookie2, Authorization, Proxy-Authorization, Origin, Referer and Sec-Fetch-* are never forwarded, so the caller's credentials and identity signals do not end up at the target server. Headers you set deliberately — Accept, User-Agent, Accept-Language, custom X-* — still pass through.

Tips & Tricks

  • Restrict origins in production: Without --origin all origins are allowed. For production use, always allow the specific tool via --origin https://yourtool.example.com.
  • HTTPS page calling HTTP localhost — no problem: Browsers treat http://127.0.0.1 as a "potentially trustworthy origin" (W3C Secure Contexts). Requests from an HTTPS page to the local proxy are not blocked as mixed content; Chrome's Private Network Access preflight is answered automatically.
  • /page saves round-trips: The endpoint traces the redirect chain (up to 20 redirects), downloads the body, inspects SSL and measures timing in a single JSON — replacing multiple /proxy and /inspect calls. If the 21st request redirects again, the chain stops with too_many_redirects since 1.3.0; before, it was fetched as the final page. Likewise, since 1.3.0 an unusable Location ends the chain (bad_request, or blocked_target for a non-http(s) target) instead of the redirecting URL being fetched again as the final page. Since 1.3.0 error.error names the real code of the failed hop (tls_error, dns_error, timeout, redirect_loop …, with status 504 for timeout) instead of a blanket upstream_error, and ip names the server that failed. Since 1.3.0 hops also carry method (except error-only entries that make no request of their own), location on a 3xx, errorCategory on a failure (plus errorType for a tls_error) and, on https, a certificate summary (certCN, certExpiry, certDaysLeft); rawHeaders starts with the status line (HTTP/2 200) and lists the headers sorted by name — before, the order changed from call to call. Since 1.1.0 it derives its whole time budget from the client's request context: if the client disconnects, the server-side work stops too. Redirect hops and encoding probes previously ran on context.Background(), so 20 slow redirects could keep a handler alive for minutes.
  • SSL details the browser won't give you: /inspect and /page return certificate information (version, issuer, validity, SANs including IP SANs, chain) that the Fetch API can't access. Since 1.3.0 the report (ssl) has the same content as that of the JPKCom Tools' PHP proxy: the chain (at most 10 certificates) with label, signature check, fingerprints and PEM per certificate, the host-name check hostMatch, a list of issues (among them expired, expires within 14 days, host not covered, self-signed, weak signature algorithm, RSA below 2048 bit, missing intermediate) and protocol and cipher in OpenSSL notation. Since 1.3.0 daysLeft is floored and negative for an expired certificate.
  • Examining broken certificates with diag=1: Normally a failed TLS setup yields only the error. With diag=1 (since 1.3.0) the proxy opens a separate, handshake-only connection after a tls_error and reads the server's chain, protocol and cipher — also for servers that are expired, self-signed or stuck on TLS 1.0. This probe is the only place without certificate verification: it goes to the IP the failed request had already connected to, through the same SSRF guard, is bounded to 10 s and carries no application data. The verdict (verified, error, errorType) still comes from the verified connection. Servers Go cannot handshake with even without verification (SSLv3, RSA keys below 1024 bit, among others) cannot be described by the probe either. Send internationalised host names in punycode (new URL(u).href); otherwise hostMatch is null and the probe sends no SNI.
  • HEAD, GET and get=1: Without body=1, /inspect sends only a HEAD request upstream (faster, no body download). If the server refuses HEAD with 405 or 501, /inspect asks again with GET since 1.3.0, and so does every /page hop; the body is closed unread. With get=1, /inspect takes the headers from a GET right away — the better choice for a security-header check, because some servers, www.google.com for example, send Strict-Transport-Security only on GET. method tells which request the response describes.
  • Detect HTTP/3 without speaking HTTP/3: Since version 1.1.0 /inspect and /page return an http3 field. It answers "does this target support HTTP/3?" — not "was this request made over HTTP/3?". The answer comes from the target's Alt-Svc field (RFC 7838, ALPN token h3 per RFC 9114), which is exactly how browsers discover HTTP/3 as well: over TCP. No QUIC stack is involved. Draft tokens like h3-29 are ignored, and the raw Alt-Svc field stays available in the headers map. protocol still reports what this one request actually used — the combination {"protocol": "HTTP/2.0", "http3": true} is the normal case, not a contradiction.
  • Security defaults: 48-character session token regenerated on each start, SSRF protection, TLS verification on every connection that carries HTTP data (the only exception: the handshake probe of diag=1), the HTTP client follows no redirect by itself and since 1.3.0 Location never reaches the browser — redirects are followed only by /proxy?follow=1 (max. 5) and /page (max. 20), every hop http/https only and SSRF-checked, response size capped via --max-mb, constant-time token comparison, X-Content-Type-Options: nosniff on every proxy-generated response.
  • SSRF protection now enforced in the dialer: Since 1.1.0 the target address is checked after DNS resolution and before connect. The earlier pre-check resolved the name separately from the HTTP client, so an attacker-controlled zone with a short TTL could answer with a public address for the check and a loopback address for the connection. The pre-check remains as a fast-fail path and is bounded to 5 s. The blocklist now also covers reserved IPv4 space (0/8, 192.0.0.0/24, 198.18/15, multicast, 240/4) and the IPv6 forms that embed an IPv4 address — IPv4-mapped, NAT64 (64:ff9b::/96) and 6to4 (2002::/16). 64:ff9b::7f00:1 reaches 127.0.0.1 on a NAT64 network and was invisible to a v4-only check before.
  • Concurrency & compression: The proxy handles requests concurrently (one goroutine per connection); each upstream request uses a fresh connection for accurate timing. Brotli, gzip and deflate pass through transparently on /proxy — the browser handles decompression. On /inspect with body=1 and on /page the proxy decompresses itself; since 1.1.0 Content-Encoding: deflate handles both zlib-wrapped data (RFC 1950) and raw DEFLATE (RFC 1951) — the zlib variant, which a large share of servers send, previously came back empty or corrupt. Since 1.3.0 no binary data ends up in body or html: an encoding Go cannot decode (br, zstd, compress …), stacked encodings (gzip, br) or a corrupt stream set decodeError: true, and only what could be decoded is returned — before, the raw compressed bytes were there. Since 1.3.0 a truncated body ends on a UTF-8 character boundary and carries truncated: true.
  • Build from source: With Go 1.27+ via go build -o localproxy . (releases are built with Go 1.27.3); cross-compile with GOOS/GOARCH (e.g. GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w" -trimpath -o localproxy.exe .). A Git tag (git tag v1.3.0 && git push origin v1.3.0) triggers cross-platform release builds automatically via GitHub Actions.
  • Tests & linting: go test -race ./... for the race detector; the project is linted with staticcheck (opens in a new tab), plus govulncheck and a gofmt check (CI blocks the release on any finding). Since 1.1.0 CI runs on every push and pull request, not only on tags — a broken test no longer surfaces first at release time. staticcheck is dev tooling only and never ships with the binary. Build it locally, pinned, in a throwaway module: d=$(mktemp -d) && cd "$d" && go mod init staticcheck-build && go get honnef.co/go/tools/cmd/staticcheck@v0.8.1 golang.org/x/tools@v0.51.0 && go install honnef.co/go/tools/cmd/staticcheck && cd - && rm -rf "$d". A plain go install …@v0.8.1 is not enough on Go 1.27.2 and later: staticcheck v0.8.1 (2026.2.1) still requires golang.org/x/tools v0.44, which reads export data only up to version 4, while Go 1.27.2 and later write version 5 — every stdlib import then aborts with export data version 5 is greater than maximum supported version 4. go install pkg@version cannot override a dependency, hence the throwaway module; CI builds it the same way. Once a staticcheck release requires x/tools v0.51.0 or newer, a plain go install works again.

Further reading