@@ -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).
4550const externalSessionTokenPlaceholder = "$SESSION_TOKEN"
4651
4752func (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