Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,12 +303,16 @@ 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
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.
Expand Down
3 changes: 2 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/proxy-tools-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
141 changes: 141 additions & 0 deletions examples/gcp-observability-curl.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# 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 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:
#
# * `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/**",
]
21 changes: 21 additions & 0 deletions src/proxy/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()) {
Expand Down
24 changes: 24 additions & 0 deletions src/proxy/server/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading