Skip to content

Commit cfcd463

Browse files
coder-backporter-app[bot]dylanhuff-at-coderclaude
authored
fix: only add the session token to trusted app links in coder open app (#31721) (conflicts) (#31797)
Backport of #31721 Original PR: #31721 — fix: only add the session token to trusted app links in coder open app Merge commit: 34a14cb Requested by: @dylanhuff-at-coder --------- Co-authored-by: coder-backporter-app[bot] <335582564+coder-backporter-app[bot]@users.noreply.github.com> Co-authored-by: Dylan Huff <dylan@coder.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent a882236 commit cfcd463

7 files changed

Lines changed: 705 additions & 184 deletions

File tree

‎cli/open.go‎

Lines changed: 229 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@ import (
88
"net/url"
99
"path"
1010
"path/filepath"
11+
"regexp"
1112
"runtime"
1213
"slices"
14+
"strconv"
1315
"strings"
1416
"time"
17+
"unicode"
18+
"unicode/utf8"
1519

1620
"github.com/google/uuid"
1721
"github.com/skratchdot/open-golang/open"
@@ -41,7 +45,8 @@ const vscodeDesktopName = "VS Code Desktop"
4145

4246
// externalSessionTokenPlaceholder is the literal substring in an external
4347
// workspace-app URL that the CLI replaces with the user's session token
44-
// when the app belongs to a trusted (top-level) agent.
48+
// when the app belongs to a top-level agent and its URL is trusted (see
49+
// sessionTokenDestinationTrusted).
4550
const externalSessionTokenPlaceholder = "$SESSION_TOKEN"
4651

4752
func (r *RootCmd) openVSCode() *serpent.Command {
@@ -392,12 +397,27 @@ func (r *RootCmd) openApp() *serpent.Command {
392397
pathAppURL := strings.TrimPrefix(region.PathAppURL, baseURL.String())
393398
appURL := buildAppLinkURL(baseURL, ws, agt, foundApp, region.WildcardHostname, pathAppURL)
394399

395-
externalSubAgentApp := foundApp.External && agt.ParentID.Valid
396-
if foundApp.External && !agt.ParentID.Valid {
397-
// Template-defined apps run on a top-level agent and are
398-
// admin-authored, so their URLs are trusted. Substitute the
399-
// session token placeholder so the OS open handler receives
400-
// a usable URL.
400+
if foundApp.External && strings.Contains(appURL, externalSessionTokenPlaceholder) {
401+
// Sub-agent external app URLs are set at runtime, so they
402+
// never receive the session token.
403+
if agt.ParentID.Valid {
404+
cliui.Warnf(inv.Stderr,
405+
"This app was registered from inside the workspace rather than from the workspace template. "+
406+
"Inspect the URL below carefully and, if you trust the source, substitute the $SESSION_TOKEN placeholder "+
407+
"with your session token and manually open it:")
408+
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", appURL)
409+
return nil
410+
}
411+
// Template-defined apps only receive the session token when
412+
// they open a known Coder IDE handler pointed at this
413+
// deployment, matching the dashboard.
414+
if !sessionTokenDestinationTrusted(appURL, sessionTokenOrigins(ctx, client)) {
415+
cliui.Warnf(inv.Stderr,
416+
"This app's URL isn't trusted to sign you in to Coder, so your session token was not added. "+
417+
"If this app should sign you in, ask your template administrator to fix its URL:")
418+
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", appURL)
419+
return nil
420+
}
401421
appURL = strings.ReplaceAll(appURL, externalSessionTokenPlaceholder, client.SessionToken())
402422
}
403423

@@ -409,18 +429,6 @@ func (r *RootCmd) openApp() *serpent.Command {
409429
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", appURL)
410430
return nil
411431
}
412-
413-
// Sub-agent external app URLs are set at runtime. Only open
414-
// sub-agent URLs that don't contain the placeholder to prevent
415-
// token exfiltration.
416-
if externalSubAgentApp && strings.Contains(appURL, externalSessionTokenPlaceholder) {
417-
cliui.Warnf(inv.Stderr,
418-
"This app was registered from inside the workspace rather than from the workspace template. "+
419-
"Inspect the URL below carefully and, if you trust the source, substitute the $SESSION_TOKEN placeholder "+
420-
"with your session token and manually open it:")
421-
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", appURL)
422-
return nil
423-
}
424432
_, _ = fmt.Fprintf(inv.Stderr, "Opening %s\n", appURL)
425433

426434
if !testOpenError {
@@ -686,3 +694,205 @@ func buildAppLinkURL(baseURL *url.URL, workspace codersdk.Workspace, agent coder
686694
}
687695
return u.String()
688696
}
697+
698+
type sessionTokenHandler struct {
699+
// base is the scheme and authority, matched case-insensitively.
700+
base string
701+
// path runs through the first required param, matched exactly.
702+
path string
703+
}
704+
705+
// sessionTokenHandlers are the only deep-link handlers that may receive the
706+
// session token. Keep in sync with SESSION_TOKEN_HANDLERS in
707+
// site/src/modules/apps/apps.ts.
708+
var sessionTokenHandlers = func() []sessionTokenHandler {
709+
var handlers []sessionTokenHandler
710+
for _, scheme := range []string{
711+
"vscode",
712+
"vscode-insiders",
713+
"windsurf",
714+
"devin",
715+
"cursor",
716+
"kiro",
717+
"positron",
718+
"antigravity",
719+
"antigravity-ide",
720+
} {
721+
for _, p := range []string{"/open?", "/openDevContainer?"} {
722+
handlers = append(handlers, sessionTokenHandler{base: scheme + "://coder.coder-remote", path: p})
723+
}
724+
}
725+
return append(handlers,
726+
sessionTokenHandler{base: "jetbrains://gateway", path: "/coder?"},
727+
sessionTokenHandler{base: "jetbrains://gateway", path: "/com.coder.toolbox?"},
728+
// Gateway also handles non-Coder providers, such as `type=ssh`.
729+
sessionTokenHandler{base: "jetbrains-gateway://connect", path: "#type=coder&"},
730+
)
731+
}()
732+
733+
// sessionTokenOrigins returns the origins that external app links may send
734+
// the session token to: the URL the CLI is logged in to and, when the server
735+
// reports one, the deployment's dashboard URL.
736+
func sessionTokenOrigins(ctx context.Context, client *codersdk.Client) []string {
737+
origins := []string{urlOrigin(client.URL)}
738+
// Without build info, only links to the URL the CLI uses are trusted.
739+
buildInfo, err := client.BuildInfo(ctx)
740+
if err != nil {
741+
return origins
742+
}
743+
if u, err := url.Parse(buildInfo.DashboardURL); err == nil && u.Host != "" {
744+
origins = append(origins, urlOrigin(u))
745+
}
746+
return origins
747+
}
748+
749+
// sessionTokenDestinationTrusted reports whether appURL opens a known Coder
750+
// handler, its only `url` param is one of origins, and the placeholder is its
751+
// only `token`. It mirrors isSessionTokenDestinationTrusted in
752+
// site/src/modules/apps/apps.ts, which documents each check. Both test suites
753+
// run the cases in site/src/modules/apps/sessionTokenDestinationCases.json, so
754+
// add new cases there.
755+
func sessionTokenDestinationTrusted(appURL string, origins []string) bool {
756+
// IDEs and browsers strip some control characters, so `ur\tl` could
757+
// become a second `url` param after this check.
758+
if !utf8.ValidString(appURL) ||
759+
strings.IndexFunc(appURL, unicode.IsControl) != -1 ||
760+
strings.Count(appURL, externalSessionTokenPlaceholder) != 1 {
761+
return false
762+
}
763+
handlerIdx := slices.IndexFunc(sessionTokenHandlers, func(h sessionTokenHandler) bool {
764+
return len(appURL) >= len(h.base) &&
765+
asciiLower(appURL[:len(h.base)]) == h.base &&
766+
strings.HasPrefix(appURL[len(h.base):], h.path)
767+
})
768+
if handlerIdx == -1 {
769+
return false
770+
}
771+
handler := sessionTokenHandlers[handlerIdx]
772+
params := appURL[len(handler.base)+strings.IndexAny(handler.path, "?#")+1:]
773+
// A later `#` would hide params from IDEs that read only the query.
774+
if strings.Contains(params, "#") {
775+
return false
776+
}
777+
// The placeholder must be the raw `token` entry, and names must be
778+
// literal because JetBrains Gateway doesn't decode them.
779+
entries := strings.Split(params, "&")
780+
if !slices.Contains(entries, "token="+externalSessionTokenPlaceholder) {
781+
return false
782+
}
783+
for _, entry := range entries {
784+
name, _, _ := strings.Cut(entry, "=")
785+
if strings.Contains(name, "%") {
786+
return false
787+
}
788+
}
789+
// IDEs decode values twice, so check that view too and reject leftovers.
790+
decoded, err := url.PathUnescape(params)
791+
if err != nil || !utf8.ValidString(decoded) || strings.Contains(decoded, "%") {
792+
return false
793+
}
794+
return hasTrustedSessionTokenParams(params, origins) &&
795+
hasTrustedSessionTokenParams(decoded, origins)
796+
}
797+
798+
// hasTrustedSessionTokenParams reports whether every name in rawQuery appears
799+
// once, `token` is the placeholder, and `url` is one of origins.
800+
func hasTrustedSessionTokenParams(rawQuery string, origins []string) bool {
801+
values, ok := parseSearchParams(rawQuery)
802+
if !ok {
803+
return false
804+
}
805+
for _, v := range values {
806+
if len(v) != 1 {
807+
return false
808+
}
809+
}
810+
return values.Get("token") == externalSessionTokenPlaceholder &&
811+
isTrustedBareOrigin(values.Get("url"), origins)
812+
}
813+
814+
// parseSearchParams parses rawQuery like URLSearchParams, which IDEs use. It
815+
// drops one leading `?`, so `??url=a&url=b` can't hide the duplicate `url`
816+
// behind a `?url` name, and splits on `&` only, so a `;` in a value is kept
817+
// rather than rejected as url.ParseQuery does. Unlike URLSearchParams, it
818+
// rejects invalid percent escapes.
819+
func parseSearchParams(rawQuery string) (url.Values, bool) {
820+
values := url.Values{}
821+
for _, entry := range strings.Split(strings.TrimPrefix(rawQuery, "?"), "&") {
822+
if entry == "" {
823+
continue
824+
}
825+
rawName, rawValue, _ := strings.Cut(entry, "=")
826+
name, err := url.QueryUnescape(rawName)
827+
if err != nil {
828+
return nil, false
829+
}
830+
value, err := url.QueryUnescape(rawValue)
831+
if err != nil {
832+
return nil, false
833+
}
834+
values.Add(name, value)
835+
}
836+
return values, true
837+
}
838+
839+
// bareOriginRegex matches a URL that is only an origin, optionally with a
840+
// trailing slash. A path could reach a workspace app on this deployment, and
841+
// userinfo can hide a different host from IDEs that decode it again.
842+
var bareOriginRegex = regexp.MustCompile(`(?i)^[a-z][a-z\d+.-]*://[^/\\?#@\s]+/?$`)
843+
844+
func isTrustedBareOrigin(rawURL string, origins []string) bool {
845+
if !bareOriginRegex.MatchString(rawURL) {
846+
return false
847+
}
848+
u, err := url.Parse(rawURL)
849+
if err != nil {
850+
return false
851+
}
852+
// Browsers map non-ASCII hosts through IDNA, which this comparison doesn't
853+
// model, so only ASCII hosts are trusted.
854+
if strings.IndexFunc(u.Hostname(), func(r rune) bool { return r > unicode.MaxASCII }) != -1 {
855+
return false
856+
}
857+
origin := urlOrigin(u)
858+
return origin != "" && slices.Contains(origins, origin)
859+
}
860+
861+
// urlOrigin returns the scheme, host, and non-default port of u, with ASCII
862+
// letters lowercased and the port in canonical form, like a browser origin.
863+
// Unicode case folding would turn hosts such as `codİng.com` into a
864+
// different, trusted host. It returns "" when the port is out of range.
865+
func urlOrigin(u *url.URL) string {
866+
scheme := asciiLower(u.Scheme)
867+
host := asciiLower(u.Hostname())
868+
port := u.Port()
869+
if port != "" {
870+
n, err := strconv.ParseUint(port, 10, 16)
871+
if err != nil {
872+
return ""
873+
}
874+
port = strconv.FormatUint(n, 10)
875+
}
876+
if (scheme == "http" && port == "80") || (scheme == "https" && port == "443") {
877+
port = ""
878+
}
879+
if strings.Contains(host, ":") {
880+
host = "[" + host + "]"
881+
}
882+
if port != "" {
883+
host += ":" + port
884+
}
885+
return scheme + "://" + host
886+
}
887+
888+
// asciiLower lowercases ASCII letters only, so non-ASCII characters that
889+
// Unicode folds to ASCII, such as the Kelvin sign, never match a handler.
890+
func asciiLower(s string) string {
891+
b := []byte(s)
892+
for i, c := range b {
893+
if 'A' <= c && c <= 'Z' {
894+
b[i] = c + ('a' - 'A')
895+
}
896+
}
897+
return string(b)
898+
}

0 commit comments

Comments
 (0)