Skip to content

Repository files navigation

secproxy

A small egress proxy that lets a sandboxed program use secrets it never holds.

The program gets a placeholder like sp_github_k7f3m9q2x8w4v1n6b0c5z2jd instead of a real token. The proxy sits between the sandbox and the internet. When the program sends the placeholder to the right host, in the right header, with an allowed method and path, the proxy swaps in the real value. In every other case the request is refused with a message that says why.

sandbox                          secproxy                                  internet
GITHUB_TOKEN=sp_github_…         holds the real token
curl api.github.com ──────────▶  not listed?            ─▶ refused
                                 on the egress list?    ─▶ raw TLS passed through, untouched
                                 has a secret bound?    ─▶ decrypt with our own CA
                                                           method + path allowed?
                                                           Authorization == "Bearer sp_github_…"?
                                                           swap, forward over fresh verified TLS ─▶ api.github.com
                                                           scrub the real value out of the response
                                                           one JSON line to the audit log

What it is and is not

It is one static binary, a YAML file, and a certificate. Secrets come from environment variables or files. There is no vault, no server, no account, no UI, and nothing specific to any cloud or sandbox product.

It is not a sandbox. The proxy only helps if the sandboxed program has no other route to the internet. Enforcing that is the job of whatever runs the sandbox: a firewall rule, a network namespace, a cloud sandbox's egress allowlist, or a relay that captures all port-443 traffic. Setting HTTPS_PROXY in the sandbox is a courtesy to well-behaved programs, not a boundary.

It also does not stop the sandbox from using a secret it is allowed to use. If the GitHub token can delete repositories and the binding allows DELETE, the sandbox can delete repositories without ever seeing the token. Method and path rules on bindings are how you narrow that.

What it assumes about the other end

The proxy keeps a hostile sandbox from reading a secret. It assumes the upstream is the real service and is not trying to hand the secret back. Response scrubbing catches the literal value in plaintext headers and bodies (gzip is decompressed first; other encodings are refused) and in WebSocket frames. It cannot catch a value the server transforms before echoing: base64, URL-encoding, a hash, a derived session token, a cookie. Bind secrets to the hosts that own them, keep method and path rules tight, and treat the scrubber as a safety net for careless servers, not as a guarantee.

What is explicitly outside the model

  • Passthrough is name-level, not tenant-level. For an egress host the proxy checks the hostname (from SNI, and on the CONNECT listener the SNI must also match the CONNECT line) and then copies encrypted bytes. It cannot see the HTTP Host header inside. On a shared CDN or reverse proxy, a connection that presents an allowed name could carry requests the CDN routes to another tenant. If that matters for a host, do not put it on egress; bind a secret to it so it is inspected, where the Host header is checked on every request.
  • secproxy run is not a sandbox. The child runs as the same user as the proxy, so it can read any file the proxy can: from_file secrets, a persist_dir CA key, your shell history. run hides the proxy's environment variables from the child as a courtesy, nothing more. Real isolation means the proxy and its files live outside the sandbox's filesystem and network view, which is how the sidecar deployments work.
  • A hostile upstream wins. As above: the scrubber catches literal echoes, not transformations. A split-across-frames WebSocket value or a value straddling a chunk boundary in a very large frame is not caught.
  • Denial of service by the sandbox is not prevented. Connection counts are capped, but a sandbox can hold its own proxy's connections open. The proxy is meant to be per sandbox, so the only victim is the attacker.

Two front doors

  • Transparent (listen.transparent: ":443"). Accepts raw TLS. The destination is read from the SNI in the ClientHello. Use this behind a network relay that delivers all of the sandbox's HTTPS traffic to the proxy; the program in the sandbox does not need to know a proxy exists.
  • Connect (listen.connect: ":8080"). An ordinary HTTP proxy for programs that honour HTTPS_PROXY. CONNECT host:443 is brokered; plain http:// requests are forwarded only for egress hosts.

Both lead to the same decision per host: refuse, pass through, or inspect.

Config

See examples/secproxy.yaml. The short version:

version: 1
listen: { transparent: ":443", connect: ":8080" }
ca: { cert_out: /run/secproxy/ca.pem }
unmatched_host: deny

egress: [pypi.org, files.pythonhosted.org, registry.npmjs.org]

secrets:
  github: { from_env: SP_GITHUB_TOKEN, env: GITHUB_TOKEN }

bindings:
  - secret: github
    hosts: [api.github.com]
    slot: { header: Authorization, format: "Bearer {secret}" }
    methods: [GET, POST, PATCH]
    paths: ["/repos/my-org/**"]

Rules that keep the model honest:

  • A host is either an egress host or a binding host, never both.
  • Substitution happens only when the placeholder is the entire value in the declared slot. A placeholder anywhere else in a request to an inspected host is a 403 with an explanation.
  • A binding can opt out of needing the placeholder with inject: always: every request to that host gets the secret, whether or not the program sent the placeholder. If the slot already holds some other value the request is refused rather than quietly rewritten. This is for programs that cannot be handed a placeholder (they read credentials from their own config, or check the token's shape before sending). With it the sandbox need not hold the placeholder at all, so env can be left off the secret.
  • Unknown hosts are refused unless unmatched_host: passthrough. Unmatched hosts are never inspected.
  • Missing secret at startup means the proxy does not start.
  • Config typos are errors, not silently ignored keys.

Install

go install github.com/taylorai/secproxy/cmd/secproxy@latest

For a Linux container from a Mac:

GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go install github.com/taylorai/secproxy/cmd/secproxy@latest
# binary lands in ~/go/bin/linux_amd64/secproxy

make linux does the same from a checkout into dist/. The Dockerfile builds a distroless image with just the binary.

Where the CA comes from

ca:
  cert_out: /run/secproxy/ca.pem       # public cert, written at startup; optional
  persist_dir: ~/.config/secproxy/ca   # laptop: keep one CA across restarts; optional
  key_env: SECPROXY_CA_KEY             # orchestrator-supplied PEM; optional, with cert_env
  cert_env: SECPROXY_CA_CERT

By default the proxy mints a new root in memory every start and the private key never touches disk. That is right for a sidecar that lives and dies with one run. On a laptop it means re-trusting the certificate after every restart, so set persist_dir: the first start writes ca.pem and ca.key (mode 600) there, valid for a year, and later starts reuse them. It also keeps an empty ca.lock file for process coordination; leave that file in place. Persistent mode uses kernel file locks and is supported on macOS, Linux, the BSDs, and illumos; elsewhere, use an ephemeral or orchestrator-supplied CA. Trust ca.pem once. On macOS, Go programs such as gh ignore SSL_CERT_FILE and consult the keychain, so:

security add-trusted-cert -r trustRoot -k ~/Library/Keychains/login.keychain-db ~/.config/secproxy/ca/ca.pem

key_env/cert_env is the third option, for an orchestrator that wants to hand the certificate to the sandbox before the proxy starts. Set only one of persist_dir and key_env.

Commands

secproxy check --config cfg.yaml            validate and print the effective policy
secproxy serve --config cfg.yaml            run
secproxy env   --config cfg.yaml            print the env the sandbox should get
secproxy run   --config cfg.yaml -- cmd …   laptop mode: proxy on loopback, run cmd with that env

env prints the placeholders under their configured names, the HTTPS_PROXY family (when --proxy-url is given), and the certificate variables (SSL_CERT_FILE, NODE_EXTRA_CA_CERTS, REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE, GIT_SSL_CAINFO, DENO_CERT) pointing at --ca-path. Install the certificate into the sandbox's system trust store as well; some programs read none of those variables.

Audit log

One JSON line per decision on stdout. Never header values, query strings, bodies, placeholders, or secrets. bindings lists every binding that substituted a secret; automatic is the subset that did so without a placeholder because of inject: always.

{"ts":"","run_id":"r1","decision":"inject","host":"api.github.com","method":"GET","path":"/user","bindings":["github#0"],"status":200,"ms":143}
{"ts":"","run_id":"r1","decision":"inject","host":"api.github.com","method":"GET","path":"/user","bindings":["github#0"],"automatic":["github#0"],"status":200,"ms":97}
{"ts":"","run_id":"r1","decision":"deny","host":"api.github.com","method":"DELETE","path":"/repos/x/y","reason":"method_not_allowed"}
{"ts":"","run_id":"r1","decision":"deny","host":"evil.example","reason":"unmatched_host"}

A tour of the pieces, for someone who doesn't write Go

You only need to know two things going in. First, HTTPS is HTTP wrapped in TLS: the request is encrypted between the program and the server, so anything sitting in between sees only noise. Second, the only way for something in between to read or change an HTTPS request is to be the server as far as the program is concerned: present a certificate the program trusts, decrypt, do its work, then open its own, separate HTTPS connection to the real server. That is what "inspecting" a host means below. Everything else follows from those two facts.

Here is what happens to one request, and which piece does each step.

1. The program in the sandbox makes a request

It has GITHUB_TOKEN=sp_github_k7f3… in its environment (a placeholder, not a real token) and runs, say, gh api /user. That tool opens a TLS connection to api.github.com. The traffic has to end up at the proxy; how that happens is the job of whoever set up the sandbox, and there are two ways.

childenv (internal/childenv) is the list of environment variables a well-behaved program reads to find a proxy and to trust a certificate: HTTPS_PROXY, SSL_CERT_FILE, NODE_EXTRA_CA_CERTS, and a handful more, because every language runtime picked its own name. secproxy env prints them. This is the cooperative route; a program can ignore these variables, so it is not a security boundary, only a convenience.

The other route is a network rule outside the sandbox that grabs all port-443 traffic and delivers it to the proxy whether the program likes it or not. That is the real boundary, and it is the sandbox platform's job, not this tool's.

2. The proxy accepts the connection: two front doors

Depending on which route the traffic took, it arrives in one of two shapes.

The connect listener is what a proxy-aware program talks to. It sends a line like CONNECT api.github.com:443 first, in plain text, asking the proxy to open a tunnel. So the proxy knows the destination before any TLS happens. It answers "200 OK" and from then on the program sends TLS through the tunnel.

The transparent listener is what the network-rule route delivers. Here the proxy receives raw TLS bytes with no introduction. It still needs to know where the program was going, so it uses SNI peek. SNI (Server Name Indication) is a field in the very first TLS message, the ClientHello, where the program says "I want to talk to api.github.com" in plain text so that shared servers know which certificate to present. The peek reads just that first message, pulls out the name, and then rewinds so the bytes can be replayed to whatever comes next as if nothing had been read. Go's own TLS library does the parsing; we just stop it after the first message.

Both doors end at the same place: a hostname and a decision.

3. The decision: deny, pass through, or inspect

policy (internal/policy) answers: given this hostname, what now? It reads the compiled config. Three answers:

  • Deny. The host is not listed anywhere. Connection closed. On the connect listener the program gets a readable 403 first.
  • Passthrough. The host is on the egress list (pypi.org, the npm registry, and so on). The proxy dials the real host and copies bytes in both directions without decrypting anything. This is the passthrough splice: two pipes, one each way, and when either side hangs up both are closed. No secret can be involved because the proxy never sees the plaintext, and the program sees the real server's own certificate, so nothing in the sandbox needs to trust ours for these hosts.
  • Inspect. The host has a secret bound to it. Now the proxy has to be the server.

4. Inspecting: the CA and the one-shot TLS listener

To decrypt, the proxy needs a certificate for api.github.com that the program will accept. Real certificates come from public authorities we don't control, so the proxy runs its own tiny authority.

ca (internal/ca) generates a root certificate and private key in memory when the proxy starts. The public half is written to a file so the orchestrator can hand it to the sandbox to trust; the private half is never written anywhere. When a request for api.github.com arrives, the CA mints a certificate for that exact name, signed by the root, and caches it so the second request is instant. Certificates expire in a day and can never outlive the root, which itself expires in a week; the whole thing is disposable per run. The exception is ca.persist_dir, for a proxy on your own machine: there the root is kept on disk for a year so you trust it once rather than after every restart.

The one-shot TLS listener is a Go detail that needs a sentence. Go's HTTP server is built to accept many connections from a socket. Here we have exactly one already-open connection that we just decrypted, and we want the standard HTTP server to parse requests on it. So we wrap that one connection in something that looks like a socket, hands over the connection once, and then never produces another. It lets us reuse Go's well-tested HTTP parsing instead of writing our own.

5. Forwarding: the policy engine does its real work

Now the proxy has a decrypted HTTP request and the request forwarding code (internal/proxy/forward.go) runs the checks in a fixed order:

  1. Build the audit log line now, from the request as sent, before any secret is added. That way the log structurally cannot contain one.
  2. Refuse TRACE, an HTTP method whose entire purpose is to echo the request headers back, which would echo the injected secret.
  3. Check that the request's own Host header agrees with where the connection was opened. Without this a program could open a tunnel to api.github.com, get the GitHub token injected, and address the request to some other site hosted on the same server.
  4. Hand the request to policy.Apply. It looks at each binding for this host. If the placeholder is the entire value in the declared slot (say, Authorization: Bearer sp_github_k7f3…) and the method and path are allowed, it swaps in the real token. A binding marked inject: always also fires when the slot is empty, and refuses the request if the slot holds something else, so it never overwrites a credential the program chose to send. Then it scans the whole request for any placeholder that survived. Finding one means the program put it somewhere it isn't allowed, and the request is refused with a message naming the secret, where it was found, and where it belongs. Coding agents read error messages, so this turns a policy violation into something the agent can fix.
  5. Open a fresh HTTPS connection to the real api.github.com, verifying its certificate normally, and send the rewritten request. Headers that only make sense between a program and its proxy are dropped. Accept-Encoding is dropped too, so the server replies uncompressed, for the next step.
  6. Stream the response back, through the scrubber.

WebSocket relay is a variant of the same. A WebSocket starts life as an ordinary HTTP request with an Upgrade: websocket header; the server answers "101 Switching Protocols" and from then on it is a two-way stream of frames. Some tools use this to talk to model APIs. The proxy runs the handshake through the same checks, so a placeholder in the handshake's Authorization header is swapped, then on the 101 it splices the two connections like passthrough. Frames after that are not inspected.

Plain-HTTP forwarding is the last shape: a program asking the connect listener for an http:// (not https://) URL. Only egress hosts are allowed, and a host that carries a secret is refused with "use https", so a credential never crosses the network unencrypted.

6. Netguard: where the proxy is allowed to dial

Every outgoing connection the proxy makes goes through netguard (internal/netguard). It answers a different question from the policy: not "is this hostname allowed" but "is the address behind this hostname somewhere the proxy should ever connect to".

The threat. The proxy sits in a better network position than the sandbox. It can usually reach the machine it runs on, the private network around it, and on any cloud VM the metadata service at 169.254.169.254, which hands the instance's own cloud credentials to whoever asks. The sandbox cannot reach any of that directly; its only route out is the proxy. So if the sandbox can make the proxy connect to an address of its choosing, the proxy becomes a tunnel into the internal network. This is server-side request forgery, and it is the standard way proxies get turned against their owners.

Why it does not need a compromised resolver. The sandbox never names an address, only a hostname, but anyone can register a domain and point it at 127.0.0.1 or 169.254.169.254. Public DNS serves such records without complaint; services like nip.io exist to do exactly that on demand. Your own honest resolver faithfully returns the attacker's answer. All it takes is a way to get an untrusted name past the policy: unmatched_host: passthrough, a wildcard egress entry on a domain where strangers can create subdomains, or a trusted host whose DNS is later changed.

The defence. Netguard is a dialer with a list of forbidden address ranges: loopback, link-local, multicast, the RFC 1918 private ranges and their IPv6 equivalents, the cloud metadata addresses, and reserved or documentation space that nothing legitimate lives in. It resolves the hostname, checks every answer, refuses if any one is forbidden, and then connects to the exact address it checked rather than resolving again. That last step matters: a zone with a very short TTL can answer a public address for the check and a private one for the connection, and using one answer for both closes that gap. Addresses that wrap an IPv4 address inside IPv6 (NAT64, 6to4) are unwrapped and judged by what they would reach.

With a strict egress list of hosts you trust, netguard rarely fires. It is there for the day someone widens the list on a cloud machine, so the result is a refused connection and an audit line rather than stolen instance credentials. --allow-private-upstreams opens the private ranges for deliberately reaching an internal service; loopback and metadata stay blocked regardless.

7. The streaming response scrubber

Servers sometimes echo a credential back: in an error message, in a redirect, in a debug page. If that happened the sandbox would see the real token. So every byte of the response, headers and body, passes through a filter that replaces the real value with the placeholder. The tricky bit is that the body arrives in chunks and a token could be split across two of them, so the filter holds back only as many trailing bytes as could be the start of a match, and lets everything else through immediately. That keeps streamed responses (an LLM writing token by token) arriving as they are produced.

8. Audit

audit (internal/audit) writes one JSON line per decision: time, host, method, path, what was decided and why, which binding was used, status, bytes, milliseconds. Never a header value, a query string, a body, a placeholder, or a secret. It goes to standard output so whatever runs the proxy can capture it.

The commands

  • secproxy check loads the config, resolves every secret from the environment or files, refuses if any is missing (a proxy that starts without a secret it was told about would forward placeholders to real servers, which is a confusing failure), and prints the policy it would enforce, in one line per host.
  • secproxy serve does the same and then opens the listeners.
  • secproxy env prints the environment variables for the sandbox.
  • secproxy run -- some-command is for a laptop: it starts the connect listener on the local machine, writes the CA certificate to a temporary file, runs the command with all the right variables set, and exits with the command's exit code. Cooperative only, since nothing stops the command from ignoring the proxy; useful for trying things out.

Layout

cmd/secproxy        CLI
internal/config     config file: types, validation, secret resolution
internal/policy     per-host decision, per-request substitution and rejection
internal/ca         in-memory certificate authority, per-host leaf certs
internal/proxy      listeners, TLS termination, forwarding, passthrough, scrubbing
internal/netguard   upstream dialer that refuses private and metadata addresses
internal/audit      JSON log lines
internal/childenv   the env vars a sandboxed program needs
docs/research       notes on the projects this borrows from

License and credits

Apache License 2.0. See LICENSE.

Designs, and in a few places small pieces of code, come from Infisical's CLI agent proxy and Agent Vault (MIT), inflightsec's agent-vault-proxy (Apache-2.0), and stashbase's agent-proxy (MIT). Each borrowed piece is credited in a comment where it lives and listed in NOTICE. Apoxy CLRK (AGPL) was read for reference only; nothing from it was used. Research notes are in docs/research/.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages