Ctrl K
ComunidadGuías…

coderd/workspaceapps/proxy.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package workspaceapps

import (
	"context"
	"errors"
	"fmt"
	"net"
	"net/http"
	"net/http/httputil"
	"net/url"
	"regexp"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/go-chi/chi/v5"
	"github.com/go-jose/go-jose/v4/jwt"
	"github.com/google/uuid"
	"go.opentelemetry.io/otel/trace"

	"cdr.dev/slog/v3"
	"github.com/coder/coder/v2/agent/agentssh"
	"github.com/coder/coder/v2/coderd/cryptokeys"
	"github.com/coder/coder/v2/coderd/database/dbtime"
	"github.com/coder/coder/v2/coderd/httpapi"
	"github.com/coder/coder/v2/coderd/httpmw"
	"github.com/coder/coder/v2/coderd/jwtutils"
	"github.com/coder/coder/v2/coderd/tracing"
	"github.com/coder/coder/v2/coderd/util/slice"
	"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
	"github.com/coder/coder/v2/coderd/workspaceapps/cors"
	"github.com/coder/coder/v2/codersdk"
	"github.com/coder/coder/v2/codersdk/workspacesdk"
	"github.com/coder/coder/v2/site"
	"github.com/coder/websocket"
)

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"
	// 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.
	//
	// Deprecated: we no longer use this, but we still redirect from it to the
	// main login page.
	appLogoutHostname = "coder-logout"
)

// 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",
}

type AgentProvider interface {
	// ReverseProxy returns an httputil.ReverseProxy for proxying HTTP requests
	// to the specified agent.
	ReverseProxy(targetURL, dashboardURL *url.URL, agentID uuid.UUID, app appurl.ApplicationURL, wildcardHost string) *httputil.ReverseProxy

	// AgentConn returns a new connection to the specified agent.
	AgentConn(ctx context.Context, agentID uuid.UUID) (_ workspacesdk.AgentConn, release func(), _ error)

	ServeHTTPDebug(w http.ResponseWriter, r *http.Request)

	Close() error
}

// AgentUnreachableError is returned by AgentProvider.AgentConn when the agent
// did not answer before the context ended. It is not returned when the client
// canceled within 5s, or when the context was canceled with
// workspacesdk.ErrDialAbandoned or workspacesdk.ErrReadinessProbeTimeout.
// Fields holds the peer diagnostics collected at that time. Callers with a
// request log line that log the failure should include them.
type AgentUnreachableError struct {
	Fields []slog.Field
}

func (*AgentUnreachableError) Error() string {
	return "agent is unreachable"
}

type ServerOptions struct {
	Logger slog.Logger

	// DashboardURL should be a url to the coderd dashboard. This can be the
	// same as the AccessURL if the Server is embedded.
	DashboardURL *url.URL
	AccessURL    *url.URL
	// Hostname should be the wildcard hostname to use for workspace
	// applications INCLUDING the asterisk, (optional) suffix and leading dot.
	// It will use the same scheme and port number as the access URL.
	// E.g. "*.apps.coder.com" or "*-apps.coder.com".
	Hostname string
	// HostnameRegex contains the regex version of Hostname as generated by
	// appurl.CompileHostnamePattern(). It MUST be set if Hostname is set.
	HostnameRegex *regexp.Regexp
	RealIPConfig  *httpmw.RealIPConfig

	SignedTokenProvider      SignedTokenProvider
	APIKeyEncryptionKeycache cryptokeys.EncryptionKeycache

	// DisablePathApps disables path-based apps. This is a security feature as path
	// based apps share the same cookie as the dashboard, and are susceptible to XSS
	// by a malicious workspace app.
	//
	// Subdomain apps are safer with their cookies scoped to the subdomain, and XSS
	// calls to the dashboard are not possible due to CORs.
	DisablePathApps bool
	CookiesConfig   codersdk.HTTPCookieConfig

	AgentProvider  AgentProvider
	StatsCollector *StatsCollector
	WSWatcher      *httpapi.WSWatcher
}

// Server serves workspace apps endpoints, including:
// - Path-based apps
// - Subdomain app middleware
// - Workspace reconnecting-pty (aka. web terminal)
type Server struct {
	ServerOptions

	cookies AppCookies

	websocketWaitMutex sync.Mutex
	websocketWaitGroup sync.WaitGroup
}

func NewServer(options ServerOptions) *Server {
	return &Server{
		ServerOptions: options,
		cookies:       NewAppCookies(options.Hostname),
	}
}

// Close waits for all reconnecting-pty WebSocket connections to drain before
// returning.
func (s *Server) Close() error {
	s.websocketWaitMutex.Lock()
	s.websocketWaitGroup.Wait()
	s.websocketWaitMutex.Unlock()

	if s.StatsCollector != nil {
		_ = s.StatsCollector.Close()
	}

	// The caller must close the SignedTokenProvider and the AgentProvider (if
	// necessary).

	return nil
}

func (s *Server) Attach(r chi.Router) {
	servePathApps := func(r chi.Router) {
		r.HandleFunc("/*", s.workspaceAppsProxyPath)
	}

	// %40 is the encoded character of the @ symbol. VS Code Web does
	// not handle character encoding properly, so it's safe to assume
	// other applications might not as well.
	r.Route("/%40{user}/{workspace_and_agent}/apps/{workspaceapp}", servePathApps)
	r.Route("/@{user}/{workspace_and_agent}/apps/{workspaceapp}", servePathApps)

	r.Get("/api/v2/workspaceagents/{workspaceagent}/pty", s.workspaceAgentPTY)
}

// originLocalURL returns p as a relative URL rooted at the current origin,
// safe to use as a redirect Location. p (typically r.URL.Path) is
// attacker-controlled and already percent-decoded, so a leading "//" or "/\"
// run would otherwise be parsed by http.Redirect or a browser as a
// scheme-relative URL pointing at another host, turning a redirect back to the
// same path into an open redirect. Collapsing the leading slash and backslash
// run to a single "/" keeps the result same-origin, and url.URL.String()
// percent-encodes any control characters in the path.
func originLocalURL(p string) *url.URL {
	return &url.URL{Path: "/" + strings.TrimLeft(p, `/\`)}
}

// stripQueryParam removes every field whose decoded key equals key from
// rawQuery and reports whether any field was removed. Other fields are kept
// byte-for-byte and in order, including fields url.ParseQuery would reject, so
// the result can be forwarded to apps that rely on the exact query string.
// Fields are split on "&" only and keys are decoded with url.QueryUnescape to
// match how Coder's auth reads the parameter. A key that fails to decode is
// kept because Coder's auth could not have read it.
func stripQueryParam(rawQuery, key string) (string, bool) {
	fields := strings.Split(rawQuery, "&")
	kept := fields[:0]
	for _, field := range fields {
		fieldKey, _, _ := strings.Cut(field, "=")
		if decoded, err := url.QueryUnescape(fieldKey); err == nil && decoded == key {
			continue
		}
		kept = append(kept, field)
	}
	if len(kept) == len(fields) {
		return rawQuery, false
	}
	return strings.Join(kept, "&"), true
}

// handleAPIKeySmuggling is called by the proxy path and subdomain handlers to
// process any "smuggled" API keys in the query parameters.
//
// If a smuggled key is found, it is decrypted and the cookie is set, and the
// user is redirected to strip the query parameter.
func (s *Server) handleAPIKeySmuggling(rw http.ResponseWriter, r *http.Request, accessMethod AccessMethod) bool {
	ctx := r.Context()

	encryptedAPIKey := r.URL.Query().Get(SubdomainProxyAPIKeyParam)
	if encryptedAPIKey == "" {
		return true
	}

	// API key smuggling is not permitted for path apps on the primary access
	// URL. The user is already covered by their full session token.
	if accessMethod == AccessMethodPath && s.AccessURL.Host == s.DashboardURL.Host {
		site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
			Status:      http.StatusBadRequest,
			Title:       "Bad Request",
			Description: "Could not decrypt API key. Workspace app API key smuggling is not permitted on the primary access URL. Please remove the query parameter and try again.",
			// No retry is included because the user needs to remove the query
			// parameter before they try again.
			Actions: []site.Action{
				{
					URL:  s.DashboardURL.String(),
					Text: "Back to site",
				},
			},
		})
		return false
	}

	// Exchange the encoded API key for a real one.
	var payload EncryptedAPIKeyPayload
	err := jwtutils.Decrypt(ctx, s.APIKeyEncryptionKeycache, encryptedAPIKey, &payload, jwtutils.WithDecryptExpected(jwt.Expected{
		Time: time.Now(),
	}))
	if err != nil {
		s.Logger.Debug(ctx, "could not decrypt smuggled workspace app API key", slog.Error(err))
		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.",
			// No retry is included because the user needs to remove the query
			// parameter before they try again.
			Actions: []site.Action{
				{
					URL:  s.DashboardURL.String(),
					Text: "Back to site",
				},
			},
		})
		return false
	}

	// Set the cookie. For subdomain apps, we set the cookie on the whole
	// wildcard so users don't need to re-auth for every subdomain app they
	// access. For path apps (only on proxies, see above) we just set it on the
	// current domain.
	domain := "" // use the current domain
	if accessMethod == AccessMethodSubdomain {
		hostSplit := strings.SplitN(s.Hostname, ".", 2)
		if len(hostSplit) != 2 {
			// This should be impossible as we verify the app hostname on
			// startup, but we'll check anyways.
			s.Logger.Error(r.Context(), "could not split invalid app hostname", slog.F("hostname", s.Hostname))
			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.",
				Actions: []site.Action{
					{
						URL:  s.DashboardURL.String(),
						Text: "Back to site",
					},
				},
			})
			return false
		}

		// Set the cookie for all subdomains of s.Hostname.
		domain = "." + hostSplit[1]
	}

	// We don't set an expiration because the key in the database already has an
	// expiration, and expired tokens don't affect the user experience (they get
	// auto-redirected to re-smuggle the API key).
	//
	// We use different cookie names for path apps and for subdomain apps to
	// avoid both being set and sent to the server at the same time and the
	// server using the wrong value.
	http.SetCookie(rw, s.CookiesConfig.Apply(&http.Cookie{
		Name:     s.cookies.CookieNameForAccessMethod(accessMethod),
		Value:    payload.APIKey,
		Domain:   domain,
		Path:     "/",
		MaxAge:   0,
		HttpOnly: true,
	}))

	// Strip the smuggled API key query parameter and redirect back to the same
	// path. r.URL.Path is attacker-controlled and can smuggle a separate host
	// (e.g. "//evil.com"); originLocalURL keeps the redirect on the current
	// origin.
	redirectURL := originLocalURL(r.URL.Path)

	q := r.URL.Query()
	q.Del(SubdomainProxyAPIKeyParam)
	redirectURL.RawQuery = q.Encode()

	http.Redirect(rw, r, redirectURL.String(), http.StatusSeeOther)
	return false
}

// workspaceAppsProxyPath proxies requests to a workspace application
// through a relative URL path.
func (s *Server) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request) {
	if s.DisablePathApps {
		site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
			Status:      http.StatusForbidden,
			Title:       "Forbidden",
			Description: "Path-based applications are disabled on this Coder deployment by the administrator.",
			Actions: []site.Action{
				{
					URL:  s.DashboardURL.String(),
					Text: "Back to site",
				},
			},
		})
		return
	}

	// We don't support @me in path apps since it requires the database to
	// lookup the username from token. We used to redirect by doing this lookup.
	if chi.URLParam(r, "user") == codersdk.Me {
		site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
			Status:      http.StatusNotFound,
			Title:       "Application Not Found",
			Description: "Applications must be accessed with the full username, not @me.",
			Actions: []site.Action{
				{
					URL:  s.DashboardURL.String(),
					Text: "Back to site",
				},
			},
		})
		return
	}

	if !s.handleAPIKeySmuggling(rw, r, AccessMethodPath) {
		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
	}

	// ResolveRequest will only return a new signed token if the actor has the RBAC
	// permissions to connect to a workspace.
	token, ok := ResolveRequest(rw, r, ResolveRequestOptions{
		Logger:              s.Logger,
		Cookies:             s.cookies,
		CookieCfg:           s.CookiesConfig,
		SignedTokenProvider: s.SignedTokenProvider,
		DashboardURL:        s.DashboardURL,
		PathAppBaseURL:      s.AccessURL,
		AppHostname:         s.Hostname,
		AppRequest: Request{
			AccessMethod:      AccessMethodPath,
			BasePath:          basePath,
			Prefix:            "", // Prefix doesn't exist for path apps
			UsernameOrID:      chi.URLParam(r, "user"),
			WorkspaceAndAgent: chi.URLParam(r, "workspace_and_agent"),
			// We don't support port proxying on paths. The ResolveRequest method
			// won't allow port proxying on path-based apps if the app is a number.
			AppSlugOrPort: chi.URLParam(r, "workspaceapp"),
		},
		AppPath:  chiPath,
		AppQuery: r.URL.RawQuery,
	})
	if !ok {
		return
	}

	s.proxyWorkspaceApp(rw, r, *token, chiPath, appurl.ApplicationURL{})
}

// determineCORSBehavior examines the given token and conditionally applies
// CORS middleware if the token specifies that behavior.
func (s *Server) determineCORSBehavior(token *SignedToken, app appurl.ApplicationURL) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		// Create the CORS middleware handler upfront.
		corsHandler := httpmw.WorkspaceAppCors(s.HostnameRegex, app)(next)

		return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
			var behavior codersdk.CORSBehavior
			if token != nil {
				behavior = token.CORSBehavior
			}

			// Add behavior to context regardless of which handler we use,
			// since we will use this later on to determine if we should strip
			// CORS headers in the response.
			r = r.WithContext(cors.WithBehavior(r.Context(), behavior))

			switch behavior {
			case codersdk.CORSBehaviorPassthru:
				// Bypass the CORS middleware.
				next.ServeHTTP(rw, r)
				return
			default:
				// Apply the CORS middleware.
				corsHandler.ServeHTTP(rw, r)
			}
		})
	}
}

// HandleSubdomain handles subdomain-based application proxy requests (aka.
// DevURLs in Coder V1).
//
// There are a lot of paths here:
//  1. If api.Hostname 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 appurl.ApplicationURL struct. If we
//     encounter an error:
//     a. If the "rest" does not match api.Hostname then we pass on;
//     b. Otherwise, we return a 400.
//  6. Finally, we verify that the "rest" matches api.Hostname, else we
//     return a 404.
//
// Rationales for each of the above steps:
//  1. We pass on if api.Hostname 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.Hostname, 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.Hostname, then we return a 400 because the
//     request is probably a typo or something.
//  6. We finally verify that the "rest" matches api.Hostname for security
//     purposes regarding re-authentication and application proxy session
//     tokens.
func (s *Server) HandleSubdomain(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 s.Hostname == "" || s.HostnameRegex == nil {
				next.ServeHTTP(rw, r)
				return
			}

			// Step 2: Get the request Host.
			host := httpmw.EffectiveHost(s.RealIPConfig, 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 := s.parseHostname(rw, r, next, host)
			if !ok {
				return
			}

			if !s.handleAPIKeySmuggling(rw, r, AccessMethodSubdomain) {
				return
			}

			// Generate a signed token for the request.
			token, ok := ResolveRequest(rw, r, ResolveRequestOptions{
				Logger:              s.Logger,
				Cookies:             s.cookies,
				CookieCfg:           s.CookiesConfig,
				SignedTokenProvider: s.SignedTokenProvider,
				DashboardURL:        s.DashboardURL,
				PathAppBaseURL:      s.AccessURL,
				AppHostname:         s.Hostname,
				AppRequest: Request{
					AccessMethod:      AccessMethodSubdomain,
					BasePath:          "/",
					Prefix:            app.Prefix,
					UsernameOrID:      app.Username,
					WorkspaceNameOrID: app.WorkspaceName,
					AgentNameOrID:     app.AgentName,
					AppSlugOrPort:     app.AppSlugOrPort,
				},
				AppPath:  r.URL.Path,
				AppQuery: r.URL.RawQuery,
			})
			if !ok {
				return
			}

			// Proxy the request (possibly with the CORS middleware).
			mws := chi.Middlewares(append(middlewares, s.determineCORSBehavior(token, app)))
			mws.Handler(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
				s.proxyWorkspaceApp(rw, r, *token, r.URL.Path, app)
			})).ServeHTTP(rw, r.WithContext(ctx))
		})
	}
}

// parseHostname will return if a given request is attempting to access a
// workspace app via a subdomain. If it is, the hostname of the request is parsed
// into an appurl.ApplicationURL and true is returned. If the request is not
// accessing a workspace app, then the next handler is called and false is
// returned.
func (s *Server) parseHostname(rw http.ResponseWriter, r *http.Request, next http.Handler, host string) (appurl.ApplicationURL, bool) {
	// Check if the hostname matches either of the access URLs. If it does, the
	// user was definitely trying to connect to the dashboard/API or a
	// path-based app.
	if appurl.HostnamesMatch(s.DashboardURL.Hostname(), host) || appurl.HostnamesMatch(s.AccessURL.Hostname(), host) {
		next.ServeHTTP(rw, r)
		return appurl.ApplicationURL{}, false
	}

	// 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 appurl.ApplicationURL{}, false
	}

	// Split the subdomain so we can parse the application details and verify it
	// matches the configured app hostname later.
	subdomain, ok := appurl.ExecuteHostnamePattern(s.HostnameRegex, host)
	if !ok {
		// Doesn't match the regex, so it's not a valid application URL.
		next.ServeHTTP(rw, r)
		return appurl.ApplicationURL{}, false
	}

	// Check if the request is part of the deprecated logout flow. If so, we
	// just redirect to the main access URL.
	if subdomain == appLogoutHostname {
		http.Redirect(rw, r, s.AccessURL.String(), http.StatusSeeOther)
		return appurl.ApplicationURL{}, false
	}

	// Parse the application URL from the subdomain.
	app, err := appurl.ParseSubdomainAppURL(subdomain)
	if err != nil {
		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()),
			Actions: []site.Action{
				{
					URL:  s.DashboardURL.String(),
					Text: "Back to site",
				},
			},
		})
		return appurl.ApplicationURL{}, false
	}

	return app, true
}

func (s *Server) proxyWorkspaceApp(rw http.ResponseWriter, r *http.Request, appToken SignedToken, path string, app appurl.ApplicationURL) {
	ctx := r.Context()

	// Filter IP headers from untrusted origins.
	httpmw.FilterUntrustedOriginHeaders(s.RealIPConfig, r)

	// Ensure proper IP headers get sent to the forwarded application.
	err := httpmw.EnsureXForwardedForHeader(r)
	if err != nil {
		httpapi.InternalServerError(rw, err)
		return
	}

	appURL, err := url.Parse(appToken.AppURL)
	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", appToken.AppURL, err.Error()),
			Actions: []site.Action{
				{
					Text: "Retry",
				},
				{
					URL:  s.DashboardURL.String(),
					Text: "Back to site",
				},
			},
		})
		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.", appToken.AppURL, port),
				Detail:  err.Error(),
			})
			return
		}

		if portInt < workspacesdk.AgentMinimumListeningPort {
			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, workspacesdk.AgentMinimumListeningPort,
				),
			})
			return
		}
	}

	// Ensure path and query parameter correctness.
	if 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.
		//
		// r.URL.Path is attacker-controlled, so sanitize it before redirecting
		// to avoid an off-origin "//host" Location (see originLocalURL).
		redirectURL := originLocalURL(r.URL.Path)
		if !strings.HasSuffix(redirectURL.Path, "/") {
			redirectURL.Path += "/"
		}
		http.Redirect(rw, r, redirectURL.String(), http.StatusTemporaryRedirect)
		return
	}
	if 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.Path is attacker-controlled, so build the Location from a
		// sanitized same-origin path instead of r.URL directly (see
		// originLocalURL).
		redirectURL := originLocalURL(r.URL.Path)
		redirectURL.RawQuery = appURL.RawQuery
		http.Redirect(rw, r, redirectURL.String(), http.StatusTemporaryRedirect)
		return
	}

	r.URL.Path = path
	appURL.RawQuery = ""
	_, protocol, isPort := app.PortInfo()
	if isPort {
		appURL.Scheme = protocol
	}

	proxy := s.AgentProvider.ReverseProxy(appURL, s.DashboardURL, appToken.AgentID, app, s.Hostname)

	proxy.ModifyResponse = func(r *http.Response) error {
		// If passthru behavior is set, disable our CORS header stripping.
		if cors.HasBehavior(r.Request.Context(), codersdk.CORSBehaviorPassthru) {
			return nil
		}
		r.Header.Del(httpmw.AccessControlAllowOriginHeader)
		r.Header.Del(httpmw.AccessControlAllowCredentialsHeader)
		r.Header.Del(httpmw.AccessControlAllowMethodsHeader)
		r.Header.Del(httpmw.AccessControlAllowHeadersHeader)
		varies := r.Header.Values(httpmw.VaryHeader)
		r.Header.Del(httpmw.VaryHeader)
		forbiddenVary := []string{
			httpmw.OriginHeader,
			httpmw.AccessControlRequestMethodsHeader,
			httpmw.AccessControlRequestHeadersHeader,
		}
		for _, value := range varies {
			if !slice.ContainsCompare(forbiddenVary, value, strings.EqualFold) {
				r.Header.Add(httpmw.VaryHeader, value)
			}
		}
		return nil
	}

	// Strip the Coder session token from cookies, the Coder-Session-Token
	// header, and the coder_session_token query parameter before proxying.
	// Authorization and access_token are forwarded because apps may use them
	// for their own auth.
	cookieHeaders := r.Header.Values("Cookie")
	r.Header.Del("Cookie")
	for _, cookieHeader := range cookieHeaders {
		r.Header.Add("Cookie", httpapi.StripCoderCookies(cookieHeader))
	}
	r.Header.Del(codersdk.SessionTokenHeader)
	if rawQuery, ok := stripQueryParam(r.URL.RawQuery, codersdk.SessionTokenCookie); ok {
		r.URL.RawQuery = rawQuery
	}

	// 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
		}
	}

	// end span so we don't get long lived trace data
	tracing.EndHTTPSpan(r, http.StatusOK, trace.SpanFromContext(ctx), "coderd")

	report := newStatsReportFromSignedToken(appToken)
	defer func() {
		// We must use defer here because ServeHTTP may panic.
		report.SessionEndedAt = dbtime.Now()
		s.collectStats(report)
	}()

	proxy.ServeHTTP(rw, r)
}

// workspaceAgentPTY spawns a PTY and pipes it over a WebSocket.
// This is used for the web terminal.
//
// @Summary Open PTY to workspace agent
// @ID open-pty-to-workspace-agent
// @Security CoderSessionToken
// @Tags Agents
// @Param workspaceagent path string true "Workspace agent ID" format(uuid)
// @Success 101
// @Router /api/v2/workspaceagents/{workspaceagent}/pty [get]
func (s *Server) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithCancel(r.Context())
	defer cancel()

	s.websocketWaitMutex.Lock()
	s.websocketWaitGroup.Add(1)
	s.websocketWaitMutex.Unlock()
	defer s.websocketWaitGroup.Done()

	appToken, ok := ResolveRequest(rw, r, ResolveRequestOptions{
		Logger:              s.Logger,
		Cookies:             s.cookies,
		CookieCfg:           s.CookiesConfig,
		SignedTokenProvider: s.SignedTokenProvider,
		DashboardURL:        s.DashboardURL,
		PathAppBaseURL:      s.AccessURL,
		AppHostname:         s.Hostname,
		AppRequest: Request{
			AccessMethod:  AccessMethodTerminal,
			BasePath:      r.URL.Path,
			AgentNameOrID: chi.URLParam(r, "workspaceagent"),
		},
		AppPath:  "",
		AppQuery: "",
	})
	if !ok {
		return
	}

	clientSessionID := tracing.ClientSessionID(r)

	log := s.Logger.With(
		slog.F("agent_id", appToken.AgentID),
		slog.F("client_session_id", clientSessionID),
	)
	log.Debug(ctx, "resolved PTY request")

	values := r.URL.Query()
	parser := httpapi.NewQueryParamParser()
	reconnect := parser.RequiredNotEmpty("reconnect").UUID(values, uuid.New(), "reconnect")
	height := parser.UInt(values, 80, "height")
	width := parser.UInt(values, 80, "width")
	container := parser.String(values, "", "container")
	containerUser := parser.String(values, "", "container_user")
	backendType := parser.String(values, "", "backend_type")
	if len(parser.Errors) > 0 {
		httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
			Message:     "Invalid query parameters.",
			Validations: parser.Errors,
		})
		return
	}

	conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{
		CompressionMode: websocket.CompressionDisabled,
		// Always allow websockets from the primary dashboard URL.
		// Terminals are opened there and connect to the proxy.
		OriginPatterns: []string{
			s.DashboardURL.Host,
			s.AccessURL.Host,
		},
	})
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
			Message: "Failed to accept websocket.",
			Detail:  err.Error(),
		})
		return
	}

	ctx, wsNetConn := WebsocketNetConn(ctx, conn, websocket.MessageBinary)
	defer wsNetConn.Close() // Also closes conn.

	ctx = s.WSWatcher.Watch(ctx, s.Logger, conn)

	agentConn, release, err := s.AgentProvider.AgentConn(ctx, appToken.AgentID)
	if err != nil {
		// A 101 response logs the request at debug, so log unreachable agents
		// at warn here. Use s.Logger: Fields already has agent_id.
		if unreachable, ok := errors.AsType[*AgentUnreachableError](err); ok {
			s.Logger.Warn(ctx, "agent is unreachable", append(unreachable.Fields, slog.Error(err))...)
		} else {
			log.Debug(ctx, "dial workspace agent", slog.Error(err))
		}
		_ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("dial workspace agent: %s", err))
		return
	}
	defer release()
	log.Debug(ctx, "dialed workspace agent")

	// Pass through the client session ID to the agent via the header.
	if clientSessionID != "" {
		agentConn.SetExtraHeaders(http.Header{
			"baggage": []string{tracing.SessionIDBaggageKey + "=" + clientSessionID},
		})
	}

	// #nosec G115 - Safe conversion for terminal height/width which are expected to be within uint16 range (0-65535)
	ptNetConn, err := agentConn.ReconnectingPTY(ctx, reconnect, uint16(height), uint16(width), r.URL.Query().Get("command"), func(arp *workspacesdk.AgentReconnectingPTYInit) {
		arp.Container = container
		arp.ContainerUser = containerUser
		arp.BackendType = backendType
	})
	if err != nil {
		log.Debug(ctx, "dial reconnecting pty server in workspace agent", slog.Error(err))
		_ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("dial: %s", err))
		return
	}
	defer ptNetConn.Close()
	log.Debug(ctx, "obtained PTY")

	report := newStatsReportFromSignedToken(*appToken)
	s.collectStats(report)
	defer func() {
		report.SessionEndedAt = dbtime.Now()
		s.collectStats(report)
	}()

	agentssh.Bicopy(ctx, wsNetConn, ptNetConn)
	log.Debug(ctx, "pty Bicopy finished")
}

func (s *Server) collectStats(stats StatsReport) {
	if s.StatsCollector != nil {
		s.StatsCollector.Collect(stats)
	}
}

// wsNetConn wraps net.Conn created by websocket.NetConn(). Cancel func
// is called if a read or write error is encountered.
type wsNetConn struct {
	cancel context.CancelFunc
	net.Conn
}

func (c *wsNetConn) Read(b []byte) (n int, err error) {
	n, err = c.Conn.Read(b)
	if err != nil {
		c.cancel()
	}
	return n, err
}

func (c *wsNetConn) Write(b []byte) (n int, err error) {
	n, err = c.Conn.Write(b)
	if err != nil {
		c.cancel()
	}
	return n, err
}

func (c *wsNetConn) Close() error {
	defer c.cancel()
	return c.Conn.Close()
}

// WebsocketNetConn wraps websocket.NetConn and returns a context that
// is tied to the parent context and the lifetime of the conn. Any error
// during read or write will cancel the context, but not close the
// conn. Close should be called to release context resources.
func WebsocketNetConn(ctx context.Context, conn *websocket.Conn, msgType websocket.MessageType) (context.Context, net.Conn) {
	ctx, cancel := context.WithCancel(ctx)
	nc := websocket.NetConn(ctx, conn, msgType)
	return ctx, &wsNetConn{
		cancel: cancel,
		Conn:   nc,
	}
}