From ee56929486e4783c95c40d698d0b98d45837d5ed Mon Sep 17 00:00:00 2001 From: Paavo Pokkinen Date: Wed, 23 Sep 2026 14:44:19 +0300 Subject: [PATCH 1/2] docs(examples): add curl proxy-tool example for GCP observability APIs Proxy tools landed in #8 without an example under examples/. This adds one for Cloud Trace, Monitoring (MQL, PromQL, dashboards) and Logging, the REST APIs gcloud doesn't fully cover. The routes pin every path to one project and allow read verbs only. The monitoring route also denies notificationChannels and uptimeCheckConfigs: those objects can hold third-party credentials, and the redactor only knows the declared secrets. The header comment says the SA's IAM roles are the real boundary. Two reasons: - Google front ends honor X-HTTP-Method-Override on GET and POST, so a method rule doesn't stop a write on that path. Verified against logging.googleapis.com: GET entries:list with the override set to POST returns 403 (routed) instead of 404. - entries:list names its projects in the body, which the proxy doesn't inspect. --- README.md | 2 + examples/gcp-observability-curl.toml | 143 +++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 examples/gcp-observability-curl.toml diff --git a/README.md b/README.md index f1fab7d..123c78d 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,8 @@ The agent then uses ordinary URLs from the API docs: airlock exec -- curl -s https://run.googleapis.com/v2/projects/my-project/locations/-/services ``` +A complete, read-only setup for Cloud Trace, Monitoring (including PromQL) and Logging is in [`examples/gcp-observability-curl.toml`](examples/gcp-observability-curl.toml). + For each `airlock exec` of a proxy tool, the daemon: 1. Starts a proxy on a random loopback port. diff --git a/examples/gcp-observability-curl.toml b/examples/gcp-observability-curl.toml new file mode 100644 index 0000000..adec23a --- /dev/null +++ b/examples/gcp-observability-curl.toml @@ -0,0 +1,143 @@ +# curl as a proxy tool for Google Cloud observability APIs +# +# Trace, Monitoring (including PromQL) and Logging queries have REST APIs +# that gcloud covers only partly or not at all, so the agent needs a raw HTTP +# client. A curl with a token in its env could send that token anywhere, so +# here curl is a *proxy tool*: it gets no secret. Its only network path is a +# per-exec proxy inside the daemon. The proxy attaches `Authorization` after +# the request has left curl, for the routes below only, and redacts the +# response before curl sees it. +# +# One-time setup, as yourself. The token-minting half is the same as in +# gcp-impersonation.toml; only the roles differ: +# +# gcloud iam service-accounts create agent-observability --project my-project +# +# for role in roles/cloudtrace.user roles/monitoring.viewer roles/logging.viewer; do +# gcloud projects add-iam-policy-binding my-project \ +# --member=serviceAccount:agent-observability@my-project.iam.gserviceaccount.com \ +# --role=$role +# done +# +# gcloud iam service-accounts add-iam-policy-binding \ +# agent-observability@my-project.iam.gserviceaccount.com \ +# --member=user:you@example.com \ +# --role=roles/iam.serviceAccountTokenCreator +# +# Start the daemon: +# +# airlock daemon start +# +# Use it with plain https:// URLs and no Authorization header. A header the +# agent sets is replaced, and the agent has no token to put in it anyway: +# +# airlock exec -- curl -s 'https://cloudtrace.googleapis.com/v1/projects/my-project/traces?pageSize=5' +# +# airlock exec -- curl -s https://monitoring.googleapis.com/v1/projects/my-project/location/global/prometheus/api/v1/query \ +# --data-urlencode 'query=sum by (job) (up)' +# +# airlock exec -- curl -s https://logging.googleapis.com/v2/entries:list \ +# -H 'Content-Type: application/json' \ +# -d '{"resourceNames":["projects/my-project"],"filter":"severity>=ERROR","orderBy":"timestamp desc","pageSize":20}' +# +# # Refused by the proxy with 403: DELETE is not on any allow list. +# airlock exec -- curl -s -X DELETE https://monitoring.googleapis.com/v3/projects/my-project/alertPolicies/123 +# +# What the rules do and do not enforce +# +# The rules see only the host, method and path. The IAM roles above are the +# real boundary, and they have to be read-only on their own, for three +# reasons: +# +# * Google front ends honor `X-HTTP-Method-Override`, on GET as well as on +# POST. The proxy matches the method on the request line, so a "GET" rule +# does not stop a write on that path. Only IAM does. +# * `entries:list` names the projects to read in the request *body*, which +# the proxy does not inspect. Pinning `my-project` in the paths below +# narrows everything else, but not this. +# * Response redaction covers the declared secrets only. Anything else an +# API returns reaches the agent as-is. That is why the notification +# channel and uptime check configs are denied below: their labels and +# headers can carry third-party credentials (webhook URLs with tokens, +# unmasked auth headers). + +timeout = 120 + +[filesystem] +write = ["/tmp"] + +# ─── Minted secret ────────────────────────────────────────────────────────── +# +# Runs on the trusted side, with your own gcloud login. Only the access token +# it prints enters the secret store. See gcp-impersonation.toml for details. +[secrets.gcp_observability_token] +source = "command" +command = [ + "gcloud", "auth", "print-access-token", + "--impersonate-service-account=agent-observability@my-project.iam.gserviceaccount.com", +] +env = { CLOUDSDK_CORE_ACCOUNT = "you@example.com", CLOUDSDK_BILLING_QUOTA_PROJECT = "my-project" } +timeout = 30 +# Tokens live 1 h. Re-mint every 50 min. +refresh = 3000 +refresh_max_backoff = 600 + +# ─── Proxy tool ───────────────────────────────────────────────────────────── +# +# `proxy = true` means: no secrets in `env`, network only through the proxy, +# and only to the hosts routed below. Any other host gets a 403 from the +# proxy. The description is what the agent sees in `airlock list`, so say +# which APIs are reachable and that auth is automatic. +[tools.curl] +description = "curl for Google Cloud Trace / Monitoring / Logging REST APIs in my-project, read-only. Auth is injected by airlock; do not pass tokens." +proxy = true +timeout = 120 + +# Each route is one host. With a non-empty `allow`, a request must match an +# allow rule and no deny rule. In paths, `*` is one segment and `**` (last +# only) is zero or more segments. + +# Cloud Trace. v1 is the read API (list and get traces); v2 is write-only. +[[tools.curl.routes]] +host = "cloudtrace.googleapis.com" +inject = { header = "Authorization", value = "Bearer {secret}", secret = "gcp_observability_token" } +allow = [ + "GET /v1/projects/my-project/traces", + "GET /v1/projects/my-project/traces/*", +] + +# Cloud Monitoring. +# v3: time series, metric and resource descriptors, alert policies, groups, +# services and SLOs. MQL queries are a POST to `timeSeries:query`. +# v1: dashboards, and the Managed Service for Prometheus query API +# (PromQL). Its query endpoints also take POST with a form body, which +# is what `curl --data-urlencode` sends. +[[tools.curl.routes]] +host = "monitoring.googleapis.com" +inject = { header = "Authorization", value = "Bearer {secret}", secret = "gcp_observability_token" } +allow = [ + "GET /v3/projects/my-project/**", + "POST /v3/projects/my-project/timeSeries:query", + "GET /v1/projects/my-project/dashboards/**", + "GET /v1/projects/my-project/location/global/prometheus/api/v1/**", + "POST /v1/projects/my-project/location/global/prometheus/api/v1/query", + "POST /v1/projects/my-project/location/global/prometheus/api/v1/query_range", + "POST /v1/projects/my-project/location/global/prometheus/api/v1/series", + "POST /v1/projects/my-project/location/global/prometheus/api/v1/labels", +] +# Deny wins over allow, which carves these out of `GET /v3/projects/my-project/**`. +deny = [ + "* /v3/projects/my-project/notificationChannels/**", + "* /v3/projects/my-project/uptimeCheckConfigs/**", +] + +# Cloud Logging. Reading log entries is a POST to `entries:list`. Everything +# under the project path is GET metadata: log names, buckets, views, sinks, +# exclusions and log-based metrics. +[[tools.curl.routes]] +host = "logging.googleapis.com" +inject = { header = "Authorization", value = "Bearer {secret}", secret = "gcp_observability_token" } +allow = [ + "POST /v2/entries:list", + "GET /v2/projects/my-project/**", +] From 658469f00214f20069dfb50cd185df72c2da65a8 Mon Sep 17 00:00:00 2001 From: Paavo Pokkinen Date: Wed, 23 Sep 2026 14:48:15 +0300 Subject: [PATCH 2/2] fix(proxy): refuse requests carrying method-override headers Route rules match the method on the request line, but Google front ends route by X-HTTP-Method-Override when it is present, on GET as well as POST. So `deny = ["DELETE /**"]` did not stop `POST /x` + `X-HTTP-Method-Override: DELETE`, and a `GET /v3/**` allow rule admitted writes. Verified against logging.googleapis.com: GET /v2/entries:list returns 404 without the header and 403 (routed to the POST method, auth required) with `X-HTTP-Method-Override: POST`. vet_request now answers 403 when X-HTTP-Method-Override, X-HTTP-Method or X-Method-Override is present, whatever its value. The request is refused, not stripped: stripping would forward a different request than the tool sent, and a clear 403 tells the agent to use -X. Frameworks that take `_method` from a POST's form body or query string (Laravel, Symfony, Rails) are out of reach, because the proxy doesn't parse bodies. SECURITY.md lists that as a residual risk. --- README.md | 2 ++ SECURITY.md | 3 ++- SKILL.md | 2 ++ docs/proxy-tools-design.md | 2 ++ examples/gcp-observability-curl.toml | 10 ++++------ src/proxy/server.rs | 21 +++++++++++++++++++++ src/proxy/server/tests.rs | 24 ++++++++++++++++++++++++ 7 files changed, 57 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 123c78d..97de16a 100644 --- a/README.md +++ b/README.md @@ -303,6 +303,8 @@ Each `allow` and `deny` rule has the form `METHOD /path`. `*` as the method matc In the example above, `allow` limits the tool to one project, and `deny` removes `DELETE` from that. Without `deny`, you would have to list each allowed method. +The proxy refuses a request that carries an `X-HTTP-Method-Override`, `X-HTTP-Method` or `X-Method-Override` header. Google APIs and many frameworks take the method from that header instead of the request line, so it would let a `POST` bypass `deny = ["DELETE /**"]`. + The agent then uses ordinary URLs from the API docs: ```bash diff --git a/SECURITY.md b/SECURITY.md index be41741..a3d5461 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -346,6 +346,7 @@ Egress restriction is the second layer, not the first. It makes the set of hosts | Inside the tunnel: `Host` header ≠ the CONNECT authority | `400` (no domain fronting) | | Inside the tunnel: absolute-form request target | `400` | | `Transfer-Encoding` together with `Content-Length`, or two `Content-Length` headers | `400` (request smuggling) | +| An `X-HTTP-Method-Override`, `X-HTTP-Method` or `X-Method-Override` header, whatever its value | `403`. Upstreams such as Google APIs route by this header instead of the request method, so the method the rules checked would not be the one that runs. | | The route's `allow` / `deny` rules do not permit the method and path | `403` | | The path contains `.` or `..` segments, `//`, a backslash, a malformed percent-escape, or an escape that decodes to `/`, `\`, `%` or NUL | `403`. The proxy refuses the path instead of normalizing it, because the upstream may normalize it differently than the matcher. | | The slot of the injected secret is `Stale` | `502` | @@ -401,7 +402,7 @@ The proxy logs each request to the ring buffer: tool, method, host, path, decisi - **Misuse, not leakage.** The agent gets the full API permissions of the credential on the routed hosts. This is broader than a purpose-built CLI. Mitigate this first with a narrowly scoped service account, then with allow/deny rules. - **Data exfiltration to other tenants.** The tool can upload anything it can read to an attacker's project on an allowed multi-tenant host (`storage.googleapis.com` serves every GCP customer). It cannot upload the credential. -- **Allow/deny rules are a convenience, not an authorization system.** They see the path, not the body. A `POST` allowed for one purpose can do something else (`:batchUpdate`, GraphQL). IAM is the real permission boundary. +- **Allow/deny rules are a convenience, not an authorization system.** They see the path, not the body. A `POST` allowed for one purpose can do something else (`:batchUpdate`, GraphQL). Some web frameworks (Laravel, Symfony, Rails) also read the method from a `_method` field in a POST's form body or query string. The proxy refuses the method-override *headers*, but it does not parse bodies or query strings. IAM is the real permission boundary. - **An upstream that *transforms* the secret is not caught.** [Response redaction](#response-redaction) closes the `curl -o` / `--dump-header` / `--trace` path for response bytes. What the tool writes to a file is already redacted, so the plaintext credential never exists inside the sandbox. Redaction does not catch an upstream that returns the secret reversed, split into pieces, or in an encoding the redactor does not know. The stdout path has the same limit. Redaction also does not apply to data the agent sends: the proxy forwards the query string and request body as the agent wrote them. - **Linux egress restriction is port-scoped and TCP-only.** See [Linux — Landlock LSM](#linux--landlock-lsm). - **HTTP/1.1 only.** ALPN offers only `http/1.1`, so gRPC and HTTP/2-only endpoints do not work. diff --git a/SKILL.md b/SKILL.md index eb035f9..1cf606f 100644 --- a/SKILL.md +++ b/SKILL.md @@ -99,6 +99,8 @@ Rules: proxy removes it. You also have no token to put there. - **Only the hosts that `airlock list` shows are reachable.** Requests to any other host fail. +- **Set the method with `-X`.** The proxy refuses any request with an + `X-HTTP-Method-Override`, `X-HTTP-Method` or `X-Method-Override` header. - **`403` from the proxy means the host, port, method or path is not allowed.** The response body says why. This is a policy decision, not a temporary error. Do **not** retry with `--noproxy`, `--insecure`/`-k`, a diff --git a/docs/proxy-tools-design.md b/docs/proxy-tools-design.md index 9cd33ea..c9137d7 100644 --- a/docs/proxy-tools-design.md +++ b/docs/proxy-tools-design.md @@ -182,6 +182,8 @@ CONNECT run.googleapis.com:443 per request: ├─ Host header ≠ CONNECT authority → 400 (no domain fronting) ├─ TE + CL together, obs-fold → 400 (smuggling) + ├─ X-HTTP-Method-Override / X-HTTP-Method / + │ X-Method-Override present → 403 (method the rules saw ≠ method that runs) ├─ !route.permits(method, path) → 403 ├─ remove any client-supplied copy of inject.header ├─ look up secret; slot Stale → 502 diff --git a/examples/gcp-observability-curl.toml b/examples/gcp-observability-curl.toml index adec23a..055bc58 100644 --- a/examples/gcp-observability-curl.toml +++ b/examples/gcp-observability-curl.toml @@ -45,13 +45,11 @@ # # What the rules do and do not enforce # -# The rules see only the host, method and path. The IAM roles above are the -# real boundary, and they have to be read-only on their own, for three -# reasons: +# The rules see only the host, method and path. The proxy refuses +# `X-HTTP-Method-Override` and its variants, so the method a rule matched is +# the one Google runs. The IAM roles above are still the real boundary, and +# they have to be read-only on their own: # -# * Google front ends honor `X-HTTP-Method-Override`, on GET as well as on -# POST. The proxy matches the method on the request line, so a "GET" rule -# does not stop a write on that path. Only IAM does. # * `entries:list` names the projects to read in the request *body*, which # the proxy does not inspect. Pinning `my-project` in the paths below # narrows everything else, but not this. diff --git a/src/proxy/server.rs b/src/proxy/server.rs index 82e835c..d1d5087 100644 --- a/src/proxy/server.rs +++ b/src/proxy/server.rs @@ -79,6 +79,15 @@ const MAX_CONCURRENT_TUNNELS: usize = 32; /// Proxy-auth username. The password is the per-exec token. const PROXY_USER: &str = "airlock"; +/// Headers an upstream may route by instead of the request line's method. +/// Google front ends honor `X-HTTP-Method-Override` on GET and POST alike, +/// so a rule that permits `GET /x` would also let a DELETE of `/x` through. +const METHOD_OVERRIDE_HEADERS: &[&str] = &[ + "x-http-method-override", + "x-http-method", + "x-method-override", +]; + /// Hop-by-hop headers stripped before forwarding upstream. `Content-Length` is /// deliberately *not* in this list: some APIs reject a chunked upload, so a /// client-declared length is passed through rather than re-derived. @@ -541,6 +550,18 @@ pub(crate) fn vet_request( )); } + // Refused rather than stripped: stripping would quietly turn the request + // into a different one than the tool sent. + if METHOD_OVERRIDE_HEADERS + .iter() + .any(|name| parts.headers.contains_key(*name)) + { + return Err(deny( + StatusCode::FORBIDDEN, + "denied: method-override headers are not allowed; send the real method", + )); + } + // Rules match the path only. The query string is forwarded untouched but // never matched against and never logged — it may carry data. if !route.permits(parts.method.as_str(), parts.uri.path()) { diff --git a/src/proxy/server/tests.rs b/src/proxy/server/tests.rs index dc21044..6d715b3 100644 --- a/src/proxy/server/tests.rs +++ b/src/proxy/server/tests.rs @@ -755,6 +755,30 @@ fn rules_match_the_path_and_ignore_the_query() { assert_eq!(err.status, StatusCode::FORBIDDEN); } +#[test] +fn method_override_headers_are_refused() { + // An empty allow list permits everything, so only the override check + // can refuse these. + let r = route(UPSTREAM_HOST, &[], None); + for name in [ + "X-HTTP-Method-Override", + "x-http-method-override", + "X-HTTP-Method", + "X-Method-Override", + ] { + for method in ["GET", "POST"] { + let overridden = parts( + method, + "/v1/x", + &[("host", UPSTREAM_HOST), (name, "DELETE")], + ); + let err = vet_request(&overridden, UPSTREAM_HOST, &r).unwrap_err(); + assert_eq!(err.status, StatusCode::FORBIDDEN, "{method} with {name}"); + assert!(err.reason.contains("method-override"), "{}", err.reason); + } + } +} + // ─── Forwarding ─────────────────────────────────────────────────────────────── #[tokio::test]