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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,9 @@ services:
stream: true
```

Path params like `{id}` are escaped as single path segments, including slashes and URL delimiters.
See [path parameter rules](docs/registry.md#path-parameters) for accepted values and validation errors.

| Setting under `upstream` | Default | Meaning |
|-------------------------|---------|---------|
| `request_timeout` | `30s` | Context deadline across dispatch, retries, and response reading |
Expand Down
27 changes: 27 additions & 0 deletions docs/registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,33 @@ JSON uses `body` in the response envelope. Text uses `body_text`.
Validated JSON bytes pass through without compaction or HTML escaping.
Envelope field order stays unchanged. Text keeps standard JSON string escaping.

## Path parameters

Placeholders like `/repos/{owner}/{repo}` use values from `params`.
Each value is escaped as a single path segment with Go's `url.PathEscape`.
Slashes, query delimiters, fragment delimiters, percent signs, and spaces are encoded.
For example, `owner/repo` becomes `owner%2Frepo`, and `%2e%2e` becomes `%252e%252e`.
Inserted values aren't scanned again for placeholders.

Placeholders aren't allowed after `?`. Static query strings remain supported.
Registry loading rejects query placeholders and names the service and action without exposing template values.
The gateway also rejects these templates defensively.

Path values must be strings, integer numbers, or booleans.
Missing values, empty strings, `.` and `..` are rejected.
Objects, arrays, null, and non-integer numbers are rejected.
Integer number text stays unchanged, including large integers and integral exponent notation.
Other params can still contain objects, arrays, or null when they aren't used in the path.

Invalid path params return HTTP 400 with code `bad_request` and fixed message `invalid params`.
The receipt records status 400, policy `deny`, and error `bad_request`.
URL validation runs before vault access or token refresh.
Invalid base URLs return HTTP 500 with code `internal` and fixed message `failed to build upstream request`.
The receipt records status 500, policy `allow`, and error `internal`.
Base URL joining preserves its existing trailing-slash trimming.
The resulting URL must retain the base URL's scheme and host.
Connectors must declare path separators in templates, not inside param values.

## SSE negotiation and receipts

Set `stream: true` only for actions that may produce SSE.
Expand Down
108 changes: 95 additions & 13 deletions internal/gateway/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ import (
"fmt"
"io"
"log/slog"
"math"
"math/big"
"net/http"
"net/url"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -402,6 +406,24 @@ func (s *Server) executeAttempt(ctx context.Context, att *attempt) *outcome {
}
}

upstreamURL, err := buildURL(svc.BaseURL, action.Path, att.params)
if err != nil {
if !errors.Is(err, errInvalidPathParams) {
return &outcome{
status: http.StatusInternalServerError,
body: ErrorResponse{Error: "failed to build upstream request", Code: "internal"},
policyDecision: "allow",
errorCode: "internal",
}
}
return &outcome{
status: http.StatusBadRequest,
body: ErrorResponse{Error: "invalid params", Code: "bad_request"},
policyDecision: "deny",
errorCode: "bad_request",
}
}

tok, err := s.cfg.Vault.Get(att.req.OnBehalfOf, att.req.Service)
if err != nil {
return &outcome{
Expand Down Expand Up @@ -434,8 +456,6 @@ func (s *Server) executeAttempt(ctx context.Context, att *attempt) *outcome {
}
}

upstreamURL := buildURL(svc.BaseURL, action.Path, att.params)

var bodyReader io.Reader
if action.Method == "POST" || action.Method == "PUT" || action.Method == "PATCH" {
bodyReader = bytes.NewReader(att.req.Params)
Expand Down Expand Up @@ -561,19 +581,81 @@ func injectAuth(r *http.Request, auth registry.AuthCfg, tok vault.Token) {
}
}

// buildURL constructs the full upstream URL with path parameters
// substituted from decoded params. json.Number preserves the
// parameter's original numeric text instead of reformatting it through a
// float64 round trip.
func buildURL(baseURL, pathTemplate string, params map[string]any) string {
path := pathTemplate
for k, v := range params {
placeholder := "{" + k + "}"
if strings.Contains(path, placeholder) {
path = strings.ReplaceAll(path, placeholder, fmt.Sprint(v))
var errInvalidPathParams = errors.New("invalid path params")

// buildURL scans the template once. Inserted values are escaped single path segments.
func buildURL(baseURL, pathTemplate string, params map[string]any) (string, error) {
base, err := url.Parse(baseURL)
if err != nil || base.Scheme == "" || base.Host == "" {
return "", errors.New("invalid base URL")
}
if _, query, ok := strings.Cut(pathTemplate, "?"); ok && strings.Contains(query, "{") {
return "", errors.New("placeholders are not allowed after '?'")
}

var path strings.Builder
for len(pathTemplate) > 0 {
start := strings.IndexByte(pathTemplate, '{')
if start < 0 {
path.WriteString(pathTemplate)
break
}
path.WriteString(pathTemplate[:start])
pathTemplate = pathTemplate[start+1:]
end := strings.IndexByte(pathTemplate, '}')
if end <= 0 {
return "", errInvalidPathParams
}
value, ok := params[pathTemplate[:end]]
if !ok {
return "", errInvalidPathParams
}
escaped, err := escapePathParam(value)
if err != nil {
return "", err
}
path.WriteString(escaped)
pathTemplate = pathTemplate[end+1:]
}
fullURL := strings.TrimRight(baseURL, "/") + path.String()
parsed, err := url.Parse(fullURL)
if err != nil || parsed.Scheme != base.Scheme || parsed.Host != base.Host {
return "", errors.New("invalid upstream URL origin")
}
return fullURL, nil
}

func escapePathParam(value any) (string, error) {
var text string
switch v := value.(type) {
case string:
text = v
case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
text = fmt.Sprint(v)
case json.Number:
text = v.String()
if !json.Valid([]byte(text)) {
return "", errInvalidPathParams
}
// Check integrality without rounding large integers or near-integer fractions through float64.
number, ok := new(big.Rat).SetString(text)
if !ok || !number.IsInt() {
return "", errInvalidPathParams
}
case float64:
if math.IsNaN(v) || math.IsInf(v, 0) || math.Trunc(v) != v {
return "", errInvalidPathParams
}
text = strconv.FormatFloat(v, 'f', -1, 64)
case float32:
return escapePathParam(float64(v))
default:
return "", errInvalidPathParams
}
if text == "" || text == "." || text == ".." {
return "", errInvalidPathParams
}
return strings.TrimRight(baseURL, "/") + path
return url.PathEscape(text), nil
}

func writeJSON(w http.ResponseWriter, status int, v interface{}) {
Expand Down
15 changes: 9 additions & 6 deletions internal/gateway/gateway_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ func testSetup(t *testing.T) (*Server, *vault.MemoryStore, *fakeReceipts) {
_ = reg.LoadBytes([]byte(`
services:
github:
base_url: PLACEHOLDER
base_url: https://api.github.com
auth:
type: oauth2
actions:
Expand All @@ -90,7 +90,7 @@ services:
repo: string
title: string
stripe:
base_url: PLACEHOLDER
base_url: https://api.stripe.com
auth:
type: bearer
actions:
Expand Down Expand Up @@ -593,10 +593,10 @@ func TestBuildURL(t *testing.T) {
{"no params", "https://api.example.com", "/users", ``, "https://api.example.com/users"},
{"path params", "https://api.example.com", "/repos/{owner}/{repo}", `{"owner":"octocat","repo":"hello"}`, "https://api.example.com/repos/octocat/hello"},
{"trailing slash", "https://api.example.com/", "/users", ``, "https://api.example.com/users"},
{"null", "https://api.example.com", "/users/{id}", `null`, "https://api.example.com/users/{id}"},
{"static query", "https://api.example.com", "/repos/{repo}?sort=updated&limit=10", `{"repo":"hello"}`, "https://api.example.com/repos/hello?sort=updated&limit=10"},
{"null without placeholders", "https://api.example.com", "/users", `null`, "https://api.example.com/users"},
{"numeric text", "https://api.example.com", "/users/{id}/{rate}", `{"id":9007199254740993,"rate":1.00e+2}`, "https://api.example.com/users/9007199254740993/1.00e+2"},
{"unicode and bool", "https://api.example.com", "/{name}/{flag}/{name}", `{"name":"😀","flag":true}`, "https://api.example.com/😀/true/😀"},
{"null value", "https://api.example.com", "/{id}", `{"id":null}`, "https://api.example.com/<nil>"},
{"unicode and bool", "https://api.example.com", "/{name}/{flag}/{name}", `{"name":"😀","flag":true}`, "https://api.example.com/%F0%9F%98%80/true/%F0%9F%98%80"},
}

for _, tc := range cases {
Expand All @@ -607,7 +607,10 @@ func TestBuildURL(t *testing.T) {
if err != nil {
t.Fatal(err)
}
got := buildURL(tc.base, tc.path, params)
got, err := buildURL(tc.base, tc.path, params)
if err != nil {
t.Fatal(err)
}
if got != tc.want {
t.Fatalf("got %s, want %s", got, tc.want)
}
Expand Down
Loading
Loading