Skip to content
Open
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
23 changes: 22 additions & 1 deletion docs/learn/services.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,28 @@ Worked example — Slack with two credentials at the same host:

- To override a wildcard-host rule for a specific subdomain, **add the exact-host rule** — it takes priority no matter what its path portion looks like.
- To layer two credentials on the same host, give them **different path portions**; the more specific path will win.
- The matcher does not run regex, does not match on method/headers/query/body, and does not bound `*` at path segments. Patterns that tie under rule 2 (e.g. `slack.com/api/*/v2` and `slack.com/api/*/v3`) fall to declaration order.
- The matcher does not run regex, does not match on method/headers/query/body, and does not bound `*` at path segments. Patterns that tie under rule 2 (e.g. `slack.com/api/*/v2` and `slack.com/api/*/v3`) fall to declaration order. (A service's optional `methods` list is not a matcher dimension — it is checked after selection; see [Restricting HTTP methods](#restricting-http-methods).)

## Restricting HTTP methods

A service may declare an optional `methods` list. When present, the credential is attached only to requests using one of the listed HTTP methods; any other method is denied with a 403 (`method_not_allowed`) and the credential is not attached in any form — neither as auth headers nor through substitutions.

```yaml
services:
- name: github-readonly
host: api.github.com
methods: [GET, HEAD]
auth:
type: bearer
token: GITHUB_TOKEN
```

- Omitting `methods` allows all methods. An explicitly empty list (`methods: []`) is rejected by validation.
- Supported values: `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`. Values are uppercased on ingest.
- `methods` is **not** part of matching. The matcher selects the single most-specific host/port/path winner exactly as described above, and the method check then runs on that winner — like a disabled service, a method-excluded winner is denied outright with no fall-through to a broader sibling. Making a whole host read-only therefore requires `methods` on every service whose pattern can win requests for that host.
- On a method-restricted service, Agent Vault also strips the well-known method-override surfaces before forwarding: the `X-HTTP-Method-Override`, `X-Method-Override`, and `X-HTTP-Method` headers and the `_method` query parameter. The `_method` form carried in a POST body is not inspected.

Method restriction constrains plain HTTP request semantics only. A WebSocket handshake is a `GET`, so a `methods: [GET]` service still matches it, and `websocket`-scope substitutions write into frames that carry no method.

## Example

Expand Down
57 changes: 57 additions & 0 deletions internal/broker/broker.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type Service struct {
Path string `yaml:"path,omitempty" json:"path,omitempty"`
Port *int `yaml:"port,omitempty" json:"-"`
Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
Methods []string `yaml:"methods,omitempty" json:"methods,omitempty"`
Auth Auth `yaml:"auth" json:"auth"`
Substitutions []Substitution `yaml:"substitutions,omitempty" json:"substitutions,omitempty"`
}
Expand Down Expand Up @@ -75,6 +76,57 @@ func (s *Service) IsEnabled() bool {
return s.Enabled == nil || *s.Enabled
}

// SupportedMethods lists the HTTP methods a service's Methods field may
// contain.
var SupportedMethods = []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}

// NormalizeAndValidateMethods uppercases s.Methods in place, then checks
// each value against SupportedMethods and rejects duplicates. A nil
// Methods field means "all methods"; an explicitly-empty list is
// rejected so `methods: []` can't silently mean "allow everything".
func (s *Service) NormalizeAndValidateMethods() error {
if s.Methods == nil {
return nil
}
if len(s.Methods) == 0 {
return fmt.Errorf("methods: must not be an empty list (omit the field to allow all methods)")
}
allowed := make(map[string]bool, len(SupportedMethods))
for _, m := range SupportedMethods {
allowed[m] = true
}
seen := make(map[string]bool, len(s.Methods))
for i, m := range s.Methods {
u := strings.ToUpper(strings.TrimSpace(m))
if !allowed[u] {
return fmt.Errorf("methods: unsupported method %q (supported: %s)", m, strings.Join(SupportedMethods, ", "))
}
if seen[u] {
return fmt.Errorf("methods: duplicate method %q", u)
}
seen[u] = true
s.Methods[i] = u
}
return nil
}

// AllowsMethod reports whether the service permits the given request
// method. A service with no Methods list permits all methods. The
// comparison is case-sensitive against the uppercase-normalized list,
// so a non-canonical-case request method fails closed rather than being
// coerced into a match.
func (s *Service) AllowsMethod(method string) bool {
if len(s.Methods) == 0 {
return true
}
for _, m := range s.Methods {
if m == method {
return true
}
}
return false
}

// Auth describes how credentials are attached for a broker service.
// Each service must specify a Type and the fields relevant to that type.
//
Expand Down Expand Up @@ -373,6 +425,11 @@ func Validate(cfg *Config) error {
if err := ValidatePort(s.Port); err != nil {
return fmt.Errorf("service %d: %w", i, err)
}
// Normalize through the slice element, not the range copy, so the
// uppercased methods persist.
if err := cfg.Services[i].NormalizeAndValidateMethods(); err != nil {
return fmt.Errorf("service %d: %w", i, err)
}
if err := s.Auth.Validate(); err != nil {
return fmt.Errorf("service %d: %w", i, err)
}
Expand Down
78 changes: 78 additions & 0 deletions internal/broker/broker_methods_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package broker

import (
"strings"
"testing"
)

func methodsCfg(methods []string) *Config {
return &Config{
Vault: "v1",
Services: []Service{{
Name: "api",
Host: "api.example.com",
Methods: methods,
Auth: Auth{Type: "bearer", Token: "TOKEN"},
}},
}
}

func TestValidate_MethodsNormalized(t *testing.T) {
cfg := methodsCfg([]string{"get", "Post"})
if err := Validate(cfg); err != nil {
t.Fatalf("unexpected err: %v", err)
}
got := cfg.Services[0].Methods
if len(got) != 2 || got[0] != "GET" || got[1] != "POST" {
t.Fatalf("methods not normalized in place: %v", got)
}
}

func TestValidate_MethodsRejectsUnsupported(t *testing.T) {
err := Validate(methodsCfg([]string{"GET", "FETCH"}))
if err == nil || !strings.Contains(err.Error(), "unsupported method") {
t.Fatalf("want unsupported-method error, got %v", err)
}
}

func TestValidate_MethodsRejectsDuplicates(t *testing.T) {
err := Validate(methodsCfg([]string{"GET", "get"}))
if err == nil || !strings.Contains(err.Error(), "duplicate method") {
t.Fatalf("want duplicate-method error, got %v", err)
}
}

func TestValidate_MethodsRejectsExplicitEmptyList(t *testing.T) {
err := Validate(methodsCfg([]string{}))
if err == nil || !strings.Contains(err.Error(), "empty list") {
t.Fatalf("want empty-list error, got %v", err)
}
}

func TestValidate_MethodsNilAllowed(t *testing.T) {
if err := Validate(methodsCfg(nil)); err != nil {
t.Fatalf("nil methods must validate: %v", err)
}
}

func TestAllowsMethod(t *testing.T) {
cases := []struct {
name string
methods []string
method string
want bool
}{
{"nil list allows all", nil, "DELETE", true},
{"listed method allowed", []string{"GET", "HEAD"}, "GET", true},
{"unlisted method denied", []string{"GET", "HEAD"}, "POST", false},
{"non-canonical case fails closed", []string{"GET"}, "get", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s := Service{Methods: tc.methods}
if got := s.AllowsMethod(tc.method); got != tc.want {
t.Fatalf("AllowsMethod(%q) with %v = %v, want %v", tc.method, tc.methods, got, tc.want)
}
})
}
}
8 changes: 8 additions & 0 deletions internal/brokercore/brokercore.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,14 @@ func WriteInjectError(w http.ResponseWriter, err error, targetHost, vaultName, b
case errors.Is(err, ErrServiceDisabled):
writeProxyErrorWithHelp(w, http.StatusForbidden, "service_disabled",
fmt.Sprintf("Broker service matching host %q in vault %q is currently disabled", targetHost, vaultName), baseURL)
case errors.Is(err, ErrMethodNotAllowed):
msg := fmt.Sprintf("Request method is not allowed by the broker service matching host %q in vault %q", targetHost, vaultName)
var mna *MethodNotAllowedError
if errors.As(err, &mna) {
msg = fmt.Sprintf("Method %s is not allowed by the broker service matching host %q in vault %q (allowed: %s)",
mna.Method, targetHost, vaultName, strings.Join(mna.Allowed, ", "))
}
writeProxyErrorWithHelp(w, http.StatusForbidden, "method_not_allowed", msg, baseURL)
case errors.Is(err, ErrOAuthNotConnected):
writeProxyErrorWithHelp(w, http.StatusBadGateway, "oauth_not_connected",
"OAuth credential is approved but not yet connected — complete the connection in the Agent Vault dashboard", baseURL)
Expand Down
49 changes: 40 additions & 9 deletions internal/brokercore/credential.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"fmt"
"log/slog"
"net"
"strings"
"time"

"github.com/Infisical/agent-vault/internal/broker"
Expand Down Expand Up @@ -57,15 +58,36 @@ type InjectResult struct {
// Passthrough is set when no service matched but the unmatched-host
// policy permitted forwarding.
Passthrough bool

// MethodRestricted is set when the matched service declares a Methods
// list. The MITM ingress strips method-override headers and the
// _method query parameter on such requests so an allowed method can't
// stand in for a denied one. Safe to log.
MethodRestricted bool
}

// CredentialProvider resolves a service for (targetHost, targetPath) in
// vaultID and returns the headers to attach. targetPath must be the URL
// path only — no query, no fragment.
// path only — no query, no fragment. method is the request's HTTP method,
// checked against the matched service's Methods list.
type CredentialProvider interface {
Inject(ctx context.Context, vaultID, targetHost string, targetPort int, targetPath string) (*InjectResult, error)
Inject(ctx context.Context, vaultID, targetHost string, targetPort int, targetPath, method string) (*InjectResult, error)
}

// MethodNotAllowedError reports the denied method and the methods the
// matched service permits. Unwraps to ErrMethodNotAllowed.
type MethodNotAllowedError struct {
Method string
Allowed []string
}

func (e *MethodNotAllowedError) Error() string {
return fmt.Sprintf("brokercore: method %s not allowed by broker service (allowed: %s)",
e.Method, strings.Join(e.Allowed, ", "))
}

func (e *MethodNotAllowedError) Unwrap() error { return ErrMethodNotAllowed }

// CredentialStore is the minimal store surface used by StoreCredentialProvider.
type CredentialStore interface {
GetBrokerConfig(ctx context.Context, vaultID string) (*store.BrokerConfig, error)
Expand Down Expand Up @@ -108,8 +130,10 @@ func NewStoreCredentialProvider(s CredentialStore, encKey []byte) *StoreCredenti
// Inject matches (targetHost, targetPath) and resolves the matched
// service's auth into HTTP headers. targetHost may include a port —
// stripped before matching. Pass "/" for targetPath when no path is
// meaningful.
func (p *StoreCredentialProvider) Inject(ctx context.Context, vaultID, targetHost string, targetPort int, targetPath string) (*InjectResult, error) {
// meaningful. method is checked against the matched service's Methods
// list after selection, parallel to the IsEnabled check — a
// method-excluded winner hard-fails with no fall-through.
func (p *StoreCredentialProvider) Inject(ctx context.Context, vaultID, targetHost string, targetPort int, targetPath, method string) (*InjectResult, error) {
// A missing row is equivalent to an empty services list — fall
// through to the unmatched-host policy. Any other error fails closed
// so a transient store failure can't silently strip enforcement.
Expand Down Expand Up @@ -155,6 +179,12 @@ func (p *StoreCredentialProvider) Inject(ctx context.Context, vaultID, targetHos
if !matched.IsEnabled() {
return nil, ErrServiceDisabled
}
// The method check runs here, before either credential path (auth
// headers or substitutions) resolves, so a denied request receives
// the credential in no form.
if !matched.AllowsMethod(method) {
return nil, &MethodNotAllowedError{Method: method, Allowed: matched.Methods}
}
slog.Default().Debug("broker matched",
slog.String("vault", vaultID),
slog.String("service", matched.Name),
Expand Down Expand Up @@ -210,11 +240,12 @@ func (p *StoreCredentialProvider) Inject(ctx context.Context, vaultID, targetHos
// Capture non-secret metadata up front so a downstream credential-missing
// error still carries it for diagnostic logging.
result := &InjectResult{
MatchedName: matched.Name,
MatchedHost: matched.Host,
MatchedPath: matched.Path,
MatchedPort: matched.Port,
CredentialKeys: matched.CredentialKeys(),
MatchedName: matched.Name,
MatchedHost: matched.Host,
MatchedPath: matched.Path,
MatchedPort: matched.Port,
CredentialKeys: matched.CredentialKeys(),
MethodRestricted: len(matched.Methods) > 0,
}

// Resolve substitutions before auth so passthrough services (which
Expand Down
103 changes: 103 additions & 0 deletions internal/brokercore/credential_methods_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package brokercore

import (
"context"
"errors"
"testing"

"github.com/Infisical/agent-vault/internal/broker"
)

func TestInject_MethodAllowed(t *testing.T) {
key32 := make32(0x51)
f := newFakeCredStore()
f.setServices(t, "v1", []broker.Service{{
Host: "api.example.com",
Methods: []string{"GET", "HEAD"},
Auth: broker.Auth{Type: "bearer", Token: "MY_TOKEN"},
}})
f.setCred(t, key32, "v1", "MY_TOKEN", "s3cret")

p := NewStoreCredentialProvider(f, key32)
res, err := p.Inject(context.Background(), "v1", "api.example.com", 0, "/", "GET")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if res.Headers["Authorization"] != "Bearer s3cret" {
t.Fatalf("got Authorization=%q", res.Headers["Authorization"])
}
if !res.MethodRestricted {
t.Fatal("MethodRestricted should be true for a service with a Methods list")
}
}

func TestInject_MethodDenied(t *testing.T) {
key32 := make32(0x52)
f := newFakeCredStore()
f.setServices(t, "v1", []broker.Service{{
Host: "api.example.com",
Methods: []string{"GET", "HEAD"},
Auth: broker.Auth{Type: "bearer", Token: "MY_TOKEN"},
}})
f.setCred(t, key32, "v1", "MY_TOKEN", "s3cret")

p := NewStoreCredentialProvider(f, key32)
_, err := p.Inject(context.Background(), "v1", "api.example.com", 0, "/", "DELETE")
if !errors.Is(err, ErrMethodNotAllowed) {
t.Fatalf("want ErrMethodNotAllowed, got %v", err)
}
var mna *MethodNotAllowedError
if !errors.As(err, &mna) {
t.Fatalf("want *MethodNotAllowedError, got %T", err)
}
if mna.Method != "DELETE" || len(mna.Allowed) != 2 || mna.Allowed[0] != "GET" {
t.Fatalf("unexpected error detail: %+v", mna)
}
// The check must run before credential resolution: a denied request
// must not decrypt or touch the credential in any form.
if f.getCredentialCalls != 0 {
t.Fatalf("credential store consulted %d times for a denied method", f.getCredentialCalls)
}
}

func TestInject_MethodDeniedBlocksSubstitutionsOnPassthrough(t *testing.T) {
key32 := make32(0x53)
f := newFakeCredStore()
f.setServices(t, "v1", []broker.Service{{
Host: "api.example.com",
Methods: []string{"GET"},
Auth: broker.Auth{Type: "passthrough"},
Substitutions: []broker.Substitution{{
Key: "SIGNING_KEY", Placeholder: "__signing_key__",
}},
}})
f.setCred(t, key32, "v1", "SIGNING_KEY", "sig-secret")

p := NewStoreCredentialProvider(f, key32)
_, err := p.Inject(context.Background(), "v1", "api.example.com", 0, "/", "POST")
if !errors.Is(err, ErrMethodNotAllowed) {
t.Fatalf("want ErrMethodNotAllowed, got %v", err)
}
if f.getCredentialCalls != 0 {
t.Fatalf("substitution credential resolved %d times for a denied method", f.getCredentialCalls)
}
}

func TestInject_NoMethodsAllowsAll(t *testing.T) {
key32 := make32(0x54)
f := newFakeCredStore()
f.setServices(t, "v1", []broker.Service{{
Host: "api.example.com",
Auth: broker.Auth{Type: "bearer", Token: "MY_TOKEN"},
}})
f.setCred(t, key32, "v1", "MY_TOKEN", "s3cret")

p := NewStoreCredentialProvider(f, key32)
res, err := p.Inject(context.Background(), "v1", "api.example.com", 0, "/", "DELETE")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if res.MethodRestricted {
t.Fatal("MethodRestricted should be false when the service has no Methods list")
}
}
Loading