@@ -3,6 +3,7 @@ package commands
33import (
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 slash — the 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.
0 commit comments