mirror of https://github.com/coder/coder.git
798 lines
28 KiB
Go
798 lines
28 KiB
Go
package coderd
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httputil"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"go.opentelemetry.io/otel/trace"
|
|
"golang.org/x/xerrors"
|
|
jose "gopkg.in/square/go-jose.v2"
|
|
|
|
"cdr.dev/slog"
|
|
"github.com/coder/coder/coderd/database"
|
|
"github.com/coder/coder/coderd/database/dbauthz"
|
|
"github.com/coder/coder/coderd/httpapi"
|
|
"github.com/coder/coder/coderd/httpmw"
|
|
"github.com/coder/coder/coderd/rbac"
|
|
"github.com/coder/coder/coderd/tracing"
|
|
"github.com/coder/coder/coderd/workspaceapps"
|
|
"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.
|
|
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",
|
|
}
|
|
|
|
// @Summary Get applications host
|
|
// @ID get-applications-host
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Applications
|
|
// @Success 200 {object} codersdk.AppHostResponse
|
|
// @Router /applications/host [get]
|
|
func (api *API) appHost(rw http.ResponseWriter, r *http.Request) {
|
|
host := api.AppHostname
|
|
if host != "" && api.AccessURL.Port() != "" {
|
|
host += fmt.Sprintf(":%s", api.AccessURL.Port())
|
|
}
|
|
|
|
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.AppHostResponse{
|
|
Host: host,
|
|
})
|
|
}
|
|
|
|
// workspaceAppsProxyPath proxies requests to a workspace application
|
|
// through a relative URL path.
|
|
func (api *API) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request) {
|
|
if api.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: api.AccessURL.String(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// If the username in the request is @me, then redirect to the current
|
|
// username. The resolveWorkspaceApp function does not accept @me for
|
|
// security purposes.
|
|
if chi.URLParam(r, "user") == codersdk.Me {
|
|
_, roles, ok := httpmw.ExtractAPIKey(rw, r, httpmw.ExtractAPIKeyConfig{
|
|
DB: api.Database,
|
|
OAuth2Configs: &httpmw.OAuth2Configs{
|
|
Github: api.GithubOAuth2Config,
|
|
OIDC: api.OIDCConfig,
|
|
},
|
|
RedirectToLogin: true,
|
|
DisableSessionExpiryRefresh: api.DeploymentValues.DisableSessionExpiryRefresh.Value(),
|
|
})
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
http.Redirect(rw, r, strings.Replace(r.URL.Path, "@me", "@"+roles.Username, 1), http.StatusTemporaryRedirect)
|
|
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
|
|
}
|
|
|
|
ticket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, workspaceapps.Request{
|
|
AccessMethod: workspaceapps.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
|
|
}
|
|
|
|
api.proxyWorkspaceApplication(rw, r, *ticket, chiPath)
|
|
}
|
|
|
|
// handleSubdomainApplications handles subdomain-based application proxy
|
|
// requests (aka. DevURLs in Coder V1).
|
|
//
|
|
// There are a lot of paths here:
|
|
// 1. If api.AppHostname is not set then we pass on.
|
|
// 2. If we can't read the request hostname then we return a 400.
|
|
// 3. If the request hostname matches api.AccessURL then we pass on.
|
|
// 5. We split the subdomain into the subdomain and the "rest". If there are no
|
|
// periods in the hostname then we pass on.
|
|
// 5. We parse the subdomain into a httpapi.ApplicationURL struct. If we
|
|
// encounter an error:
|
|
// a. If the "rest" does not match api.AppHostname then we pass on;
|
|
// b. Otherwise, we return a 400.
|
|
// 6. Finally, we verify that the "rest" matches api.AppHostname, else we
|
|
// return a 404.
|
|
//
|
|
// Rationales for each of the above steps:
|
|
// 1. We pass on if api.AppHostname is not set to avoid returning any errors if
|
|
// `--app-hostname` is not configured.
|
|
// 2. Every request should have a valid Host header anyways.
|
|
// 3. We pass on if the request hostname matches api.AccessURL so we can
|
|
// support having the access URL be at the same level as the application
|
|
// base hostname.
|
|
// 4. We pass on if there are no periods in the hostname as application URLs
|
|
// must be a subdomain of a hostname, which implies there must be at least
|
|
// one period.
|
|
// 5. a. If the request subdomain is not a valid application URL, and the
|
|
// "rest" does not match api.AppHostname, then it is very unlikely that
|
|
// the request was intended for this handler. We pass on.
|
|
// b. If the request subdomain is not a valid application URL, but the
|
|
// "rest" matches api.AppHostname, then we return a 400 because the
|
|
// request is probably a typo or something.
|
|
// 6. We finally verify that the "rest" matches api.AppHostname for security
|
|
// purposes regarding re-authentication and application proxy session
|
|
// tokens.
|
|
func (api *API) handleSubdomainApplications(middlewares ...func(http.Handler) http.Handler) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
// Step 1: Pass on if subdomain-based application proxying is not
|
|
// configured.
|
|
if api.AppHostname == "" || api.AppHostnameRegex == 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 := api.parseWorkspaceApplicationHostname(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 := decryptAPIKey(r.Context(), api.Database, encryptedAPIKey)
|
|
if err != nil {
|
|
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
|
Status: http.StatusBadRequest,
|
|
Title: "Bad Request",
|
|
Description: "Could not decrypt API key. Please remove the query parameter and try again.",
|
|
// Retry is disabled because the user needs to remove
|
|
// the query parameter before they try again.
|
|
RetryEnabled: false,
|
|
DashboardURL: api.AccessURL.String(),
|
|
})
|
|
return
|
|
}
|
|
|
|
api.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
|
|
}
|
|
|
|
ticket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, workspaceapps.Request{
|
|
AccessMethod: workspaceapps.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) {
|
|
api.proxyWorkspaceApplication(rw, r, *ticket, r.URL.Path)
|
|
})).ServeHTTP(rw, r.WithContext(ctx))
|
|
})
|
|
}
|
|
}
|
|
|
|
// workspaceApplicationAuth is an endpoint on the main router that handles
|
|
// redirects from the subdomain handler.
|
|
//
|
|
// This endpoint is under /api so we don't return the friendly error page here.
|
|
// Any errors on this endpoint should be errors that are unlikely to happen
|
|
// in production unless the user messes with the URL.
|
|
//
|
|
// @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]
|
|
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, apiKey) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
|
|
// Get the redirect URI from the query parameters and parse it.
|
|
redirectURI := r.URL.Query().Get(workspaceapps.RedirectURIQueryParam)
|
|
if redirectURI == "" {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Missing redirect_uri query parameter.",
|
|
})
|
|
return
|
|
}
|
|
u, err := url.Parse(redirectURI)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Invalid redirect_uri query parameter.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
// Force the redirect URI to use the same scheme as the access URL for
|
|
// security purposes.
|
|
u.Scheme = api.AccessURL.Scheme
|
|
|
|
// Ensure that the redirect URI is a subdomain of api.AppHostname and is a
|
|
// valid app subdomain.
|
|
subdomain, ok := httpapi.ExecuteHostnamePattern(api.AppHostnameRegex, u.Host)
|
|
if !ok {
|
|
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.
|
|
exp := apiKey.ExpiresAt
|
|
lifetimeSeconds := apiKey.LifetimeSeconds
|
|
if exp.IsZero() || time.Until(exp) > api.DeploymentValues.SessionDuration.Value() {
|
|
exp = database.Now().Add(api.DeploymentValues.SessionDuration.Value())
|
|
lifetimeSeconds = int64(api.DeploymentValues.SessionDuration.Value().Seconds())
|
|
}
|
|
cookie, _, err := api.createAPIKey(ctx, createAPIKeyParams{
|
|
UserID: apiKey.UserID,
|
|
LoginType: database.LoginTypePassword,
|
|
ExpiresAt: exp,
|
|
LifetimeSeconds: lifetimeSeconds,
|
|
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)
|
|
}
|
|
|
|
func (api *API) parseWorkspaceApplicationHostname(rw http.ResponseWriter, r *http.Request, next http.Handler, host string) (httpapi.ApplicationURL, bool) {
|
|
// Check if the hostname matches the access URL. If it does, the user was
|
|
// definitely trying to connect to the dashboard/API.
|
|
if httpapi.HostnamesMatch(api.AccessURL.Hostname(), host) {
|
|
next.ServeHTTP(rw, r)
|
|
return httpapi.ApplicationURL{}, false
|
|
}
|
|
|
|
// 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(api.AppHostnameRegex, 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 a logout flow.
|
|
if subdomain == appLogoutHostname {
|
|
api.handleWorkspaceSubdomainAppLogout(rw, r)
|
|
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: api.AccessURL.String(),
|
|
})
|
|
return httpapi.ApplicationURL{}, false
|
|
}
|
|
|
|
return app, true
|
|
}
|
|
|
|
func (api *API) handleWorkspaceSubdomainAppLogout(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(codersdk.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.
|
|
//nolint:gocritic // needed for workspace app logout
|
|
apiKey, err := api.Database.GetAPIKeyByID(dbauthz.AsSystemRestricted(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
|
|
}
|
|
//nolint:gocritic // needed for workspace app logout
|
|
err = api.Database.DeleteAPIKeyByID(dbauthz.AsSystemRestricted(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(workspaceapps.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)
|
|
}
|
|
|
|
// 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: codersdk.DevURLSessionTokenCookie,
|
|
Value: token,
|
|
Domain: cookieHost,
|
|
Path: "/",
|
|
MaxAge: maxAge,
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
Secure: api.SecureAuthCookie,
|
|
})
|
|
|
|
return true
|
|
}
|
|
|
|
func (api *API) proxyWorkspaceApplication(rw http.ResponseWriter, r *http.Request, ticket workspaceapps.Ticket, path string) {
|
|
ctx := r.Context()
|
|
|
|
// 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
|
|
}
|
|
|
|
appURL, err := url.Parse(ticket.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", ticket.AppURL, err.Error()),
|
|
RetryEnabled: true,
|
|
DashboardURL: api.AccessURL.String(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Verify that the port is allowed. See the docs above
|
|
// `codersdk.MinimumListeningPort` for more details.
|
|
port := appURL.Port()
|
|
if port != "" {
|
|
portInt, err := strconv.Atoi(port)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: fmt.Sprintf("App URL %q has an invalid port %q.", ticket.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: api.AccessURL.String(),
|
|
})
|
|
}
|
|
|
|
conn, release, err := api.workspaceAgentCache.Acquire(r, ticket.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: api.AccessURL.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)
|
|
}
|
|
|
|
type encryptedAPIKeyPayload struct {
|
|
APIKey string `json:"api_key"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
}
|
|
|
|
// encryptAPIKey encrypts an API key with it's own hashed secret. This is used
|
|
// for smuggling (application_connect scoped) API keys securely to app
|
|
// hostnames.
|
|
//
|
|
// We encrypt API keys when smuggling them in query parameters to avoid them
|
|
// getting accidentally logged in access logs or stored in browser history.
|
|
func encryptAPIKey(data encryptedAPIKeyPayload) (string, error) {
|
|
if data.APIKey == "" {
|
|
return "", xerrors.New("API key is empty")
|
|
}
|
|
if data.ExpiresAt.IsZero() {
|
|
// Very short expiry as these keys are only used once as part of an
|
|
// automatic redirection flow.
|
|
data.ExpiresAt = database.Now().Add(time.Minute)
|
|
}
|
|
|
|
payload, err := json.Marshal(data)
|
|
if err != nil {
|
|
return "", xerrors.Errorf("marshal payload: %w", err)
|
|
}
|
|
|
|
// We use the hashed key secret as the encryption key. The hashed secret is
|
|
// stored in the API keys table. The HashedSecret is NEVER returned from the
|
|
// API.
|
|
//
|
|
// We chose to use the key secret as the private key for encryption instead
|
|
// of a shared key for a few reasons:
|
|
// 1. A single private key used to encrypt every API key would also be
|
|
// stored in the database, which means that the risk factor is similar.
|
|
// 2. The secret essentially rotates for each key (for free!), since each
|
|
// key has a different secret. This means that if someone acquires an
|
|
// old database dump they can't decrypt new API keys.
|
|
// 3. These tokens are scoped only for application_connect access.
|
|
keyID, keySecret, err := httpmw.SplitAPIToken(data.APIKey)
|
|
if err != nil {
|
|
return "", xerrors.Errorf("split API key: %w", err)
|
|
}
|
|
// SHA256 the key secret so it matches the hashed secret in the database.
|
|
// The key length doesn't matter to the jose.Encrypter.
|
|
privateKey := sha256.Sum256([]byte(keySecret))
|
|
|
|
// JWEs seem to apply a nonce themselves.
|
|
encrypter, err := jose.NewEncrypter(
|
|
jose.A256GCM,
|
|
jose.Recipient{
|
|
Algorithm: jose.A256GCMKW,
|
|
KeyID: keyID,
|
|
Key: privateKey[:],
|
|
},
|
|
&jose.EncrypterOptions{
|
|
Compression: jose.DEFLATE,
|
|
},
|
|
)
|
|
if err != nil {
|
|
return "", xerrors.Errorf("initializer jose encrypter: %w", err)
|
|
}
|
|
encryptedObject, err := encrypter.Encrypt(payload)
|
|
if err != nil {
|
|
return "", xerrors.Errorf("encrypt jwe: %w", err)
|
|
}
|
|
|
|
encrypted := encryptedObject.FullSerialize()
|
|
return base64.RawURLEncoding.EncodeToString([]byte(encrypted)), nil
|
|
}
|
|
|
|
// decryptAPIKey undoes encryptAPIKey and is used in the subdomain app handler.
|
|
func decryptAPIKey(ctx context.Context, db database.Store, encryptedAPIKey string) (database.APIKey, string, error) {
|
|
encrypted, err := base64.RawURLEncoding.DecodeString(encryptedAPIKey)
|
|
if err != nil {
|
|
return database.APIKey{}, "", xerrors.Errorf("base64 decode encrypted API key: %w", err)
|
|
}
|
|
|
|
object, err := jose.ParseEncrypted(string(encrypted))
|
|
if err != nil {
|
|
return database.APIKey{}, "", xerrors.Errorf("parse encrypted API key: %w", err)
|
|
}
|
|
|
|
// Lookup the API key so we can decrypt it.
|
|
keyID := object.Header.KeyID
|
|
//nolint:gocritic // needed to check API key
|
|
key, err := db.GetAPIKeyByID(dbauthz.AsSystemRestricted(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
|
|
}
|