package coderd import ( "bytes" "context" "crypto/sha256" "database/sql" "encoding/base64" "encoding/json" "fmt" "net/http" "net/http/httputil" "net/url" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "go.opentelemetry.io/otel/trace" "golang.org/x/xerrors" jose "gopkg.in/square/go-jose.v2" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/coderd/tracing" "github.com/coder/coder/codersdk" "github.com/coder/coder/site" ) const ( // This needs to be a super unique query parameter because we don't want to // conflict with query parameters that users may use. // TODO: this will make dogfooding harder so come up with a more unique // solution //nolint:gosec subdomainProxyAPIKeyParam = "coder_application_connect_api_key_35e783" redirectURIQueryParam = "redirect_uri" ) func (api *API) appHost(rw http.ResponseWriter, r *http.Request) { httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.GetAppHostResponse{ Host: api.AppHostname, }) } // workspaceAppsProxyPath proxies requests to a workspace application // through a relative URL path. func (api *API) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request) { workspace := httpmw.WorkspaceParam(r) agent := httpmw.WorkspaceAgentParam(r) if !api.Authorize(r, rbac.ActionCreate, workspace.ApplicationConnectRBAC()) { httpapi.ResourceNotFound(rw) return } // Determine the real path that was hit. The * URL parameter in Chi will not // include the leading slash if it was present, so we need to add it back. chiPath := chi.URLParam(r, "*") basePath := strings.TrimSuffix(r.URL.Path, chiPath) if strings.HasSuffix(basePath, "/") { chiPath = "/" + chiPath } api.proxyWorkspaceApplication(proxyApplication{ Workspace: workspace, Agent: agent, // We do not support port proxying for paths. AppName: chi.URLParam(r, "workspaceapp"), Port: 0, Path: chiPath, }, rw, r) } // handleSubdomainApplications handles subdomain-based application proxy // requests (aka. DevURLs in Coder V1). // // There are a lot of paths here: // 1. If api.AppHostname is not set then we pass on. // 2. If we can't read the request hostname then we return a 400. // 3. If the request hostname matches api.AccessURL then we pass on. // 5. We split the subdomain into the subdomain and the "rest". If there are no // periods in the hostname then we pass on. // 5. We parse the subdomain into a httpapi.ApplicationURL struct. If we // encounter an error: // a. If the "rest" does not match api.AppHostname then we pass on; // b. Otherwise, we return a 400. // 6. Finally, we verify that the "rest" matches api.AppHostname, else we // return a 404. // // Rationales for each of the above steps: // 1. We pass on if api.AppHostname is not set to avoid returning any errors if // `--app-hostname` is not configured. // 2. Every request should have a valid Host header anyways. // 3. We pass on if the request hostname matches api.AccessURL so we can // support having the access URL be at the same level as the application // base hostname. // 4. We pass on if there are no periods in the hostname as application URLs // must be a subdomain of a hostname, which implies there must be at least // one period. // 5. a. If the request subdomain is not a valid application URL, and the // "rest" does not match api.AppHostname, then it is very unlikely that // the request was intended for this handler. We pass on. // b. If the request subdomain is not a valid application URL, but the // "rest" matches api.AppHostname, then we return a 400 because the // request is probably a typo or something. // 6. We finally verify that the "rest" matches api.AppHostname for security // purposes regarding re-authentication and application proxy session // tokens. func (api *API) handleSubdomainApplications(middlewares ...func(http.Handler) http.Handler) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() // Step 1: Pass on if subdomain-based application proxying is not // configured. if api.AppHostname == "" { next.ServeHTTP(rw, r) return } // Step 2: Get the request Host. host := httpapi.RequestHost(r) if host == "" { if r.URL.Path == "/derp" { // The /derp endpoint is used by wireguard clients to tunnel // through coderd. For some reason these requests don't set // a Host header properly sometimes in tests (no idea how), // which causes this path to get hit. next.ServeHTTP(rw, r) return } httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Could not determine request Host.", }) return } // Steps 3-6: Parse application from subdomain. app, ok := api.parseWorkspaceApplicationHostname(rw, r, next, host) if !ok { return } workspaceAgentKey := fmt.Sprintf("%s.%s", app.WorkspaceName, app.AgentName) chiCtx := chi.RouteContext(ctx) chiCtx.URLParams.Add("workspace_and_agent", workspaceAgentKey) chiCtx.URLParams.Add("user", app.Username) // Use the passed in app middlewares before passing to the proxy app. mws := chi.Middlewares(middlewares) mws.Handler(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { workspace := httpmw.WorkspaceParam(r) agent := httpmw.WorkspaceAgentParam(r) // Verify application auth. This function will redirect or // return an error page if the user doesn't have permission. if !api.verifyWorkspaceApplicationAuth(rw, r, workspace, host) { return } api.proxyWorkspaceApplication(proxyApplication{ Workspace: workspace, Agent: agent, AppName: app.AppName, Port: app.Port, Path: r.URL.Path, }, rw, r) })).ServeHTTP(rw, r.WithContext(ctx)) }) } } func (api *API) parseWorkspaceApplicationHostname(rw http.ResponseWriter, r *http.Request, next http.Handler, host string) (httpapi.ApplicationURL, bool) { // Check if the hostname matches the access URL. If it does, the user was // definitely trying to connect to the dashboard/API. if httpapi.HostnamesMatch(api.AccessURL.Hostname(), host) { next.ServeHTTP(rw, r) return httpapi.ApplicationURL{}, false } // Split the subdomain so we can parse the application details and verify it // matches the configured app hostname later. subdomain, rest := httpapi.SplitSubdomain(host) if rest == "" { // If there are no periods in the hostname, then it can't be a valid // application URL. next.ServeHTTP(rw, r) return httpapi.ApplicationURL{}, false } matchingBaseHostname := httpapi.HostnamesMatch(api.AppHostname, rest) // Parse the application URL from the subdomain. app, err := httpapi.ParseSubdomainAppURL(subdomain) if err != nil { // If it isn't a valid app URL and the base domain doesn't match the // configured app hostname, this request was probably destined for the // dashboard/API router. if !matchingBaseHostname { next.ServeHTTP(rw, r) return httpapi.ApplicationURL{}, false } site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ Status: http.StatusBadRequest, Title: "Invalid application URL", Description: fmt.Sprintf("Could not parse subdomain application URL %q: %s", subdomain, err.Error()), RetryEnabled: false, DashboardURL: api.AccessURL.String(), }) return httpapi.ApplicationURL{}, false } // At this point we've verified that the subdomain looks like a valid // application URL, so the base hostname should match the configured app // hostname. if !matchingBaseHostname { site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ Status: http.StatusNotFound, Title: "Not Found", Description: "The server does not accept application requests on this hostname.", RetryEnabled: false, DashboardURL: api.AccessURL.String(), }) return httpapi.ApplicationURL{}, false } return app, true } // verifyWorkspaceApplicationAuth checks that the request is authorized to // access the given application. If the user does not have a app session key, // they will be redirected to the route below. If the user does have a session // key but insufficient permissions a static error page will be rendered. func (api *API) verifyWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.Request, workspace database.Workspace, host string) bool { _, ok := httpmw.APIKeyOptional(r) if ok { if !api.Authorize(r, rbac.ActionCreate, workspace.ApplicationConnectRBAC()) { renderApplicationNotFound(rw, r, api.AccessURL) return false } // Request should be all good to go! return true } // If the request has the special query param then we need to set a cookie // and strip that query parameter. if encryptedAPIKey := r.URL.Query().Get(subdomainProxyAPIKeyParam); encryptedAPIKey != "" { // Exchange the encoded API key for a real one. _, apiKey, err := decryptAPIKey(r.Context(), api.Database, encryptedAPIKey) if err != nil { site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ Status: http.StatusBadRequest, Title: "Bad Request", Description: "Could not decrypt API key. Please remove the query parameter and try again.", // Retry is disabled because the user needs to remove the query // parameter before they try again. RetryEnabled: false, DashboardURL: api.AccessURL.String(), }) return false } // Set the app cookie for all subdomains of api.AppHostname. This cookie // is handled properly by the ExtractAPIKey middleware. http.SetCookie(rw, &http.Cookie{ Name: httpmw.DevURLSessionTokenCookie, Value: apiKey, Domain: "." + api.AppHostname, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: api.SecureAuthCookie, }) // Strip the query parameter. path := r.URL.Path if path == "" { path = "/" } q := r.URL.Query() q.Del(subdomainProxyAPIKeyParam) rawQuery := q.Encode() if rawQuery != "" { path += "?" + q.Encode() } http.Redirect(rw, r, path, http.StatusTemporaryRedirect) return false } // If the user doesn't have a session key, redirect them to the API endpoint // for application auth. redirectURI := *r.URL redirectURI.Scheme = api.AccessURL.Scheme redirectURI.Host = host u := *api.AccessURL u.Path = "/api/v2/applications/auth-redirect" q := u.Query() q.Add(redirectURIQueryParam, redirectURI.String()) u.RawQuery = q.Encode() http.Redirect(rw, r, u.String(), http.StatusTemporaryRedirect) return false } // workspaceApplicationAuth is an endpoint on the main router that handles // redirects from the subdomain handler. // // This endpoint is under /api so we don't return the friendly error page here. // Any errors on this endpoint should be errors that are unlikely to happen // in production unless the user messes with the URL. func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() if api.AppHostname == "" { httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ Message: "The server does not accept subdomain-based application requests.", }) return } apiKey := httpmw.APIKey(r) if !api.Authorize(r, rbac.ActionCreate, rbac.ResourceAPIKey.WithOwner(apiKey.UserID.String())) { httpapi.ResourceNotFound(rw) return } // Get the redirect URI from the query parameters and parse it. redirectURI := r.URL.Query().Get(redirectURIQueryParam) if redirectURI == "" { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Missing redirect_uri query parameter.", }) return } u, err := url.Parse(redirectURI) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid redirect_uri query parameter.", Detail: err.Error(), }) return } // Force the redirect URI to use the same scheme as the access URL for // security purposes. u.Scheme = api.AccessURL.Scheme // Ensure that the redirect URI is a subdomain of api.AppHostname and is a // valid app subdomain. subdomain, rest := httpapi.SplitSubdomain(u.Hostname()) if !httpapi.HostnamesMatch(api.AppHostname, rest) { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "The redirect_uri query parameter must be a valid app subdomain.", }) return } _, err = httpapi.ParseSubdomainAppURL(subdomain) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "The redirect_uri query parameter must be a valid app subdomain.", Detail: err.Error(), }) return } // Create the application_connect-scoped API key with the same lifetime as // the current session (defaulting to 1 day, capped to 1 week). exp := apiKey.ExpiresAt if exp.IsZero() { exp = database.Now().Add(time.Hour * 24) } if time.Until(exp) > time.Hour*24*7 { exp = database.Now().Add(time.Hour * 24 * 7) } lifetime := apiKey.LifetimeSeconds if lifetime > int64((time.Hour * 24 * 7).Seconds()) { lifetime = int64((time.Hour * 24 * 7).Seconds()) } cookie, err := api.createAPIKey(ctx, createAPIKeyParams{ UserID: apiKey.UserID, LoginType: database.LoginTypePassword, ExpiresAt: exp, LifetimeSeconds: lifetime, Scope: database.APIKeyScopeApplicationConnect, }) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to create API key.", Detail: err.Error(), }) return } // Encrypt the API key. encryptedAPIKey, err := encryptAPIKey(encryptedAPIKeyPayload{ APIKey: cookie.Value, }) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to encrypt API key.", Detail: err.Error(), }) return } // Redirect to the redirect URI with the encrypted API key in the query // parameters. q := u.Query() q.Set(subdomainProxyAPIKeyParam, encryptedAPIKey) u.RawQuery = q.Encode() http.Redirect(rw, r, u.String(), http.StatusTemporaryRedirect) } // proxyApplication are the required fields to proxy a workspace application. type proxyApplication struct { Workspace database.Workspace Agent database.WorkspaceAgent // Either AppName or Port must be set, but not both. AppName string Port uint16 // Path must either be empty or have a leading slash. Path string } func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.ResponseWriter, r *http.Request) { ctx := r.Context() if !api.Authorize(r, rbac.ActionCreate, proxyApp.Workspace.ApplicationConnectRBAC()) { httpapi.ResourceNotFound(rw) return } // If the app does not exist, but the app name is a port number, then // route to the port as an "anonymous app". We only support HTTP for // port-based URLs. internalURL := fmt.Sprintf("http://127.0.0.1:%d", proxyApp.Port) // If the app name was used instead, fetch the app from the database so we // can get the internal URL. if proxyApp.AppName != "" { app, err := api.Database.GetWorkspaceAppByAgentIDAndName(ctx, database.GetWorkspaceAppByAgentIDAndNameParams{ AgentID: proxyApp.Agent.ID, Name: proxyApp.AppName, }) if xerrors.Is(err, sql.ErrNoRows) { renderApplicationNotFound(rw, r, api.AccessURL) return } if err != nil { site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ Status: http.StatusInternalServerError, Title: "Internal Server Error", Description: "Could not fetch workspace application: " + err.Error(), RetryEnabled: true, DashboardURL: api.AccessURL.String(), }) return } if !app.Url.Valid { site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ Status: http.StatusBadRequest, Title: "Bad Request", Description: fmt.Sprintf("Application %q does not have a URL set.", app.Name), RetryEnabled: true, DashboardURL: api.AccessURL.String(), }) return } internalURL = app.Url.String } appURL, err := url.Parse(internalURL) if err != nil { site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ Status: http.StatusBadRequest, Title: "Bad Request", Description: fmt.Sprintf("Application has an invalid URL %q: %s", internalURL, err.Error()), RetryEnabled: true, DashboardURL: api.AccessURL.String(), }) return } // Verify that the port is allowed. See the docs above // `codersdk.MinimumListeningPort` for more details. port := appURL.Port() if port != "" { portInt, err := strconv.Atoi(port) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: fmt.Sprintf("App URL %q has an invalid port %q.", internalURL, port), Detail: err.Error(), }) return } if portInt < codersdk.MinimumListeningPort { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: fmt.Sprintf("Application port %d is not permitted. Coder reserves ports less than %d for internal use.", portInt, codersdk.MinimumListeningPort), }) return } } // Ensure path and query parameter correctness. if proxyApp.Path == "" { // Web applications typically request paths relative to the // root URL. This allows for routing behind a proxy or subpath. // See https://github.com/coder/code-server/issues/241 for examples. http.Redirect(rw, r, r.URL.Path+"/", http.StatusTemporaryRedirect) return } if proxyApp.Path == "/" && r.URL.RawQuery == "" && appURL.RawQuery != "" { // If the application defines a default set of query parameters, // we should always respect them. The reverse proxy will merge // query parameters for server-side requests, but sometimes // client-side applications require the query parameters to render // properly. With code-server, this is the "folder" param. r.URL.RawQuery = appURL.RawQuery http.Redirect(rw, r, r.URL.String(), http.StatusTemporaryRedirect) return } r.URL.Path = proxyApp.Path appURL.RawQuery = "" proxy := httputil.NewSingleHostReverseProxy(appURL) proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ Status: http.StatusBadGateway, Title: "Bad Gateway", Description: "Failed to proxy request to application: " + err.Error(), RetryEnabled: true, DashboardURL: api.AccessURL.String(), }) } conn, release, err := api.workspaceAgentCache.Acquire(r, proxyApp.Agent.ID) if err != nil { site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ Status: http.StatusBadGateway, Title: "Bad Gateway", Description: "Could not connect to workspace agent: " + err.Error(), RetryEnabled: true, DashboardURL: api.AccessURL.String(), }) return } defer release() // This strips the session token from a workspace app request. cookieHeaders := r.Header.Values("Cookie")[:] r.Header.Del("Cookie") for _, cookieHeader := range cookieHeaders { r.Header.Add("Cookie", httpapi.StripCoderCookies(cookieHeader)) } proxy.Transport = conn.HTTPTransport() // end span so we don't get long lived trace data tracing.EndHTTPSpan(r, http.StatusOK, trace.SpanFromContext(ctx)) proxy.ServeHTTP(rw, r) } type encryptedAPIKeyPayload struct { APIKey string `json:"api_key"` ExpiresAt time.Time `json:"expires_at"` } // encryptAPIKey encrypts an API key with it's own hashed secret. This is used // for smuggling (application_connect scoped) API keys securely to app // hostnames. // // We encrypt API keys when smuggling them in query parameters to avoid them // getting accidentally logged in access logs or stored in browser history. func encryptAPIKey(data encryptedAPIKeyPayload) (string, error) { if data.APIKey == "" { return "", xerrors.New("API key is empty") } if data.ExpiresAt.IsZero() { // Very short expiry as these keys are only used once as part of an // automatic redirection flow. data.ExpiresAt = database.Now().Add(time.Minute) } payload, err := json.Marshal(data) if err != nil { return "", xerrors.Errorf("marshal payload: %w", err) } // We use the hashed key secret as the encryption key. The hashed secret is // stored in the API keys table. The HashedSecret is NEVER returned from the // API. // // We chose to use the key secret as the private key for encryption instead // of a shared key for a few reasons: // 1. A single private key used to encrypt every API key would also be // stored in the database, which means that the risk factor is similar. // 2. The secret essentially rotates for each key (for free!), since each // key has a different secret. This means that if someone acquires an // old database dump they can't decrypt new API keys. // 3. These tokens are scoped only for application_connect access. keyID, keySecret, err := httpmw.SplitAPIToken(data.APIKey) if err != nil { return "", xerrors.Errorf("split API key: %w", err) } // SHA256 the key secret so it matches the hashed secret in the database. // The key length doesn't matter to the jose.Encrypter. privateKey := sha256.Sum256([]byte(keySecret)) // JWEs seem to apply a nonce themselves. encrypter, err := jose.NewEncrypter( jose.A256GCM, jose.Recipient{ Algorithm: jose.A256GCMKW, KeyID: keyID, Key: privateKey[:], }, &jose.EncrypterOptions{ Compression: jose.DEFLATE, }, ) if err != nil { return "", xerrors.Errorf("initializer jose encrypter: %w", err) } encryptedObject, err := encrypter.Encrypt(payload) if err != nil { return "", xerrors.Errorf("encrypt jwe: %w", err) } encrypted := encryptedObject.FullSerialize() return base64.RawURLEncoding.EncodeToString([]byte(encrypted)), nil } // decryptAPIKey undoes encryptAPIKey and is used in the subdomain app handler. func decryptAPIKey(ctx context.Context, db database.Store, encryptedAPIKey string) (database.APIKey, string, error) { encrypted, err := base64.RawURLEncoding.DecodeString(encryptedAPIKey) if err != nil { return database.APIKey{}, "", xerrors.Errorf("base64 decode encrypted API key: %w", err) } object, err := jose.ParseEncrypted(string(encrypted)) if err != nil { return database.APIKey{}, "", xerrors.Errorf("parse encrypted API key: %w", err) } // Lookup the API key so we can decrypt it. keyID := object.Header.KeyID key, err := db.GetAPIKeyByID(ctx, keyID) if err != nil { return database.APIKey{}, "", xerrors.Errorf("get API key by key ID: %w", err) } // Decrypt using the hashed secret. decrypted, err := object.Decrypt(key.HashedSecret) if err != nil { return database.APIKey{}, "", xerrors.Errorf("decrypt API key: %w", err) } // Unmarshal the payload. var payload encryptedAPIKeyPayload if err := json.Unmarshal(decrypted, &payload); err != nil { return database.APIKey{}, "", xerrors.Errorf("unmarshal decrypted payload: %w", err) } // Validate expiry. if payload.ExpiresAt.Before(database.Now()) { return database.APIKey{}, "", xerrors.New("encrypted API key expired") } // Validate that the key matches the one we got from the DB. gotKeyID, gotKeySecret, err := httpmw.SplitAPIToken(payload.APIKey) if err != nil { return database.APIKey{}, "", xerrors.Errorf("split API key: %w", err) } gotHashedSecret := sha256.Sum256([]byte(gotKeySecret)) if gotKeyID != key.ID || !bytes.Equal(key.HashedSecret, gotHashedSecret[:]) { return database.APIKey{}, "", xerrors.New("encrypted API key does not match key in database") } return key, payload.APIKey, nil } // renderApplicationNotFound should always be used when the app is not found or // the current user doesn't have permission to access it. func renderApplicationNotFound(rw http.ResponseWriter, r *http.Request, accessURL *url.URL) { site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ Status: http.StatusNotFound, Title: "Application not found", Description: "The application or workspace you are trying to access does not exist.", RetryEnabled: false, DashboardURL: accessURL.String(), }) }