Skip to content

Restrict a service to specific HTTP methods (read-only credential brokering) #323

Description

@cjohnhanson

Summary

Add an optional methods field to a service so the credential is attached only to requests using the listed HTTP methods. methods: [GET, HEAD] would broker a privileged token read-only.

Motivation

A service today matches on host + optional port + optional path glob. Any matching request gets the credential, whatever the verb. For most resource-oriented REST APIs, path scoping can't express "read-only": the read and the mutation share a path and differ only by method. On api.github.com, GET /repos/{owner}/{repo} and DELETE /repos/{owner}/{repo} are the same path.

Where the upstream offers a scoped credential (GitHub fine-grained PATs, Stripe restricted keys), that's the better place to enforce read-only. But plenty of internal and legacy REST services issue only an all-or-nothing token. For those, short of trusting the agent's prompt, a method check at the proxy is the only read-only control available.

Proposal

services:
  - name: github-readonly
    host: api.github.com
    methods: [GET, HEAD]
    auth:
      type: bearer
      token: GITHUB_TOKEN
  • methods omitted = all methods, so every existing config keeps its behavior. Validation rejects an explicit methods: [] rather than treating it as "allow everything."
  • Values are uppercase-normalized on ingest and validated against GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS. The request method is compared case-sensitively against that normalized set. An odd-cased method fails closed.
  • A request whose method isn't listed is denied, and the credential is not attached as a header or through substitutions (see Design notes).

Separable add-on: method-override stripping. To hold up against upstreams that honor method-override, a service with methods set should also strip X-HTTP-Method-Override / X-Method-Override / X-HTTP-Method and the _method query parameter before forwarding. Stripping modifies the request, which the methods field alone does not, so treat it as a separate yes/no. It's proposed in scope and on by default here, but splits cleanly into a follow-up. It only removes those spoofing tokens; it is not a general header or query matcher. The _method body form would need body inspection and stays out of scope.

Design notes

methods adds no new matcher tier. MatchService still selects the single most-specific host/port/path winner exactly as today. The method check then runs on that winner, parallel to the existing IsEnabled() check in internal/brokercore/credential.go: a winner whose list excludes the method is denied outright, with no fall-through to a broader sibling.

The check has to happen at the Inject boundary, before either credential path runs. Inject resolves both the auth header and any substitutions, and substitutions can write the secret into the path, query, or body (they resolve for passthrough services too). A header-only check would still leak the credential through substitutions on a request it was supposed to deny.

The check also binds the winning service, not the host. A more specific sibling without methods (say path: /repos/*) out-matches a broad methods: [GET] rule and injects on any verb. Making a whole host read-only means putting methods on every overlapping service.

The core plumbing is small: Methods []string on broker.Service plus validation, and passing r.Method into Inject — cheap because both call sites in forwardRequest already hold it. The rest is trailing surfaces: services API validation, web UI form, agent proposal flow, SDKs, docs. Happy to contribute the PR if the shape is agreeable.

Limitations

Method checking constrains plain HTTP request semantics, and only that. A WebSocket handshake is a GET, so a methods: [GET] service still matches it, and a websocket-surface substitution writes the credential into frames that carry no method at all. Upstreams that read _method from a POST body (Rack, Symfony) can still be spoofed, since that would take body inspection. GraphQL-style POST-everything APIs get nothing from this for the same reason.

Open questions

Denial status: every broker-policy denial today is 403 (StatusForbidden in internal/brokercore/brokercore.go), so leaning 403 for consistency. 405 + Allow is more literally correct but would be the ingress's first non-403 broker denial.

Per-method credential splitting (GET gets a read token, POST a write token on the same host) is a different mechanism, a selection-time filter rather than a check on the selected winner, and is not part of this ask.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions