chore: move app proxying code to workspaceapps pkg (#6998)

* chore: move app proxying code to workspaceapps pkg

Moves path-app, subdomain-app and reconnecting PTY proxying to the new
workspaceapps.WorkspaceAppServer struct. This is in preparation for
external workspace proxies.

Updates app logout flow to avoid redirecting to coder-logout.${app_host}
on logout. Instead, all subdomain app tokens owned by the logging-out
user will be deleted every time you logout for simplicity sake.

Tests will remain in their original package, pending being moved to an
apptest package (or similar).

Co-authored-by: Steven Masley <stevenmasley@coder.com>
This commit is contained in:
Dean Sheather
2023-04-05 13:41:55 -05:00
committed by GitHub
co-authored by Steven Masley
parent 0069831e8d
commit eb66cc9f35
28 changed files with 1236 additions and 1334 deletions
+7 -13
View File
@@ -32,16 +32,12 @@ type DBTokenProvider struct {
DeploymentValues *codersdk.DeploymentValues
OAuth2Configs *httpmw.OAuth2Configs
WorkspaceAgentInactiveTimeout time.Duration
TokenSigningKey []byte
SigningKey SecurityKey
}
var _ SignedTokenProvider = &DBTokenProvider{}
func NewDBTokenProvider(log slog.Logger, accessURL *url.URL, authz rbac.Authorizer, db database.Store, cfg *codersdk.DeploymentValues, oauth2Cfgs *httpmw.OAuth2Configs, workspaceAgentInactiveTimeout time.Duration, tokenSigningKey []byte) SignedTokenProvider {
if len(tokenSigningKey) != 64 {
panic("token signing key must be 64 bytes")
}
func NewDBTokenProvider(log slog.Logger, accessURL *url.URL, authz rbac.Authorizer, db database.Store, cfg *codersdk.DeploymentValues, oauth2Cfgs *httpmw.OAuth2Configs, workspaceAgentInactiveTimeout time.Duration, signingKey SecurityKey) SignedTokenProvider {
if workspaceAgentInactiveTimeout == 0 {
workspaceAgentInactiveTimeout = 1 * time.Minute
}
@@ -54,7 +50,7 @@ func NewDBTokenProvider(log slog.Logger, accessURL *url.URL, authz rbac.Authoriz
DeploymentValues: cfg,
OAuth2Configs: oauth2Cfgs,
WorkspaceAgentInactiveTimeout: workspaceAgentInactiveTimeout,
TokenSigningKey: tokenSigningKey,
SigningKey: signingKey,
}
}
@@ -62,7 +58,7 @@ func (p *DBTokenProvider) TokenFromRequest(r *http.Request) (*SignedToken, bool)
// Get the existing token from the request.
tokenCookie, err := r.Cookie(codersdk.DevURLSignedAppTokenCookie)
if err == nil {
token, err := ParseToken(p.TokenSigningKey, tokenCookie.Value)
token, err := p.SigningKey.VerifySignedToken(tokenCookie.Value)
if err == nil {
req := token.Request.Normalize()
err := req.Validate()
@@ -130,9 +126,6 @@ func (p *DBTokenProvider) CreateToken(ctx context.Context, rw http.ResponseWrite
token.AgentID = dbReq.Agent.ID
token.AppURL = dbReq.AppURL
// TODO(@deansheather): return an error if the agent is offline or the app
// is not running.
// Verify the user has access to the app.
authed, err := p.authorizeRequest(r.Context(), authz, dbReq)
if err != nil {
@@ -150,7 +143,8 @@ func (p *DBTokenProvider) CreateToken(ctx context.Context, rw http.ResponseWrite
// and they aren't signed in.
switch appReq.AccessMethod {
case AccessMethodPath:
// TODO(@deansheather): this doesn't work on moons
// TODO(@deansheather): this doesn't work on moons so will need to
// be updated to include the access URL as a param
httpmw.RedirectToLogin(rw, r, httpmw.SignedOutErrorMessage)
case AccessMethodSubdomain:
// Redirect to the app auth redirect endpoint with a valid redirect
@@ -195,7 +189,7 @@ func (p *DBTokenProvider) CreateToken(ctx context.Context, rw http.ResponseWrite
// Sign the token.
token.Expiry = time.Now().Add(DefaultTokenExpiry)
tokenStr, err := GenerateToken(p.TokenSigningKey, token)
tokenStr, err := p.SigningKey.SignToken(token)
if err != nil {
WriteWorkspaceApp500(p.Logger, p.AccessURL, rw, r, &appReq, err, "generate token")
return nil, "", false
+3 -3
View File
@@ -263,7 +263,7 @@ func Test_ResolveRequest(t *testing.T) {
require.Equal(t, codersdk.DevURLSignedAppTokenCookie, cookie.Name)
require.Equal(t, req.BasePath, cookie.Path)
parsedToken, err := workspaceapps.ParseToken(api.AppSigningKey, cookie.Value)
parsedToken, err := api.AppSecurityKey.VerifySignedToken(cookie.Value)
require.NoError(t, err)
// normalize expiry
require.WithinDuration(t, token.Expiry, parsedToken.Expiry, 2*time.Second)
@@ -482,7 +482,7 @@ func Test_ResolveRequest(t *testing.T) {
AgentID: agentID,
AppURL: appURL,
}
badTokenStr, err := workspaceapps.GenerateToken(api.AppSigningKey, badToken)
badTokenStr, err := api.AppSecurityKey.SignToken(badToken)
require.NoError(t, err)
req := workspaceapps.Request{
@@ -518,7 +518,7 @@ func Test_ResolveRequest(t *testing.T) {
require.Len(t, cookies, 1)
require.Equal(t, cookies[0].Name, codersdk.DevURLSignedAppTokenCookie)
require.NotEqual(t, cookies[0].Value, badTokenStr)
parsedToken, err := workspaceapps.ParseToken(api.AppSigningKey, cookies[0].Value)
parsedToken, err := api.AppSecurityKey.VerifySignedToken(cookies[0].Value)
require.NoError(t, err)
require.Equal(t, appNameOwner, parsedToken.AppSlugOrPort)
})
+3 -3
View File
@@ -19,14 +19,14 @@ const (
RedirectURIQueryParam = "redirect_uri"
)
// ResolveRequest calls TokenProvider to use an existing signed app token in the
// ResolveRequest calls SignedTokenProvider to use an existing signed app token in the
// request or issue a new one. If it returns a newly minted token, it sets the
// cookie for you.
func ResolveRequest(log slog.Logger, accessURL *url.URL, p SignedTokenProvider, rw http.ResponseWriter, r *http.Request, appReq Request) (*SignedToken, bool) {
func ResolveRequest(log slog.Logger, dashboardURL *url.URL, p SignedTokenProvider, rw http.ResponseWriter, r *http.Request, appReq Request) (*SignedToken, bool) {
appReq = appReq.Normalize()
err := appReq.Validate()
if err != nil {
WriteWorkspaceApp500(log, accessURL, rw, r, &appReq, err, "invalid app request")
WriteWorkspaceApp500(log, dashboardURL, rw, r, &appReq, err, "invalid app request")
return nil, false
}
+620
View File
@@ -0,0 +1,620 @@
package workspaceapps
import (
"context"
"fmt"
"net"
"net/http"
"net/http/httputil"
"net/url"
"regexp"
"strconv"
"strings"
"sync"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"go.opentelemetry.io/otel/trace"
"nhooyr.io/websocket"
"cdr.dev/slog"
"github.com/coder/coder/agent"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/coderd/tracing"
"github.com/coder/coder/coderd/wsconncache"
"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.
//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",
}
// Server serves workspace apps endpoints, including:
// - Path-based apps
// - Subdomain app middleware
// - Workspace reconnecting-pty (aka. web terminal)
type Server 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
// httpapi.CompileHostnamePattern(). It MUST be set if Hostname is set.
HostnameRegex *regexp.Regexp
DeploymentValues *codersdk.DeploymentValues
RealIPConfig *httpmw.RealIPConfig
SignedTokenProvider SignedTokenProvider
WorkspaceConnCache *wsconncache.Cache
AppSecurityKey SecurityKey
websocketWaitMutex sync.Mutex
websocketWaitGroup sync.WaitGroup
}
// 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()
// The caller must close the SignedTokenProvider (if necessary) and the
// wsconncache.
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)
}
// workspaceAppsProxyPath proxies requests to a workspace application
// through a relative URL path.
func (s *Server) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request) {
if s.DeploymentValues.DisablePathApps.Value() {
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusUnauthorized,
Title: "Unauthorized",
Description: "Path-based applications are disabled on this Coder deployment by the administrator.",
RetryEnabled: false,
DashboardURL: s.DashboardURL.String(),
})
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.",
RetryEnabled: false,
DashboardURL: s.DashboardURL.String(),
})
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(s.Logger, s.DashboardURL, s.SignedTokenProvider, rw, r, Request{
AccessMethod: AccessMethodPath,
BasePath: basePath,
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"),
})
if !ok {
return
}
s.proxyWorkspaceApp(rw, r, *token, chiPath)
}
// SubdomainAppMW 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 httpapi.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) SubdomainAppMW(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 := 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 := s.parseHostname(rw, r, next, host)
if !ok {
return
}
// 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.
token, err := s.AppSecurityKey.DecryptAPIKey(encryptedAPIKey)
if err != nil {
s.Logger.Debug(ctx, "could not decrypt 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.",
// Retry is disabled because the user needs to remove
// the query parameter before they try again.
RetryEnabled: false,
DashboardURL: s.DashboardURL.String(),
})
return
}
s.setWorkspaceAppCookie(rw, r, token)
// 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
}
token, ok := ResolveRequest(s.Logger, s.DashboardURL, s.SignedTokenProvider, rw, r, Request{
AccessMethod: AccessMethodSubdomain,
BasePath: "/",
UsernameOrID: app.Username,
WorkspaceNameOrID: app.WorkspaceName,
AgentNameOrID: app.AgentName,
AppSlugOrPort: app.AppSlugOrPort,
})
if !ok {
return
}
// 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) {
s.proxyWorkspaceApp(rw, r, *token, r.URL.Path)
})).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 httpapi.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) (httpapi.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 httpapi.HostnamesMatch(s.DashboardURL.Hostname(), host) || httpapi.HostnamesMatch(s.AccessURL.Hostname(), host) {
next.ServeHTTP(rw, r)
return httpapi.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 httpapi.ApplicationURL{}, false
}
// Split the subdomain so we can parse the application details and verify it
// matches the configured app hostname later.
subdomain, ok := httpapi.ExecuteHostnamePattern(s.HostnameRegex, host)
if !ok {
// Doesn't match the regex, so it's not a valid application URL.
next.ServeHTTP(rw, r)
return httpapi.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.StatusTemporaryRedirect)
return httpapi.ApplicationURL{}, false
}
// Parse the application URL from the subdomain.
app, err := httpapi.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()),
RetryEnabled: false,
DashboardURL: s.DashboardURL.String(),
})
return httpapi.ApplicationURL{}, false
}
return app, true
}
// 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.
func (s *Server) setWorkspaceAppCookie(rw http.ResponseWriter, r *http.Request, token string) bool {
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.",
RetryEnabled: false,
DashboardURL: s.DashboardURL.String(),
})
return false
}
// Set the app cookie for all subdomains of s.Hostname. 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).
cookieHost := "." + hostSplit[1]
http.SetCookie(rw, &http.Cookie{
Name: codersdk.DevURLSessionTokenCookie,
Value: token,
Domain: cookieHost,
Path: "/",
MaxAge: 0,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: s.DeploymentValues.SecureAuthCookie.Value(),
})
return true
}
func (s *Server) proxyWorkspaceApp(rw http.ResponseWriter, r *http.Request, appToken SignedToken, path string) {
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()),
RetryEnabled: true,
DashboardURL: s.DashboardURL.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.", appToken.AppURL, port),
Detail: err.Error(),
})
return
}
if portInt < codersdk.WorkspaceAgentMinimumListeningPort {
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.WorkspaceAgentMinimumListeningPort),
})
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.
http.Redirect(rw, r, r.URL.Path+"/", 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.RawQuery = appURL.RawQuery
http.Redirect(rw, r, r.URL.String(), http.StatusTemporaryRedirect)
return
}
r.URL.Path = 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: s.DashboardURL.String(),
})
}
conn, release, err := s.WorkspaceConnCache.Acquire(appToken.AgentID)
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: s.DashboardURL.String(),
})
return
}
defer release()
proxy.Transport = conn.HTTPTransport()
// 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))
}
// 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))
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 /workspaceagents/{workspaceagent}/pty [get]
func (s *Server) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
s.websocketWaitMutex.Lock()
s.websocketWaitGroup.Add(1)
s.websocketWaitMutex.Unlock()
defer s.websocketWaitGroup.Done()
appToken, ok := ResolveRequest(s.Logger, s.AccessURL, s.SignedTokenProvider, rw, r, Request{
AccessMethod: AccessMethodTerminal,
BasePath: r.URL.Path,
AgentNameOrID: chi.URLParam(r, "workspaceagent"),
})
if !ok {
return
}
values := r.URL.Query()
parser := httpapi.NewQueryParamParser()
reconnect := parser.Required("reconnect").UUID(values, uuid.New(), "reconnect")
height := parser.UInt(values, 80, "height")
width := parser.UInt(values, 80, "width")
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,
})
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.
go httpapi.Heartbeat(ctx, conn)
agentConn, release, err := s.WorkspaceConnCache.Acquire(appToken.AgentID)
if err != nil {
_ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("dial workspace agent: %s", err))
return
}
defer release()
ptNetConn, err := agentConn.ReconnectingPTY(ctx, reconnect, uint16(height), uint16(width), r.URL.Query().Get("command"))
if err != nil {
_ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("dial: %s", err))
return
}
defer ptNetConn.Close()
agent.Bicopy(ctx, wsNetConn, ptNetConn)
}
// 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,
}
}
+8
View File
@@ -0,0 +1,8 @@
package workspaceapps_test
// NOTE: for now, app proxying tests are still in their old locations, pending
// being moved to their own package.
//
// See:
// - coderd/workspaceapps_test.go
// - coderd/workspaceagents_test.go (for PTY)
-102
View File
@@ -1,102 +0,0 @@
package workspaceapps
import (
"encoding/json"
"time"
"github.com/google/uuid"
"golang.org/x/xerrors"
"gopkg.in/square/go-jose.v2"
)
const tokenSigningAlgorithm = jose.HS512
// SignedToken is the struct data contained inside a workspace app JWE. It
// contains the details of the workspace app that the token is valid for to
// avoid database queries.
type SignedToken struct {
// Request details.
Request `json:"request"`
// Trusted resolved details.
Expiry time.Time `json:"expiry"` // set by GenerateToken if unset
UserID uuid.UUID `json:"user_id"`
WorkspaceID uuid.UUID `json:"workspace_id"`
AgentID uuid.UUID `json:"agent_id"`
AppURL string `json:"app_url"`
}
// MatchesRequest returns true if the token matches the request. Any token that
// does not match the request should be considered invalid.
func (t SignedToken) MatchesRequest(req Request) bool {
return t.AccessMethod == req.AccessMethod &&
t.BasePath == req.BasePath &&
t.UsernameOrID == req.UsernameOrID &&
t.WorkspaceNameOrID == req.WorkspaceNameOrID &&
t.AgentNameOrID == req.AgentNameOrID &&
t.AppSlugOrPort == req.AppSlugOrPort
}
// GenerateToken generates a signed workspace app token with the given key and
// payload. If the payload doesn't have an expiry, it will be set to the current
// time plus the default expiry.
func GenerateToken(key []byte, payload SignedToken) (string, error) {
if payload.Expiry.IsZero() {
payload.Expiry = time.Now().Add(DefaultTokenExpiry)
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return "", xerrors.Errorf("marshal payload to JSON: %w", err)
}
signer, err := jose.NewSigner(jose.SigningKey{
Algorithm: tokenSigningAlgorithm,
Key: key,
}, nil)
if err != nil {
return "", xerrors.Errorf("create signer: %w", err)
}
signedObject, err := signer.Sign(payloadBytes)
if err != nil {
return "", xerrors.Errorf("sign payload: %w", err)
}
serialized, err := signedObject.CompactSerialize()
if err != nil {
return "", xerrors.Errorf("serialize JWS: %w", err)
}
return serialized, nil
}
// ParseToken parses a signed workspace app token with the given key and returns
// the payload. If the token is invalid or expired, an error is returned.
func ParseToken(key []byte, str string) (SignedToken, error) {
object, err := jose.ParseSigned(str)
if err != nil {
return SignedToken{}, xerrors.Errorf("parse JWS: %w", err)
}
if len(object.Signatures) != 1 {
return SignedToken{}, xerrors.New("expected 1 signature")
}
if object.Signatures[0].Header.Algorithm != string(tokenSigningAlgorithm) {
return SignedToken{}, xerrors.Errorf("expected token signing algorithm to be %q, got %q", tokenSigningAlgorithm, object.Signatures[0].Header.Algorithm)
}
output, err := object.Verify(key)
if err != nil {
return SignedToken{}, xerrors.Errorf("verify JWS: %w", err)
}
var tok SignedToken
err = json.Unmarshal(output, &tok)
if err != nil {
return SignedToken{}, xerrors.Errorf("unmarshal payload: %w", err)
}
if tok.Expiry.Before(time.Now()) {
return SignedToken{}, xerrors.New("signed app token expired")
}
return tok, nil
}
+219
View File
@@ -0,0 +1,219 @@
package workspaceapps
import (
"encoding/base64"
"encoding/hex"
"encoding/json"
"time"
"github.com/go-jose/go-jose/v3"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd/database"
)
const (
tokenSigningAlgorithm = jose.HS512
apiKeyEncryptionAlgorithm = jose.A256GCMKW
)
// SignedToken is the struct data contained inside a workspace app JWE. It
// contains the details of the workspace app that the token is valid for to
// avoid database queries.
type SignedToken struct {
// Request details.
Request `json:"request"`
// Trusted resolved details.
Expiry time.Time `json:"expiry"` // set by GenerateToken if unset
UserID uuid.UUID `json:"user_id"`
WorkspaceID uuid.UUID `json:"workspace_id"`
AgentID uuid.UUID `json:"agent_id"`
AppURL string `json:"app_url"`
}
// MatchesRequest returns true if the token matches the request. Any token that
// does not match the request should be considered invalid.
func (t SignedToken) MatchesRequest(req Request) bool {
return t.AccessMethod == req.AccessMethod &&
t.BasePath == req.BasePath &&
t.UsernameOrID == req.UsernameOrID &&
t.WorkspaceNameOrID == req.WorkspaceNameOrID &&
t.AgentNameOrID == req.AgentNameOrID &&
t.AppSlugOrPort == req.AppSlugOrPort
}
// SecurityKey is used for signing and encrypting app tokens and API keys.
//
// The first 64 bytes of the key are used for signing tokens with HMAC-SHA256,
// and the last 32 bytes are used for encrypting API keys with AES-256-GCM.
// We use a single key for both operations to avoid having to store and manage
// two keys.
type SecurityKey [96]byte
func (k SecurityKey) signingKey() []byte {
return k[:64]
}
func (k SecurityKey) encryptionKey() []byte {
return k[64:]
}
func KeyFromString(str string) (SecurityKey, error) {
var key SecurityKey
decoded, err := hex.DecodeString(str)
if err != nil {
return key, xerrors.Errorf("decode key: %w", err)
}
if len(decoded) != len(key) {
return key, xerrors.Errorf("expected key to be %d bytes, got %d", len(key), len(decoded))
}
copy(key[:], decoded)
return key, nil
}
// SignToken generates a signed workspace app token with the given payload. If
// the payload doesn't have an expiry, it will be set to the current time plus
// the default expiry.
func (k SecurityKey) SignToken(payload SignedToken) (string, error) {
if payload.Expiry.IsZero() {
payload.Expiry = time.Now().Add(DefaultTokenExpiry)
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return "", xerrors.Errorf("marshal payload to JSON: %w", err)
}
signer, err := jose.NewSigner(jose.SigningKey{
Algorithm: tokenSigningAlgorithm,
Key: k.signingKey(),
}, nil)
if err != nil {
return "", xerrors.Errorf("create signer: %w", err)
}
signedObject, err := signer.Sign(payloadBytes)
if err != nil {
return "", xerrors.Errorf("sign payload: %w", err)
}
serialized, err := signedObject.CompactSerialize()
if err != nil {
return "", xerrors.Errorf("serialize JWS: %w", err)
}
return serialized, nil
}
// VerifySignedToken parses a signed workspace app token with the given key and
// returns the payload. If the token is invalid or expired, an error is
// returned.
func (k SecurityKey) VerifySignedToken(str string) (SignedToken, error) {
object, err := jose.ParseSigned(str)
if err != nil {
return SignedToken{}, xerrors.Errorf("parse JWS: %w", err)
}
if len(object.Signatures) != 1 {
return SignedToken{}, xerrors.New("expected 1 signature")
}
if object.Signatures[0].Header.Algorithm != string(tokenSigningAlgorithm) {
return SignedToken{}, xerrors.Errorf("expected token signing algorithm to be %q, got %q", tokenSigningAlgorithm, object.Signatures[0].Header.Algorithm)
}
output, err := object.Verify(k.signingKey())
if err != nil {
return SignedToken{}, xerrors.Errorf("verify JWS: %w", err)
}
var tok SignedToken
err = json.Unmarshal(output, &tok)
if err != nil {
return SignedToken{}, xerrors.Errorf("unmarshal payload: %w", err)
}
if tok.Expiry.Before(time.Now()) {
return SignedToken{}, xerrors.New("signed app token expired")
}
return tok, nil
}
type EncryptedAPIKeyPayload struct {
APIKey string `json:"api_key"`
ExpiresAt time.Time `json:"expires_at"`
}
// EncryptAPIKey encrypts an API key for subdomain token smuggling.
func (k SecurityKey) EncryptAPIKey(payload EncryptedAPIKeyPayload) (string, error) {
if payload.APIKey == "" {
return "", xerrors.New("API key is empty")
}
if payload.ExpiresAt.IsZero() {
// Very short expiry as these keys are only used once as part of an
// automatic redirection flow.
payload.ExpiresAt = database.Now().Add(time.Minute)
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return "", xerrors.Errorf("marshal payload: %w", err)
}
// JWEs seem to apply a nonce themselves.
encrypter, err := jose.NewEncrypter(
jose.A256GCM,
jose.Recipient{
Algorithm: apiKeyEncryptionAlgorithm,
Key: k.encryptionKey(),
},
&jose.EncrypterOptions{
Compression: jose.DEFLATE,
},
)
if err != nil {
return "", xerrors.Errorf("initializer jose encrypter: %w", err)
}
encryptedObject, err := encrypter.Encrypt(payloadBytes)
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 (k SecurityKey) DecryptAPIKey(encryptedAPIKey string) (string, error) {
encrypted, err := base64.RawURLEncoding.DecodeString(encryptedAPIKey)
if err != nil {
return "", xerrors.Errorf("base64 decode encrypted API key: %w", err)
}
object, err := jose.ParseEncrypted(string(encrypted))
if err != nil {
return "", xerrors.Errorf("parse encrypted API key: %w", err)
}
if object.Header.Algorithm != string(apiKeyEncryptionAlgorithm) {
return "", xerrors.Errorf("expected API key encryption algorithm to be %q, got %q", apiKeyEncryptionAlgorithm, object.Header.Algorithm)
}
// Decrypt using the hashed secret.
decrypted, err := object.Decrypt(k.encryptionKey())
if err != nil {
return "", xerrors.Errorf("decrypt API key: %w", err)
}
// Unmarshal the payload.
var payload EncryptedAPIKeyPayload
if err := json.Unmarshal(decrypted, &payload); err != nil {
return "", xerrors.Errorf("unmarshal decrypted payload: %w", err)
}
// Validate expiry.
if payload.ExpiresAt.Before(database.Now()) {
return "", xerrors.New("encrypted API key expired")
}
return payload.APIKey, nil
}
@@ -1,16 +1,18 @@
package workspaceapps_test
import (
"encoding/hex"
"fmt"
"testing"
"time"
"github.com/go-jose/go-jose/v3"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"gopkg.in/square/go-jose.v2"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/workspaceapps"
"github.com/coder/coder/cryptorand"
)
func Test_TokenMatchesRequest(t *testing.T) {
@@ -164,7 +166,7 @@ func Test_GenerateToken(t *testing.T) {
t.Run("SetExpiry", func(t *testing.T) {
t.Parallel()
tokenStr, err := workspaceapps.GenerateToken(coderdtest.AppSigningKey, workspaceapps.SignedToken{
tokenStr, err := coderdtest.AppSecurityKey.SignToken(workspaceapps.SignedToken{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
@@ -182,7 +184,7 @@ func Test_GenerateToken(t *testing.T) {
})
require.NoError(t, err)
token, err := workspaceapps.ParseToken(coderdtest.AppSigningKey, tokenStr)
token, err := coderdtest.AppSecurityKey.VerifySignedToken(tokenStr)
require.NoError(t, err)
require.WithinDuration(t, time.Now().Add(time.Minute), token.Expiry, 15*time.Second)
@@ -260,13 +262,13 @@ func Test_GenerateToken(t *testing.T) {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
str, err := workspaceapps.GenerateToken(coderdtest.AppSigningKey, c.token)
str, err := coderdtest.AppSecurityKey.SignToken(c.token)
require.NoError(t, err)
// Tokens aren't deterministic as they have a random nonce, so we
// can't compare them directly.
token, err := workspaceapps.ParseToken(coderdtest.AppSigningKey, str)
token, err := coderdtest.AppSecurityKey.VerifySignedToken(str)
if c.parseErrContains != "" {
require.Error(t, err)
require.ErrorContains(t, err, c.parseErrContains)
@@ -289,7 +291,7 @@ func Test_ParseToken(t *testing.T) {
t.Run("InvalidJWS", func(t *testing.T) {
t.Parallel()
token, err := workspaceapps.ParseToken(coderdtest.AppSigningKey, "invalid")
token, err := coderdtest.AppSecurityKey.VerifySignedToken("invalid")
require.Error(t, err)
require.ErrorContains(t, err, "parse JWS")
require.Equal(t, workspaceapps.SignedToken{}, token)
@@ -299,12 +301,14 @@ func Test_ParseToken(t *testing.T) {
t.Parallel()
// Create a valid token using a different key.
otherKey, err := hex.DecodeString("62656566646561646265656664656164626565666465616462656566646561646265656664656164626565666465616462656566646561646265656664656164")
require.NoError(t, err)
require.NotEqual(t, coderdtest.AppSigningKey, otherKey)
require.Len(t, otherKey, 64)
var otherKey workspaceapps.SecurityKey
copy(otherKey[:], coderdtest.AppSecurityKey[:])
for i := range otherKey {
otherKey[i] ^= 0xff
}
require.NotEqual(t, coderdtest.AppSecurityKey, otherKey)
tokenStr, err := workspaceapps.GenerateToken(otherKey, workspaceapps.SignedToken{
tokenStr, err := otherKey.SignToken(workspaceapps.SignedToken{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
@@ -323,7 +327,7 @@ func Test_ParseToken(t *testing.T) {
require.NoError(t, err)
// Verify the token is invalid.
token, err := workspaceapps.ParseToken(coderdtest.AppSigningKey, tokenStr)
token, err := coderdtest.AppSecurityKey.VerifySignedToken(tokenStr)
require.Error(t, err)
require.ErrorContains(t, err, "verify JWS")
require.Equal(t, workspaceapps.SignedToken{}, token)
@@ -333,16 +337,86 @@ func Test_ParseToken(t *testing.T) {
t.Parallel()
// Create a signature for an invalid body.
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS512, Key: coderdtest.AppSigningKey}, nil)
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS512, Key: coderdtest.AppSecurityKey[:64]}, nil)
require.NoError(t, err)
signedObject, err := signer.Sign([]byte("hi"))
require.NoError(t, err)
serialized, err := signedObject.CompactSerialize()
require.NoError(t, err)
token, err := workspaceapps.ParseToken(coderdtest.AppSigningKey, serialized)
token, err := coderdtest.AppSecurityKey.VerifySignedToken(serialized)
require.Error(t, err)
require.ErrorContains(t, err, "unmarshal payload")
require.Equal(t, workspaceapps.SignedToken{}, token)
})
}
func TestAPIKeyEncryption(t *testing.T) {
t.Parallel()
genAPIKey := func(t *testing.T) string {
id, _ := cryptorand.String(10)
secret, _ := cryptorand.String(22)
return fmt.Sprintf("%s-%s", id, secret)
}
t.Run("OK", func(t *testing.T) {
t.Parallel()
key := genAPIKey(t)
encrypted, err := coderdtest.AppSecurityKey.EncryptAPIKey(workspaceapps.EncryptedAPIKeyPayload{
APIKey: key,
})
require.NoError(t, err)
decryptedKey, err := coderdtest.AppSecurityKey.DecryptAPIKey(encrypted)
require.NoError(t, err)
require.Equal(t, key, decryptedKey)
})
t.Run("Verifies", func(t *testing.T) {
t.Parallel()
t.Run("Expiry", func(t *testing.T) {
t.Parallel()
key := genAPIKey(t)
encrypted, err := coderdtest.AppSecurityKey.EncryptAPIKey(workspaceapps.EncryptedAPIKeyPayload{
APIKey: key,
ExpiresAt: database.Now().Add(-1 * time.Hour),
})
require.NoError(t, err)
decryptedKey, err := coderdtest.AppSecurityKey.DecryptAPIKey(encrypted)
require.Error(t, err)
require.ErrorContains(t, err, "expired")
require.Empty(t, decryptedKey)
})
t.Run("EncryptionKey", func(t *testing.T) {
t.Parallel()
// Create a valid token using a different key.
var otherKey workspaceapps.SecurityKey
copy(otherKey[:], coderdtest.AppSecurityKey[:])
for i := range otherKey {
otherKey[i] ^= 0xff
}
require.NotEqual(t, coderdtest.AppSecurityKey, otherKey)
// Encrypt with the other key.
key := genAPIKey(t)
encrypted, err := otherKey.EncryptAPIKey(workspaceapps.EncryptedAPIKeyPayload{
APIKey: key,
})
require.NoError(t, err)
// Decrypt with the original key.
decryptedKey, err := coderdtest.AppSecurityKey.DecryptAPIKey(encrypted)
require.Error(t, err)
require.ErrorContains(t, err, "decrypt API key")
require.Empty(t, decryptedKey)
})
})
}