Skip to content

Commit 447cbf3

Browse files
committed
Reject foreign-host URLs in api command to prevent token leak
parsePath now validates absolute URLs against the configured base URL host: a path is accepted only if it's relative or an absolute Basecamp URL on the same host (path extracted, account segment dropped — the SDK re-prefixes the configured account). Absolute URLs on any other host are rejected before any network call, so the SDK never attaches the Authorization: Bearer header to a foreign host. A stray leading slash ('/https://evil/…') and mixed-case schemes ('HTTPS://…') are normalized first so neither can smuggle an absolute URL past the host check. Error propagated through get/post/put/delete; api_test.go covers same-host extraction, query preservation, and foreign-host rejection.
1 parent b20051d commit 447cbf3

2 files changed

Lines changed: 97 additions & 17 deletions

File tree

internal/commands/api.go

Lines changed: 56 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package commands
33
import (
44
"encoding/json"
55
"fmt"
6+
"net/url"
67
"regexp"
78
"strings"
89

@@ -47,7 +48,10 @@ func newAPIGetCmd() *cobra.Command {
4748
return err
4849
}
4950

50-
path := parsePath(args[0])
51+
path, err := parsePath(args[0], app.Config.BaseURL)
52+
if err != nil {
53+
return err
54+
}
5155
resp, err := app.Account().Get(cmd.Context(), path)
5256
if err != nil {
5357
return convertSDKError(err)
@@ -85,7 +89,10 @@ func newAPIPostCmd() *cobra.Command {
8589
return err
8690
}
8791

88-
path := parsePath(args[0])
92+
path, err := parsePath(args[0], app.Config.BaseURL)
93+
if err != nil {
94+
return err
95+
}
8996

9097
// Parse JSON data
9198
var body any
@@ -134,7 +141,10 @@ func newAPIPutCmd() *cobra.Command {
134141
return err
135142
}
136143

137-
path := parsePath(args[0])
144+
path, err := parsePath(args[0], app.Config.BaseURL)
145+
if err != nil {
146+
return err
147+
}
138148

139149
// Parse JSON data
140150
var body any
@@ -176,7 +186,10 @@ func newAPIDeleteCmd() *cobra.Command {
176186
return err
177187
}
178188

179-
path := parsePath(args[0])
189+
path, err := parsePath(args[0], app.Config.BaseURL)
190+
if err != nil {
191+
return err
192+
}
180193
resp, err := app.Account().Delete(cmd.Context(), path)
181194
if err != nil {
182195
return err
@@ -201,20 +214,47 @@ func apiPathArgs(cmd *cobra.Command, args []string) error {
201214
return nil
202215
}
203216

204-
// parsePath extracts and normalizes the API path.
205-
// Handles full URLs and relative paths. The leading slash is stripped because
206-
// the SDK's accountPath and buildURL both add one — keeping it here would
207-
// double-slash and, on Windows, MSYS/Git Bash converts /path to C:\...\path.
208-
func parsePath(input string) string {
209-
urlPattern := regexp.MustCompile(`^https?://[^/]+/[0-9]+(/.*)`)
210-
if matches := urlPattern.FindStringSubmatch(input); len(matches) > 1 {
211-
return matches[1]
217+
// accountSegmentPattern matches a leading /<account-id>/ segment in an API
218+
// path so it can be dropped — the SDK re-prefixes the configured account.
219+
var accountSegmentPattern = regexp.MustCompile(`^/[0-9]+(/.*)$`)
220+
221+
// parsePath normalizes the user-supplied API path against the configured base
222+
// URL. It accepts relative paths ("projects.json", "/projects.json") and
223+
// absolute Basecamp URLs whose host matches baseURL — from which the path is
224+
// extracted and a leading /<account-id> segment dropped (the SDK re-prefixes
225+
// the configured account). Absolute URLs on ANY other host are rejected so the
226+
// bearer token is never attached to a request bound for a foreign host.
227+
//
228+
// A stray leading slash and a mixed-case scheme are normalized first so neither
229+
// "/https://evil/…" nor "HTTPS://evil/…" can smuggle an absolute URL past the
230+
// host check (URL schemes are case-insensitive per RFC 3986 §3.1).
231+
func parsePath(input, baseURL string) (string, error) {
232+
candidate := strings.TrimPrefix(input, "/")
233+
lower := strings.ToLower(candidate)
234+
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
235+
u, err := url.Parse(candidate)
236+
if err != nil || u.Host == "" {
237+
return "", output.ErrUsage("invalid API URL: " + input)
238+
}
239+
base, baseErr := url.Parse(baseURL)
240+
if baseErr != nil || base.Host == "" || !strings.EqualFold(u.Host, base.Host) {
241+
return "", output.ErrUsage("API path must be relative or a Basecamp URL on the configured host; refusing to send credentials to " + input)
242+
}
243+
// Same host: use the path (+ query), dropping a leading /<account-id>.
244+
path := u.EscapedPath()
245+
if m := accountSegmentPattern.FindStringSubmatch(path); m != nil {
246+
path = m[1]
247+
}
248+
if u.RawQuery != "" {
249+
path += "?" + u.RawQuery
250+
}
251+
return path, nil
212252
}
213253

214-
// Strip leading slashthe SDK prefixes the account path.
215-
input = strings.TrimPrefix(input, "/")
216-
217-
return input
254+
// Relative path — return without the leading slash; the SDK prefixes the
255+
// account path (keeping it here would double-slash and, on Windows,
256+
// MSYS/Git Bash converts /path to C:\...\path).
257+
return candidate, nil
218258
}
219259

220260
// apiSummary generates a summary from the API response.

internal/commands/api_test.go

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"github.com/stretchr/testify/require"
99
)
1010

11+
const testBaseURL = "https://3.basecampapi.com"
12+
1113
func TestParsePath(t *testing.T) {
1214
tests := []struct {
1315
input string
@@ -17,13 +19,51 @@ func TestParsePath(t *testing.T) {
1719
{"/projects.json", "projects.json"},
1820
{"buckets/123/todos/456.json", "buckets/123/todos/456.json"},
1921
{"/buckets/123/todos/456.json", "buckets/123/todos/456.json"},
22+
// Same-host absolute URLs: extract the path, dropping the account segment.
2023
{"https://3.basecampapi.com/999/projects.json", "/projects.json"},
2124
{"https://3.basecampapi.com/12345/buckets/1/todos/2.json", "/buckets/1/todos/2.json"},
25+
// Same host but no account segment — accepted, path used as-is.
26+
{"https://3.basecampapi.com/projects.json", "/projects.json"},
27+
// Query strings are preserved.
28+
{"https://3.basecampapi.com/999/projects.json?page=2", "/projects.json?page=2"},
29+
// Schemes are case-insensitive (RFC 3986): a same-host uppercase scheme
30+
// still extracts the path.
31+
{"HTTPS://3.basecampapi.com/999/projects.json", "/projects.json"},
2232
}
2333

2434
for _, tt := range tests {
2535
t.Run(tt.input, func(t *testing.T) {
26-
assert.Equal(t, tt.want, parsePath(tt.input))
36+
got, err := parsePath(tt.input, testBaseURL)
37+
require.NoError(t, err)
38+
assert.Equal(t, tt.want, got)
39+
})
40+
}
41+
}
42+
43+
// TestParsePathRejectsForeignHosts guards against bearer-token exfiltration: an
44+
// absolute URL whose host differs from the configured base URL must be rejected
45+
// before it reaches the SDK, which would otherwise attach the Authorization
46+
// header and send credentials to the foreign host. Covers mixed-case schemes
47+
// and a leading-slash smuggling attempt.
48+
func TestParsePathRejectsForeignHosts(t *testing.T) {
49+
bad := []string{
50+
"https://evil.com/projects.json",
51+
"http://evil.com/projects.json",
52+
"https://evil.com/",
53+
"https://evil.com",
54+
"https://evil.example/999/projects.json", // foreign host, numeric form
55+
"http://127.0.0.1:9999/projects.json",
56+
"HTTPS://evil.example/projects.json", // uppercase scheme
57+
"HtTpS://evil.example/projects.json", // mixed-case scheme
58+
"/https://evil.example/projects.json", // leading-slash smuggling
59+
"/HTTPS://evil.example/projects.json", // leading slash + uppercase
60+
}
61+
62+
for _, input := range bad {
63+
t.Run(input, func(t *testing.T) {
64+
got, err := parsePath(input, testBaseURL)
65+
require.Error(t, err)
66+
assert.Empty(t, got)
2767
})
2868
}
2969
}

0 commit comments

Comments
 (0)