2022-06-04 20:13:37 +00:00
package coderd
import (
2022-09-22 22:30:32 +00:00
"bytes"
"context"
"crypto/sha256"
2022-12-20 18:45:13 +00:00
"crypto/subtle"
2022-10-04 16:30:55 +00:00
"database/sql"
2022-09-22 22:30:32 +00:00
"encoding/base64"
"encoding/json"
2022-06-04 20:13:37 +00:00
"fmt"
"net/http"
"net/http/httputil"
"net/url"
2022-10-06 12:38:22 +00:00
"strconv"
2022-06-04 20:13:37 +00:00
"strings"
2022-09-22 22:30:32 +00:00
"time"
2022-06-04 20:13:37 +00:00
"github.com/go-chi/chi/v5"
2022-10-14 16:46:38 +00:00
"github.com/google/uuid"
2022-09-16 16:43:22 +00:00
"go.opentelemetry.io/otel/trace"
2022-09-22 22:30:32 +00:00
"golang.org/x/xerrors"
jose "gopkg.in/square/go-jose.v2"
2022-06-04 20:13:37 +00:00
2022-10-14 16:46:38 +00:00
"cdr.dev/slog"
2022-06-04 20:13:37 +00:00
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/coderd/rbac"
2022-08-25 16:24:43 +00:00
"github.com/coder/coder/coderd/tracing"
2022-07-13 00:15:02 +00:00
"github.com/coder/coder/codersdk"
2022-06-04 20:13:37 +00:00
"github.com/coder/coder/site"
)
2022-09-22 22:30:32 +00:00
const (
// This needs to be a super unique query parameter because we don't want to
// conflict with query parameters that users may use.
//nolint:gosec
subdomainProxyAPIKeyParam = "coder_application_connect_api_key_35e783"
2022-12-20 18:45:13 +00:00
// redirectURIQueryParam is the query param for the app URL to be passed
// back to the API auth endpoint on the main access URL.
redirectURIQueryParam = "redirect_uri"
// appLogoutHostname is the hostname to use for the logout redirect. When
// the dashboard logs out, it will redirect to this subdomain of the app
// hostname, and the server will remove the cookie and redirect to the main
// login page.
// It is important that this URL can never match a valid app hostname.
appLogoutHostname = "coder-logout"
2022-09-22 22:30:32 +00:00
)
2022-12-07 12:55:02 +00:00
// nonCanonicalHeaders is a map from "canonical" headers to the actual header we
// should send to the app in the workspace. Some headers (such as the websocket
// upgrade headers from RFC 6455) are not canonical according to the HTTP/1
// spec. Golang has said that they will not add custom cases for these headers,
// so we need to do it ourselves.
//
// Some apps our customers use are sensitive to the case of these headers.
//
// https://github.com/golang/go/issues/18495
var nonCanonicalHeaders = map [ string ] string {
"Sec-Websocket-Accept" : "Sec-WebSocket-Accept" ,
"Sec-Websocket-Extensions" : "Sec-WebSocket-Extensions" ,
"Sec-Websocket-Key" : "Sec-WebSocket-Key" ,
"Sec-Websocket-Protocol" : "Sec-WebSocket-Protocol" ,
"Sec-Websocket-Version" : "Sec-WebSocket-Version" ,
}
2022-12-21 14:37:30 +00:00
// @Summary Get applications host
// @ID get-app-host
// @Security CoderSessionToken
// @Produce json
// @Tags Applications
// @Success 200 {object} codersdk.GetAppHostResponse
// @Router /applications/host [get]
2022-09-22 22:30:32 +00:00
func ( api * API ) appHost ( rw http . ResponseWriter , r * http . Request ) {
2022-12-01 20:39:19 +00:00
host := api . AppHostname
2022-12-15 18:43:00 +00:00
if host != "" && api . AccessURL . Port ( ) != "" {
2022-12-01 20:39:19 +00:00
host += fmt . Sprintf ( ":%s" , api . AccessURL . Port ( ) )
}
2022-09-22 22:30:32 +00:00
httpapi . Write ( r . Context ( ) , rw , http . StatusOK , codersdk . GetAppHostResponse {
2022-12-01 20:39:19 +00:00
Host : host ,
2022-09-22 22:30:32 +00:00
} )
}
2022-06-04 20:13:37 +00:00
// workspaceAppsProxyPath proxies requests to a workspace application
// through a relative URL path.
func ( api * API ) workspaceAppsProxyPath ( rw http . ResponseWriter , r * http . Request ) {
2022-08-29 12:56:52 +00:00
workspace := httpmw . WorkspaceParam ( r )
agent := httpmw . WorkspaceAgentParam ( r )
2022-08-12 22:27:48 +00:00
2022-10-28 17:41:31 +00:00
// We do not support port proxying on paths, so lookup the app by slug.
appSlug := chi . URLParam ( r , "workspaceapp" )
app , ok := api . lookupWorkspaceApp ( rw , r , agent . ID , appSlug )
2022-10-14 16:46:38 +00:00
if ! ok {
return
}
appSharingLevel := database . AppSharingLevelOwner
if app . SharingLevel != "" {
appSharingLevel = app . SharingLevel
}
authed , ok := api . fetchWorkspaceApplicationAuth ( rw , r , workspace , appSharingLevel )
if ! ok {
return
}
if ! authed {
_ , hasAPIKey := httpmw . APIKeyOptional ( r )
if hasAPIKey {
// The request has a valid API key but insufficient permissions.
renderApplicationNotFound ( rw , r , api . AccessURL )
return
}
// Redirect to login as they don't have permission to access the app and
// they aren't signed in.
httpmw . RedirectToLogin ( rw , r , httpmw . SignedOutErrorMessage )
2022-06-04 20:13:37 +00:00
return
}
2022-09-13 17:31:33 +00:00
// 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
2022-06-04 20:13:37 +00:00
}
2022-09-13 17:31:33 +00:00
api . proxyWorkspaceApplication ( proxyApplication {
2022-09-16 16:31:08 +00:00
Workspace : workspace ,
Agent : agent ,
2022-10-14 16:46:38 +00:00
App : & app ,
Port : 0 ,
Path : chiPath ,
2022-09-13 17:31:33 +00:00
} , rw , r )
}
2022-09-22 22:30:32 +00:00
// 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.
2022-09-13 17:31:33 +00:00
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 ( )
2022-09-22 22:30:32 +00:00
// Step 1: Pass on if subdomain-based application proxying is not
// configured.
2022-10-14 18:25:11 +00:00
if api . AppHostname == "" || api . AppHostnameRegex == nil {
2022-09-22 22:30:32 +00:00
next . ServeHTTP ( rw , r )
return
}
// Step 2: Get the request Host.
2022-09-13 17:31:33 +00:00
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
2022-09-22 22:30:32 +00:00
// a Host header properly sometimes in tests (no idea how),
// which causes this path to get hit.
2022-09-13 17:31:33 +00:00
next . ServeHTTP ( rw , r )
return
}
2022-09-21 22:07:00 +00:00
httpapi . Write ( ctx , rw , http . StatusBadRequest , codersdk . Response {
2022-09-13 17:31:33 +00:00
Message : "Could not determine request Host." ,
} )
return
}
2022-09-22 22:30:32 +00:00
// Steps 3-6: Parse application from subdomain.
app , ok := api . parseWorkspaceApplicationHostname ( rw , r , next , host )
if ! ok {
2022-09-13 17:31:33 +00:00
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 )
2022-10-14 16:46:38 +00:00
var workspaceAppPtr * database . WorkspaceApp
2022-10-28 17:41:31 +00:00
if app . AppSlug != "" {
workspaceApp , ok := api . lookupWorkspaceApp ( rw , r , agent . ID , app . AppSlug )
2022-10-14 16:46:38 +00:00
if ! ok {
return
}
workspaceAppPtr = & workspaceApp
}
2022-09-22 22:30:32 +00:00
// Verify application auth. This function will redirect or
// return an error page if the user doesn't have permission.
2022-10-14 16:46:38 +00:00
sharingLevel := database . AppSharingLevelOwner
if workspaceAppPtr != nil && workspaceAppPtr . SharingLevel != "" {
sharingLevel = workspaceAppPtr . SharingLevel
}
if ! api . verifyWorkspaceApplicationSubdomainAuth ( rw , r , host , workspace , sharingLevel ) {
2022-09-22 22:30:32 +00:00
return
}
2022-09-13 17:31:33 +00:00
api . proxyWorkspaceApplication ( proxyApplication {
2022-10-04 16:30:55 +00:00
Workspace : workspace ,
Agent : agent ,
2022-10-14 16:46:38 +00:00
App : workspaceAppPtr ,
2022-10-04 16:30:55 +00:00
Port : app . Port ,
Path : r . URL . Path ,
2022-09-13 17:31:33 +00:00
} , rw , r )
} ) ) . ServeHTTP ( rw , r . WithContext ( ctx ) )
2022-06-04 20:13:37 +00:00
} )
2022-09-13 17:31:33 +00:00
}
}
2022-09-22 22:30:32 +00:00
func ( api * API ) parseWorkspaceApplicationHostname ( rw http . ResponseWriter , r * http . Request , next http . Handler , host string ) ( httpapi . ApplicationURL , bool ) {
2022-10-04 16:30:55 +00:00
// Check if the hostname matches the access URL. If it does, the user was
// definitely trying to connect to the dashboard/API.
2022-09-22 22:30:32 +00:00
if httpapi . HostnamesMatch ( api . AccessURL . Hostname ( ) , host ) {
next . ServeHTTP ( rw , r )
return httpapi . ApplicationURL { } , false
}
2022-10-14 18:25:11 +00:00
// If there are no periods in the hostname, then it can't be a valid
// application URL.
if ! strings . Contains ( host , "." ) {
next . ServeHTTP ( rw , r )
return httpapi . ApplicationURL { } , false
}
2022-10-04 16:30:55 +00:00
// Split the subdomain so we can parse the application details and verify it
// matches the configured app hostname later.
2022-10-14 18:25:11 +00:00
subdomain , ok := httpapi . ExecuteHostnamePattern ( api . AppHostnameRegex , host )
if ! ok {
// Doesn't match the regex, so it's not a valid application URL.
2022-09-22 22:30:32 +00:00
next . ServeHTTP ( rw , r )
return httpapi . ApplicationURL { } , false
}
2022-12-20 18:45:13 +00:00
// Check if the request is part of a logout flow.
if subdomain == appLogoutHostname {
api . handleWorkspaceAppLogout ( rw , r )
return httpapi . ApplicationURL { } , false
}
2022-09-22 22:30:32 +00:00
// Parse the application URL from the subdomain.
app , err := httpapi . ParseSubdomainAppURL ( subdomain )
if err != nil {
2022-10-04 16:30:55 +00:00
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 ( ) ,
2022-09-22 22:30:32 +00:00
} )
return httpapi . ApplicationURL { } , false
}
return app , true
}
2022-12-20 18:45:13 +00:00
func ( api * API ) handleWorkspaceAppLogout ( rw http . ResponseWriter , r * http . Request ) {
ctx := r . Context ( )
// Delete the API key and cookie first before attempting to parse/validate
// the redirect URI.
cookie , err := r . Cookie ( httpmw . DevURLSessionTokenCookie )
if err == nil && cookie . Value != "" {
id , secret , err := httpmw . SplitAPIToken ( cookie . Value )
// If it's not a valid token then we don't need to delete it from the
// database, but we'll still delete the cookie.
if err == nil {
// To avoid a situation where someone overloads the API with
// different auth formats, and tricks this endpoint into deleting an
// unchecked API key, we validate that the secret matches the secret
// we store in the database.
apiKey , err := api . Database . GetAPIKeyByID ( ctx , id )
if err != nil && ! xerrors . Is ( err , sql . ErrNoRows ) {
httpapi . Write ( ctx , rw , http . StatusInternalServerError , codersdk . Response {
Message : "Failed to lookup API key." ,
Detail : err . Error ( ) ,
} )
return
}
// This is wrapped in `err == nil` because if the API key doesn't
// exist, we still want to delete the cookie.
if err == nil {
hashedSecret := sha256 . Sum256 ( [ ] byte ( secret ) )
if subtle . ConstantTimeCompare ( apiKey . HashedSecret , hashedSecret [ : ] ) != 1 {
httpapi . Write ( ctx , rw , http . StatusUnauthorized , codersdk . Response {
Message : httpmw . SignedOutErrorMessage ,
Detail : "API key secret is invalid." ,
} )
return
}
err = api . Database . DeleteAPIKeyByID ( ctx , id )
if err != nil {
httpapi . Write ( ctx , rw , http . StatusInternalServerError , codersdk . Response {
Message : "Failed to delete API key." ,
Detail : err . Error ( ) ,
} )
return
}
}
}
}
if ! api . setWorkspaceAppCookie ( rw , r , "" ) {
return
}
// Read the redirect URI from the query string.
redirectURI := r . URL . Query ( ) . Get ( redirectURIQueryParam )
if redirectURI == "" {
redirectURI = api . AccessURL . String ( )
} else {
// Validate that the redirect URI is a valid URL and exists on the same
// host as the access URL or an app URL.
parsedRedirectURI , err := url . Parse ( redirectURI )
if err != nil {
site . RenderStaticErrorPage ( rw , r , site . ErrorPageData {
Status : http . StatusBadRequest ,
Title : "Invalid redirect URI" ,
Description : fmt . Sprintf ( "Could not parse redirect URI %q: %s" , redirectURI , err . Error ( ) ) ,
RetryEnabled : false ,
DashboardURL : api . AccessURL . String ( ) ,
} )
return
}
// Check if the redirect URI is on the same host as the access URL or an
// app URL.
ok := httpapi . HostnamesMatch ( api . AccessURL . Hostname ( ) , parsedRedirectURI . Hostname ( ) )
if ! ok && api . AppHostnameRegex != nil {
// We could also check that it's a valid application URL for
// completeness, but this check should be good enough.
_ , ok = httpapi . ExecuteHostnamePattern ( api . AppHostnameRegex , parsedRedirectURI . Hostname ( ) )
}
if ! ok {
// The redirect URI they provided is not allowed, but we don't want
// to return an error page because it'll interrupt the logout flow,
// so we just use the default access URL.
parsedRedirectURI = api . AccessURL
}
redirectURI = parsedRedirectURI . String ( )
}
http . Redirect ( rw , r , redirectURI , http . StatusTemporaryRedirect )
}
2022-10-28 17:41:31 +00:00
// lookupWorkspaceApp looks up the workspace application by slug in the given
2022-10-14 16:46:38 +00:00
// agent and returns it. If the application is not found or there was a server
// error while looking it up, an HTML error page is returned and false is
// returned so the caller can return early.
2022-10-28 17:41:31 +00:00
func ( api * API ) lookupWorkspaceApp ( rw http . ResponseWriter , r * http . Request , agentID uuid . UUID , appSlug string ) ( database . WorkspaceApp , bool ) {
app , err := api . Database . GetWorkspaceAppByAgentIDAndSlug ( r . Context ( ) , database . GetWorkspaceAppByAgentIDAndSlugParams {
2022-10-14 16:46:38 +00:00
AgentID : agentID ,
2022-10-28 17:41:31 +00:00
Slug : appSlug ,
2022-10-14 16:46:38 +00:00
} )
if xerrors . Is ( err , sql . ErrNoRows ) {
renderApplicationNotFound ( rw , r , api . AccessURL )
return database . WorkspaceApp { } , false
}
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 database . WorkspaceApp { } , false
}
return app , true
}
func ( api * API ) authorizeWorkspaceApp ( r * http . Request , sharingLevel database . AppSharingLevel , workspace database . Workspace ) ( bool , error ) {
ctx := r . Context ( )
// Short circuit if not authenticated.
roles , ok := httpmw . UserAuthorizationOptional ( r )
if ! ok {
// The user is not authenticated, so they can only access the app if it
// is public.
return sharingLevel == database . AppSharingLevelPublic , nil
}
// Do a standard RBAC check. This accounts for share level "owner" and any
// other RBAC rules that may be in place.
//
// Regardless of share level or whether it's enabled or not, the owner of
// the workspace can always access applications (as long as their API key's
// scope allows it).
err := api . Authorizer . ByRoleName ( ctx , roles . ID . String ( ) , roles . Roles , roles . Scope . ToRBAC ( ) , [ ] string { } , rbac . ActionCreate , workspace . ApplicationConnectRBAC ( ) )
if err == nil {
return true , nil
}
switch sharingLevel {
case database . AppSharingLevelOwner :
// We essentially already did this above with the regular RBAC check.
// Owners can always access their own apps according to RBAC rules, so
// they have already been returned from this function.
case database . AppSharingLevelAuthenticated :
// The user is authenticated at this point, but we need to make sure
// that they have ApplicationConnect permissions to their own
// workspaces. This ensures that the key's scope has permission to
// connect to workspace apps.
object := rbac . ResourceWorkspaceApplicationConnect . WithOwner ( roles . ID . String ( ) )
err := api . Authorizer . ByRoleName ( ctx , roles . ID . String ( ) , roles . Roles , roles . Scope . ToRBAC ( ) , [ ] string { } , rbac . ActionCreate , object )
if err == nil {
return true , nil
2022-09-22 22:30:32 +00:00
}
2022-10-14 16:46:38 +00:00
case database . AppSharingLevelPublic :
// We don't really care about scopes and stuff if it's public anyways.
// Someone with a restricted-scope API key could just not submit the
// API key cookie in the request and access the page.
return true , nil
}
2022-09-22 22:30:32 +00:00
2022-10-14 16:46:38 +00:00
// No checks were successful.
return false , nil
}
// fetchWorkspaceApplicationAuth authorizes the user using api.AppAuthorizer
// for a given app share level in the given workspace. The user's authorization
// status is returned. If a server error occurs, a HTML error page is rendered
// and false is returned so the caller can return early.
func ( api * API ) fetchWorkspaceApplicationAuth ( rw http . ResponseWriter , r * http . Request , workspace database . Workspace , appSharingLevel database . AppSharingLevel ) ( authed bool , ok bool ) {
ok , err := api . authorizeWorkspaceApp ( r , appSharingLevel , workspace )
if err != nil {
api . Logger . Error ( r . Context ( ) , "authorize workspace app" , slog . Error ( err ) )
site . RenderStaticErrorPage ( rw , r , site . ErrorPageData {
Status : http . StatusInternalServerError ,
Title : "Internal Server Error" ,
Description : "Could not verify authorization. Please try again or contact an administrator." ,
RetryEnabled : true ,
DashboardURL : api . AccessURL . String ( ) ,
} )
return false , false
}
return ok , true
}
// checkWorkspaceApplicationAuth authorizes the user using api.AppAuthorizer
// for a given app share level in the given workspace. If the user is not
// authorized or a server error occurs, a discrete HTML error page is rendered
// and false is returned so the caller can return early.
func ( api * API ) checkWorkspaceApplicationAuth ( rw http . ResponseWriter , r * http . Request , workspace database . Workspace , appSharingLevel database . AppSharingLevel ) bool {
authed , ok := api . fetchWorkspaceApplicationAuth ( rw , r , workspace , appSharingLevel )
if ! ok {
return false
}
if ! authed {
renderApplicationNotFound ( rw , r , api . AccessURL )
return false
}
return true
}
// verifyWorkspaceApplicationSubdomainAuth 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 ) verifyWorkspaceApplicationSubdomainAuth ( rw http . ResponseWriter , r * http . Request , host string , workspace database . Workspace , appSharingLevel database . AppSharingLevel ) bool {
authed , ok := api . fetchWorkspaceApplicationAuth ( rw , r , workspace , appSharingLevel )
if ! ok {
return false
}
if authed {
2022-09-22 22:30:32 +00:00
return true
}
2022-10-14 16:46:38 +00:00
_ , hasAPIKey := httpmw . APIKeyOptional ( r )
if hasAPIKey {
// The request has a valid API key but insufficient permissions.
renderApplicationNotFound ( rw , r , api . AccessURL )
return false
}
2022-09-22 22:30:32 +00:00
// 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.
2022-12-20 18:45:13 +00:00
_ , token , err := decryptAPIKey ( r . Context ( ) , api . Database , encryptedAPIKey )
2022-09-22 22:30:32 +00:00
if err != nil {
2022-10-04 16:30:55 +00:00
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 ( ) ,
2022-09-22 22:30:32 +00:00
} )
return false
}
2022-12-20 18:45:13 +00:00
api . setWorkspaceAppCookie ( rw , r , token )
2022-09-22 22:30:32 +00:00
// 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
}
2022-12-20 18:45:13 +00:00
// setWorkspaceAppCookie sets a cookie on the workspace app domain. If the app
// hostname cannot be parsed properly, a static error page is rendered and false
// is returned.
//
// If an empty token is supplied, it will clear the cookie.
func ( api * API ) setWorkspaceAppCookie ( rw http . ResponseWriter , r * http . Request , token string ) bool {
hostSplit := strings . SplitN ( api . AppHostname , "." , 2 )
if len ( hostSplit ) != 2 {
// This should be impossible as we verify the app hostname on
// startup, but we'll check anyways.
api . Logger . Error ( r . Context ( ) , "could not split invalid app hostname" , slog . F ( "hostname" , api . AppHostname ) )
site . RenderStaticErrorPage ( rw , r , site . ErrorPageData {
Status : http . StatusInternalServerError ,
Title : "Internal Server Error" ,
Description : "The app is configured with an invalid app wildcard hostname. Please contact an administrator." ,
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.
//
// We don't set an expiration because the key in the database already has an
// expiration.
maxAge := 0
if token == "" {
maxAge = - 1
}
cookieHost := "." + hostSplit [ 1 ]
http . SetCookie ( rw , & http . Cookie {
Name : httpmw . DevURLSessionTokenCookie ,
Value : token ,
Domain : cookieHost ,
Path : "/" ,
MaxAge : maxAge ,
HttpOnly : true ,
SameSite : http . SameSiteLaxMode ,
Secure : api . SecureAuthCookie ,
} )
return true
}
2022-12-21 14:37:30 +00:00
// @Summary Redirect to URI with encrypted API key
// @ID redirect-to-uri-with-encrypted-api-key
// @Security CoderSessionToken
// @Tags Applications
// @Param redirect_uri query string false "Redirect destination"
// @Success 307
// @Router /applications/auth-redirect [get]
//
2022-09-22 22:30:32 +00:00
// workspaceApplicationAuth is an endpoint on the main router that handles
// redirects from the subdomain handler.
2022-10-04 16:30:55 +00:00
//
// 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.
2022-09-22 22:30:32 +00:00
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.
2022-10-14 18:25:11 +00:00
subdomain , ok := httpapi . ExecuteHostnamePattern ( api . AppHostnameRegex , u . Host )
if ! ok {
2022-09-22 22:30:32 +00:00
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 )
}
2022-09-13 17:31:33 +00:00
// proxyApplication are the required fields to proxy a workspace application.
type proxyApplication struct {
Workspace database . Workspace
Agent database . WorkspaceAgent
2022-10-14 16:46:38 +00:00
// Either App or Port must be set, but not both.
App * database . WorkspaceApp
Port uint16
// SharingLevel MUST be set to database.AppSharingLevelOwner by default for
// ports.
SharingLevel database . AppSharingLevel
2022-09-13 17:31:33 +00:00
// Path must either be empty or have a leading slash.
Path string
}
func ( api * API ) proxyWorkspaceApplication ( proxyApp proxyApplication , rw http . ResponseWriter , r * http . Request ) {
2022-09-16 16:43:22 +00:00
ctx := r . Context ( )
2022-10-14 16:46:38 +00:00
sharingLevel := database . AppSharingLevelOwner
if proxyApp . App != nil && proxyApp . App . SharingLevel != "" {
sharingLevel = proxyApp . App . SharingLevel
}
if ! api . checkWorkspaceApplicationAuth ( rw , r , proxyApp . Workspace , sharingLevel ) {
2022-06-04 20:13:37 +00:00
return
}
2022-09-13 17:31:33 +00:00
2022-10-23 18:21:49 +00:00
// Filter IP headers from untrusted origins!
httpmw . FilterUntrustedOriginHeaders ( api . RealIPConfig , r )
// Ensure proper IP headers get sent to the forwarded application.
err := httpmw . EnsureXForwardedForHeader ( r )
if err != nil {
httpapi . InternalServerError ( rw , err )
return
}
2022-10-28 17:41:31 +00:00
// If the app does not exist, but the app slug is a port number, then route
// to the port as an "anonymous app". We only support HTTP for port-based
// URLs.
2022-10-14 16:46:38 +00:00
//
// This is only supported for subdomain-based applications.
2022-09-13 17:31:33 +00:00
internalURL := fmt . Sprintf ( "http://127.0.0.1:%d" , proxyApp . Port )
2022-10-14 16:46:38 +00:00
if proxyApp . App != nil {
if ! proxyApp . App . Url . Valid {
2022-10-04 16:30:55 +00:00
site . RenderStaticErrorPage ( rw , r , site . ErrorPageData {
Status : http . StatusBadRequest ,
Title : "Bad Request" ,
2022-10-28 17:41:31 +00:00
Description : fmt . Sprintf ( "Application %q does not have a URL set." , proxyApp . App . Slug ) ,
2022-10-04 16:30:55 +00:00
RetryEnabled : true ,
DashboardURL : api . AccessURL . String ( ) ,
2022-09-13 17:31:33 +00:00
} )
return
}
2022-10-14 16:46:38 +00:00
internalURL = proxyApp . App . Url . String
2022-06-04 20:13:37 +00:00
}
2022-09-13 17:31:33 +00:00
appURL , err := url . Parse ( internalURL )
2022-06-04 20:13:37 +00:00
if err != nil {
2022-10-04 16:30:55 +00:00
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 ( ) ,
2022-06-04 20:13:37 +00:00
} )
return
}
2022-10-06 12:38:22 +00:00
// 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
}
}
2022-09-13 17:31:33 +00:00
// Ensure path and query parameter correctness.
if proxyApp . Path == "" {
2022-06-04 20:13:37 +00:00
// 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.
2022-09-13 17:31:33 +00:00
http . Redirect ( rw , r , r . URL . Path + "/" , http . StatusTemporaryRedirect )
2022-06-04 20:13:37 +00:00
return
}
2022-09-13 17:31:33 +00:00
if proxyApp . Path == "/" && r . URL . RawQuery == "" && appURL . RawQuery != "" {
2022-06-04 20:13:37 +00:00
// 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
}
2022-09-13 17:31:33 +00:00
r . URL . Path = proxyApp . Path
appURL . RawQuery = ""
proxy := httputil . NewSingleHostReverseProxy ( appURL )
proxy . ErrorHandler = func ( w http . ResponseWriter , r * http . Request , err error ) {
2022-10-04 16:30:55 +00:00
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 ( ) ,
2022-09-13 17:31:33 +00:00
} )
}
conn , release , err := api . workspaceAgentCache . Acquire ( r , proxyApp . Agent . ID )
2022-06-04 20:13:37 +00:00
if err != nil {
2022-10-04 16:30:55 +00:00
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 ( ) ,
2022-06-04 20:13:37 +00:00
} )
return
}
defer release ( )
2022-12-07 12:55:02 +00:00
proxy . Transport = conn . HTTPTransport ( )
2022-06-04 20:13:37 +00:00
2022-08-17 17:09:45 +00:00
// 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 ) )
}
2022-12-07 12:55:02 +00:00
// Convert canonicalized headers to their non-canonicalized counterparts.
// See the comment on `nonCanonicalHeaders` for more information on why this
// is necessary.
for k , v := range r . Header {
if n , ok := nonCanonicalHeaders [ k ] ; ok {
r . Header . Del ( k )
r . Header [ n ] = v
}
}
2022-08-25 16:24:43 +00:00
// end span so we don't get long lived trace data
2022-09-16 16:43:22 +00:00
tracing . EndHTTPSpan ( r , http . StatusOK , trace . SpanFromContext ( ctx ) )
2022-08-25 16:24:43 +00:00
2022-06-04 20:13:37 +00:00
proxy . ServeHTTP ( rw , r )
}
2022-09-13 17:31:33 +00:00
2022-09-22 22:30:32 +00:00
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.
2022-09-13 17:31:33 +00:00
//
2022-09-22 22:30:32 +00:00
// 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
2022-09-13 17:31:33 +00:00
}
2022-10-04 16:30:55 +00:00
// 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 ,
2022-10-14 16:46:38 +00:00
Title : "Application Not Found" ,
Description : "The application or workspace you are trying to access does not exist or you do not have permission to access it." ,
2022-10-04 16:30:55 +00:00
RetryEnabled : false ,
DashboardURL : accessURL . String ( ) ,
} )
}