Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 7c9002f

Browse files
committed
Merge branch 'main' into 3522-document-more-methods
2 parents e5a4618 + c7ce3e7 commit 7c9002f

File tree

13 files changed

+466
-75
lines changed

13 files changed

+466
-75
lines changed

cli/server.go

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -921,7 +921,8 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
921921
},
922922
}
923923

924-
root.AddCommand(&cobra.Command{
924+
var pgRawURL bool
925+
postgresBuiltinURLCmd := &cobra.Command{
925926
Use: "postgres-builtin-url",
926927
Short: "Output the connection URL for the built-in PostgreSQL deployment.",
927928
RunE: func(cmd *cobra.Command, _ []string) error {
@@ -930,37 +931,49 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
930931
if err != nil {
931932
return err
932933
}
933-
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "psql %q\n", url)
934+
if pgRawURL {
935+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s\n", url)
936+
} else {
937+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s\n", cliui.Styles.Code.Render(fmt.Sprintf("psql %q", url)))
938+
}
934939
return nil
935940
},
936-
})
937-
938-
root.AddCommand(&cobra.Command{
941+
}
942+
postgresBuiltinServeCmd := &cobra.Command{
939943
Use: "postgres-builtin-serve",
940944
Short: "Run the built-in PostgreSQL deployment.",
941945
RunE: func(cmd *cobra.Command, args []string) error {
946+
ctx := cmd.Context()
947+
942948
cfg := createConfig(cmd)
943949
logger := slog.Make(sloghuman.Sink(cmd.ErrOrStderr()))
944950
if ok, _ := cmd.Flags().GetBool(varVerbose); ok {
945951
logger = logger.Leveled(slog.LevelDebug)
946952
}
947953

948-
url, closePg, err := startBuiltinPostgres(cmd.Context(), cfg, logger)
954+
ctx, cancel := signal.NotifyContext(ctx, InterruptSignals...)
955+
defer cancel()
956+
957+
url, closePg, err := startBuiltinPostgres(ctx, cfg, logger)
949958
if err != nil {
950959
return err
951960
}
952961
defer func() { _ = closePg() }()
953962

954-
cmd.Println(cliui.Styles.Code.Render("psql \"" + url + "\""))
955-
956-
stopChan := make(chan os.Signal, 1)
957-
defer signal.Stop(stopChan)
958-
signal.Notify(stopChan, os.Interrupt)
963+
if pgRawURL {
964+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s\n", url)
965+
} else {
966+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s\n", cliui.Styles.Code.Render(fmt.Sprintf("psql %q", url)))
967+
}
959968

960-
<-stopChan
969+
<-ctx.Done()
961970
return nil
962971
},
963-
})
972+
}
973+
postgresBuiltinURLCmd.Flags().BoolVar(&pgRawURL, "raw-url", false, "Output the raw connection URL instead of a psql command.")
974+
postgresBuiltinServeCmd.Flags().BoolVar(&pgRawURL, "raw-url", false, "Output the raw connection URL instead of a psql command.")
975+
976+
root.AddCommand(postgresBuiltinURLCmd, postgresBuiltinServeCmd)
964977

965978
deployment.AttachFlags(root.Flags(), vip, false)
966979

cli/server_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,19 @@ func TestServer(t *testing.T) {
118118

119119
pty.ExpectMatch("psql")
120120
})
121+
t.Run("BuiltinPostgresURLRaw", func(t *testing.T) {
122+
t.Parallel()
123+
root, _ := clitest.New(t, "server", "postgres-builtin-url", "--raw-url")
124+
pty := ptytest.New(t)
125+
root.SetOutput(pty.Output())
126+
err := root.Execute()
127+
require.NoError(t, err)
128+
129+
got := pty.ReadLine()
130+
if !strings.HasPrefix(got, "postgres://") {
131+
t.Fatalf("expected postgres URL to start with \"postgres://\", got %q", got)
132+
}
133+
})
121134

122135
// Validate that a warning is printed that it may not be externally
123136
// reachable.

coderd/apikey.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ func (api *API) deleteAPIKey(rw http.ResponseWriter, r *http.Request) {
201201
}
202202

203203
// Generates a new ID and secret for an API key.
204-
func generateAPIKeyIDSecret() (id string, secret string, err error) {
204+
func GenerateAPIKeyIDSecret() (id string, secret string, err error) {
205205
// Length of an API Key ID.
206206
id, err = cryptorand.String(10)
207207
if err != nil {
@@ -239,7 +239,7 @@ func (api *API) validateAPIKeyLifetime(lifetime time.Duration) error {
239239
}
240240

241241
func (api *API) createAPIKey(ctx context.Context, params createAPIKeyParams) (*http.Cookie, error) {
242-
keyID, keySecret, err := generateAPIKeyIDSecret()
242+
keyID, keySecret, err := GenerateAPIKeyIDSecret()
243243
if err != nil {
244244
return nil, xerrors.Errorf("generate API key: %w", err)
245245
}

coderd/workspaceapps.go

Lines changed: 152 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"context"
66
"crypto/sha256"
7+
"crypto/subtle"
78
"database/sql"
89
"encoding/base64"
910
"encoding/json"
@@ -36,7 +37,15 @@ const (
3637
// conflict with query parameters that users may use.
3738
//nolint:gosec
3839
subdomainProxyAPIKeyParam = "coder_application_connect_api_key_35e783"
39-
redirectURIQueryParam = "redirect_uri"
40+
// redirectURIQueryParam is the query param for the app URL to be passed
41+
// back to the API auth endpoint on the main access URL.
42+
redirectURIQueryParam = "redirect_uri"
43+
// appLogoutHostname is the hostname to use for the logout redirect. When
44+
// the dashboard logs out, it will redirect to this subdomain of the app
45+
// hostname, and the server will remove the cookie and redirect to the main
46+
// login page.
47+
// It is important that this URL can never match a valid app hostname.
48+
appLogoutHostname = "coder-logout"
4049
)
4150

4251
// nonCanonicalHeaders is a map from "canonical" headers to the actual header we
@@ -264,6 +273,12 @@ func (api *API) parseWorkspaceApplicationHostname(rw http.ResponseWriter, r *htt
264273
return httpapi.ApplicationURL{}, false
265274
}
266275

276+
// Check if the request is part of a logout flow.
277+
if subdomain == appLogoutHostname {
278+
api.handleWorkspaceAppLogout(rw, r)
279+
return httpapi.ApplicationURL{}, false
280+
}
281+
267282
// Parse the application URL from the subdomain.
268283
app, err := httpapi.ParseSubdomainAppURL(subdomain)
269284
if err != nil {
@@ -280,6 +295,95 @@ func (api *API) parseWorkspaceApplicationHostname(rw http.ResponseWriter, r *htt
280295
return app, true
281296
}
282297

298+
func (api *API) handleWorkspaceAppLogout(rw http.ResponseWriter, r *http.Request) {
299+
ctx := r.Context()
300+
301+
// Delete the API key and cookie first before attempting to parse/validate
302+
// the redirect URI.
303+
cookie, err := r.Cookie(httpmw.DevURLSessionTokenCookie)
304+
if err == nil && cookie.Value != "" {
305+
id, secret, err := httpmw.SplitAPIToken(cookie.Value)
306+
// If it's not a valid token then we don't need to delete it from the
307+
// database, but we'll still delete the cookie.
308+
if err == nil {
309+
// To avoid a situation where someone overloads the API with
310+
// different auth formats, and tricks this endpoint into deleting an
311+
// unchecked API key, we validate that the secret matches the secret
312+
// we store in the database.
313+
apiKey, err := api.Database.GetAPIKeyByID(ctx, id)
314+
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
315+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
316+
Message: "Failed to lookup API key.",
317+
Detail: err.Error(),
318+
})
319+
return
320+
}
321+
// This is wrapped in `err == nil` because if the API key doesn't
322+
// exist, we still want to delete the cookie.
323+
if err == nil {
324+
hashedSecret := sha256.Sum256([]byte(secret))
325+
if subtle.ConstantTimeCompare(apiKey.HashedSecret, hashedSecret[:]) != 1 {
326+
httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{
327+
Message: httpmw.SignedOutErrorMessage,
328+
Detail: "API key secret is invalid.",
329+
})
330+
return
331+
}
332+
err = api.Database.DeleteAPIKeyByID(ctx, id)
333+
if err != nil {
334+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
335+
Message: "Failed to delete API key.",
336+
Detail: err.Error(),
337+
})
338+
return
339+
}
340+
}
341+
}
342+
}
343+
if !api.setWorkspaceAppCookie(rw, r, "") {
344+
return
345+
}
346+
347+
// Read the redirect URI from the query string.
348+
redirectURI := r.URL.Query().Get(redirectURIQueryParam)
349+
if redirectURI == "" {
350+
redirectURI = api.AccessURL.String()
351+
} else {
352+
// Validate that the redirect URI is a valid URL and exists on the same
353+
// host as the access URL or an app URL.
354+
parsedRedirectURI, err := url.Parse(redirectURI)
355+
if err != nil {
356+
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
357+
Status: http.StatusBadRequest,
358+
Title: "Invalid redirect URI",
359+
Description: fmt.Sprintf("Could not parse redirect URI %q: %s", redirectURI, err.Error()),
360+
RetryEnabled: false,
361+
DashboardURL: api.AccessURL.String(),
362+
})
363+
return
364+
}
365+
366+
// Check if the redirect URI is on the same host as the access URL or an
367+
// app URL.
368+
ok := httpapi.HostnamesMatch(api.AccessURL.Hostname(), parsedRedirectURI.Hostname())
369+
if !ok && api.AppHostnameRegex != nil {
370+
// We could also check that it's a valid application URL for
371+
// completeness, but this check should be good enough.
372+
_, ok = httpapi.ExecuteHostnamePattern(api.AppHostnameRegex, parsedRedirectURI.Hostname())
373+
}
374+
if !ok {
375+
// The redirect URI they provided is not allowed, but we don't want
376+
// to return an error page because it'll interrupt the logout flow,
377+
// so we just use the default access URL.
378+
parsedRedirectURI = api.AccessURL
379+
}
380+
381+
redirectURI = parsedRedirectURI.String()
382+
}
383+
384+
http.Redirect(rw, r, redirectURI, http.StatusTemporaryRedirect)
385+
}
386+
283387
// lookupWorkspaceApp looks up the workspace application by slug in the given
284388
// agent and returns it. If the application is not found or there was a server
285389
// error while looking it up, an HTML error page is returned and false is
@@ -417,7 +521,7 @@ func (api *API) verifyWorkspaceApplicationSubdomainAuth(rw http.ResponseWriter,
417521
// and strip that query parameter.
418522
if encryptedAPIKey := r.URL.Query().Get(subdomainProxyAPIKeyParam); encryptedAPIKey != "" {
419523
// Exchange the encoded API key for a real one.
420-
_, apiKey, err := decryptAPIKey(r.Context(), api.Database, encryptedAPIKey)
524+
_, token, err := decryptAPIKey(r.Context(), api.Database, encryptedAPIKey)
421525
if err != nil {
422526
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
423527
Status: http.StatusBadRequest,
@@ -431,33 +535,7 @@ func (api *API) verifyWorkspaceApplicationSubdomainAuth(rw http.ResponseWriter,
431535
return false
432536
}
433537

434-
hostSplit := strings.SplitN(api.AppHostname, ".", 2)
435-
if len(hostSplit) != 2 {
436-
// This should be impossible as we verify the app hostname on
437-
// startup, but we'll check anyways.
438-
api.Logger.Error(r.Context(), "could not split invalid app hostname", slog.F("hostname", api.AppHostname))
439-
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
440-
Status: http.StatusInternalServerError,
441-
Title: "Internal Server Error",
442-
Description: "The app is configured with an invalid app wildcard hostname. Please contact an administrator.",
443-
RetryEnabled: false,
444-
DashboardURL: api.AccessURL.String(),
445-
})
446-
return false
447-
}
448-
449-
// Set the app cookie for all subdomains of api.AppHostname. This cookie
450-
// is handled properly by the ExtractAPIKey middleware.
451-
cookieHost := "." + hostSplit[1]
452-
http.SetCookie(rw, &http.Cookie{
453-
Name: httpmw.DevURLSessionTokenCookie,
454-
Value: apiKey,
455-
Domain: cookieHost,
456-
Path: "/",
457-
HttpOnly: true,
458-
SameSite: http.SameSiteLaxMode,
459-
Secure: api.SecureAuthCookie,
460-
})
538+
api.setWorkspaceAppCookie(rw, r, token)
461539

462540
// Strip the query parameter.
463541
path := r.URL.Path
@@ -491,6 +569,51 @@ func (api *API) verifyWorkspaceApplicationSubdomainAuth(rw http.ResponseWriter,
491569
return false
492570
}
493571

572+
// setWorkspaceAppCookie sets a cookie on the workspace app domain. If the app
573+
// hostname cannot be parsed properly, a static error page is rendered and false
574+
// is returned.
575+
//
576+
// If an empty token is supplied, it will clear the cookie.
577+
func (api *API) setWorkspaceAppCookie(rw http.ResponseWriter, r *http.Request, token string) bool {
578+
hostSplit := strings.SplitN(api.AppHostname, ".", 2)
579+
if len(hostSplit) != 2 {
580+
// This should be impossible as we verify the app hostname on
581+
// startup, but we'll check anyways.
582+
api.Logger.Error(r.Context(), "could not split invalid app hostname", slog.F("hostname", api.AppHostname))
583+
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
584+
Status: http.StatusInternalServerError,
585+
Title: "Internal Server Error",
586+
Description: "The app is configured with an invalid app wildcard hostname. Please contact an administrator.",
587+
RetryEnabled: false,
588+
DashboardURL: api.AccessURL.String(),
589+
})
590+
return false
591+
}
592+
593+
// Set the app cookie for all subdomains of api.AppHostname. This cookie is
594+
// handled properly by the ExtractAPIKey middleware.
595+
//
596+
// We don't set an expiration because the key in the database already has an
597+
// expiration.
598+
maxAge := 0
599+
if token == "" {
600+
maxAge = -1
601+
}
602+
cookieHost := "." + hostSplit[1]
603+
http.SetCookie(rw, &http.Cookie{
604+
Name: httpmw.DevURLSessionTokenCookie,
605+
Value: token,
606+
Domain: cookieHost,
607+
Path: "/",
608+
MaxAge: maxAge,
609+
HttpOnly: true,
610+
SameSite: http.SameSiteLaxMode,
611+
Secure: api.SecureAuthCookie,
612+
})
613+
614+
return true
615+
}
616+
494617
// @Summary Redirect to URI with encrypted API key
495618
// @ID redirect-to-uri-with-encrypted-api-key
496619
// @Security CoderSessionToken

coderd/workspaceapps_internal_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ func TestAPIKeyEncryption(t *testing.T) {
1717
t.Parallel()
1818

1919
generateAPIKey := func(t *testing.T, db database.Store) (keyID, keySecret string, hashedSecret []byte, data encryptedAPIKeyPayload) {
20-
keyID, keySecret, err := generateAPIKeyIDSecret()
20+
keyID, keySecret, err := GenerateAPIKeyIDSecret()
2121
require.NoError(t, err)
2222

2323
hashedSecretArray := sha256.Sum256([]byte(keySecret))

0 commit comments

Comments
 (0)