Skip to content

Commit 4bcce4f

Browse files
committed
Harden CLI parameter parsing and URL handling
1 parent 613aa48 commit 4bcce4f

13 files changed

Lines changed: 624 additions & 109 deletions

File tree

AGENTS.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ The CLI uses a **resource-registry** pattern:
5353
| --- | --- |
5454
| `types.go` | `Resource` interface, `Operation` struct, `OperationSchema`, `ParamSchema`, `OperationHooks` |
5555
| `registry.go` | Thread-safe global registry: `Register()`, `All()`, `Get()`, `Reset()` |
56-
| `builder.go` | Converts registered resources into Cobra commands with `--json`, `--id`, `--describe`, file input (`@filename`), parameter validation, and hook execution |
56+
| `builder.go` | Converts registered resources into Cobra commands with per-parameter flags, `--json`, `--describe`, file input (`@filename`), parameter validation, and hook execution |
5757

5858
### Resources (`internal/resources/`)
5959

@@ -95,16 +95,16 @@ The CLI uses a **resource-registry** pattern:
9595
| `connectors` | `create` | Interactive credential flow | `workspace`, `template_name` or `template_id` |
9696
| `connectors` | `delete` | Delete a connector | `name`+`workspace` or `--id` |
9797

98-
### Global Flags
98+
### Common Flags
9999

100100
| Flag | Description | Default |
101101
| --- | --- | --- |
102102
| `--format` | Output format: `json` or `table` | `json` |
103103
| `--describe` | Print operation schema and exit (do not execute) | `false` |
104104
| `--output, -o` | Write output to file instead of stdout | -- |
105105
| `--verbose, -v` | Enable debug logging | `false` |
106-
| `--json` | Inline JSON parameters | -- |
107-
| `--id` | Convenience flag for resource ID | -- |
106+
| `--json` | Operation flag for inline JSON parameters; mutually exclusive with per-parameter flags | -- |
107+
| `--<param>` | Per-parameter operation flags generated from each scalar/array schema parameter, e.g. `--id`, `--workspace`, `--select-fields` | -- |
108108

109109
## Credential Security
110110

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ Parameters can be supplied two ways: as a single JSON document via `--json`, or
9898

9999
### Two ways to pass parameters
100100

101-
**1. Individual flags (recommended for humans)**every parameter in the operation's schema is exposed as a `--<param>` flag, with snake_case keys converted to kebab-case (e.g. `select_fields``--select-fields`):
101+
**1. Individual flags (recommended for humans)**scalar and array parameters in the operation's schema are exposed as `--<param>` flags, with snake_case keys converted to kebab-case (e.g. `select_fields``--select-fields`):
102102

103103
```bash
104104
airbyte connectors describe --workspace default --name hubspot
@@ -123,11 +123,11 @@ airbyte connectors execute --json '{
123123

124124
Use `@filename` to load JSON from a file: `--json @params.json`. `--json` is the only way to pass nested objects (e.g. the `params` field on `connectors execute`).
125125

126-
### Global flags
126+
### Common flags
127127

128128
| Flag | Description | Default |
129129
| --- | --- | --- |
130-
| `--json` | Inline JSON parameters (or `@filename` to load from a file). Cannot be combined with per-parameter flags. | -- |
130+
| `--json` | Operation flag for inline JSON parameters (or `@filename` to load from a file). Cannot be combined with per-parameter flags. | -- |
131131
| `--format` | Output format: `json` or `table` | `json` |
132132
| `--describe` | Print the operation's parameter schema and exit | `false` |
133133
| `--output, -o` | Write output to a file instead of stdout | -- |

internal/client/client.go

Lines changed: 92 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ import (
44
"bytes"
55
"context"
66
"encoding/json"
7+
"errors"
78
"fmt"
89
"io"
910
"log"
1011
"math"
1112
"net/http"
1213
"net/url"
14+
"strings"
1315
"time"
1416

1517
"github.com/airbytehq/airbyte-cli/internal/auth"
@@ -28,6 +30,7 @@ type Client struct {
2830
tokenManager *auth.TokenManager
2931
httpClient *http.Client
3032
debug bool
33+
debugFunc func() bool
3134
}
3235

3336
type Option func(*Client)
@@ -38,6 +41,12 @@ func WithDebug(debug bool) Option {
3841
}
3942
}
4043

44+
func WithDebugFunc(debugFunc func() bool) Option {
45+
return func(c *Client) {
46+
c.debugFunc = debugFunc
47+
}
48+
}
49+
4150
func New(apiHost, organizationID, version string, tm *auth.TokenManager, opts ...Option) *Client {
4251
c := &Client{
4352
apiHost: apiHost,
@@ -82,7 +91,11 @@ func (c *Client) Delete(ctx context.Context, path string) (json.RawMessage, erro
8291
}
8392

8493
func (c *Client) GetURL(ctx context.Context, rawURL string) (json.RawMessage, error) {
85-
return c.do(ctx, http.MethodGet, rawURL, nil)
94+
safeURL, err := c.resolveAPIURL(rawURL)
95+
if err != nil {
96+
return nil, err
97+
}
98+
return c.do(ctx, http.MethodGet, safeURL, nil)
8699
}
87100

88101
func (c *Client) doWithBody(ctx context.Context, method, path string, body any) (json.RawMessage, error) {
@@ -130,8 +143,8 @@ func (c *Client) do(ctx context.Context, method, rawURL string, body io.Reader)
130143
return nil, err
131144
}
132145

133-
if c.debug {
134-
log.Printf("[DEBUG] request %s %s attempt %d failed: %v", method, rawURL, attempt+1, err)
146+
if c.isDebug() {
147+
log.Printf("[DEBUG] request %s %s attempt %d failed: %s", method, redactURL(rawURL), attempt+1, debugError(err))
135148
}
136149
}
137150

@@ -157,8 +170,8 @@ func (c *Client) doOnce(ctx context.Context, method, rawURL string, body io.Read
157170
req.Header.Set("X-Organization-Id", c.organizationID)
158171
}
159172

160-
if c.debug {
161-
log.Printf("[DEBUG] %s %s", method, rawURL)
173+
if c.isDebug() {
174+
log.Printf("[DEBUG] %s %s", method, redactURL(rawURL))
162175
}
163176

164177
resp, err := c.httpClient.Do(req)
@@ -172,8 +185,8 @@ func (c *Client) doOnce(ctx context.Context, method, rawURL string, body io.Read
172185
return nil, false, fmt.Errorf("reading response body: %w", err)
173186
}
174187

175-
if c.debug {
176-
log.Printf("[DEBUG] response %d: %s", resp.StatusCode, string(respBody))
188+
if c.isDebug() {
189+
log.Printf("[DEBUG] response status=%d bytes=%d", resp.StatusCode, len(respBody))
177190
}
178191

179192
if resp.StatusCode >= 400 {
@@ -189,6 +202,78 @@ func (c *Client) doOnce(ctx context.Context, method, rawURL string, body io.Read
189202
return json.RawMessage(respBody), false, nil
190203
}
191204

205+
func (c *Client) isDebug() bool {
206+
return c.debug || (c.debugFunc != nil && c.debugFunc())
207+
}
208+
209+
func (c *Client) resolveAPIURL(rawURL string) (string, error) {
210+
apiBase, err := url.Parse(c.apiHost)
211+
if err != nil {
212+
return "", fmt.Errorf("parsing API host: %w", err)
213+
}
214+
nextURL, err := url.Parse(rawURL)
215+
if err != nil {
216+
return "", fmt.Errorf("parsing URL: %w", err)
217+
}
218+
if !nextURL.IsAbs() {
219+
return apiBase.ResolveReference(nextURL).String(), nil
220+
}
221+
if nextURL.Scheme != apiBase.Scheme || nextURL.Host != apiBase.Host {
222+
return "", &APIError{
223+
Type: "validation_error",
224+
Message: "pagination URL points outside the configured API host",
225+
StatusCode: 400,
226+
}
227+
}
228+
return nextURL.String(), nil
229+
}
230+
231+
func redactURL(rawURL string) string {
232+
u, err := url.Parse(rawURL)
233+
if err != nil {
234+
return rawURL
235+
}
236+
q := u.Query()
237+
for key := range q {
238+
if isSensitiveKey(key) {
239+
q.Set(key, "[REDACTED]")
240+
}
241+
}
242+
u.RawQuery = q.Encode()
243+
return u.String()
244+
}
245+
246+
func debugError(err error) string {
247+
var apiErr *APIError
248+
if errors.As(err, &apiErr) {
249+
return fmt.Sprintf("%s status=%d retryable=%t", apiErr.Type, apiErr.StatusCode, apiErr.Retryable)
250+
}
251+
return redactText(err.Error())
252+
}
253+
254+
func redactText(s string) string {
255+
parts := strings.Fields(s)
256+
for i, part := range parts {
257+
if strings.Contains(part, "=") {
258+
key, _, _ := strings.Cut(part, "=")
259+
if isSensitiveKey(strings.Trim(key, `"'`)) {
260+
parts[i] = key + "=[REDACTED]"
261+
}
262+
}
263+
}
264+
return strings.Join(parts, " ")
265+
}
266+
267+
func isSensitiveKey(key string) bool {
268+
key = strings.ToLower(key)
269+
return strings.Contains(key, "token") ||
270+
strings.Contains(key, "secret") ||
271+
strings.Contains(key, "password") ||
272+
strings.Contains(key, "credential") ||
273+
strings.Contains(key, "api_key") ||
274+
strings.Contains(key, "apikey")
275+
}
276+
192277
func extractErrorMessage(body []byte, statusCode int) string {
193278
var parsed struct {
194279
Detail string `json:"detail"`

internal/client/client_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"net/http"
77
"net/http/httptest"
8+
"strings"
89
"sync/atomic"
910
"testing"
1011

@@ -357,3 +358,46 @@ func TestAPIError_JSONSerializable(t *testing.T) {
357358
t.Errorf("StatusCode = %d, want %d", parsed.StatusCode, apiErr.StatusCode)
358359
}
359360
}
361+
362+
func TestClient_RejectsExternalPaginationURL(t *testing.T) {
363+
creds := &auth.Credentials{ClientID: "id", ClientSecret: "secret"}
364+
tm := auth.NewTokenManager("https://api.example.com", "", creds)
365+
c := New("https://api.example.com", "", "test", tm)
366+
367+
_, err := c.GetURL(context.Background(), "https://evil.example.com/api/v1/workspaces?cursor=next")
368+
if err == nil {
369+
t.Fatal("expected error for external pagination URL")
370+
}
371+
372+
apiErr, ok := err.(*APIError)
373+
if !ok {
374+
t.Fatalf("expected *APIError, got %T", err)
375+
}
376+
if apiErr.Type != "validation_error" {
377+
t.Errorf("Type = %q, want validation_error", apiErr.Type)
378+
}
379+
}
380+
381+
func TestClient_DebugFuncIsEvaluatedAtRequestTime(t *testing.T) {
382+
debug := false
383+
c := New("https://api.example.com", "", "test", nil, WithDebugFunc(func() bool { return debug }))
384+
385+
if c.isDebug() {
386+
t.Fatal("debug should initially be false")
387+
}
388+
389+
debug = true
390+
if !c.isDebug() {
391+
t.Fatal("debug should reflect updated flag state")
392+
}
393+
}
394+
395+
func TestRedactURL(t *testing.T) {
396+
got := redactURL("https://api.example.com/path?token=secret&client_secret=hidden&safe=value")
397+
if strings.Contains(got, "token=secret") || strings.Contains(got, "client_secret=hidden") {
398+
t.Fatalf("expected sensitive query values to be redacted, got %s", got)
399+
}
400+
if !strings.Contains(got, "safe=value") {
401+
t.Fatalf("expected non-sensitive query value to remain, got %s", got)
402+
}
403+
}

0 commit comments

Comments
 (0)