Skip to content

Commit d165d76

Browse files
authored
feat: static error page in applications handlers (#4299)
1 parent ce95344 commit d165d76

File tree

9 files changed

+172
-220
lines changed

9 files changed

+172
-220
lines changed

coderd/workspaceapps.go

Lines changed: 92 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"context"
66
"crypto/sha256"
7+
"database/sql"
78
"encoding/base64"
89
"encoding/json"
910
"fmt"
@@ -66,10 +67,9 @@ func (api *API) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request)
6667
Workspace: workspace,
6768
Agent: agent,
6869
// We do not support port proxying for paths.
69-
AppName: chi.URLParam(r, "workspaceapp"),
70-
Port: 0,
71-
Path: chiPath,
72-
DashboardOnError: true,
70+
AppName: chi.URLParam(r, "workspaceapp"),
71+
Port: 0,
72+
Path: chiPath,
7373
}, rw, r)
7474
}
7575

@@ -162,33 +162,31 @@ func (api *API) handleSubdomainApplications(middlewares ...func(http.Handler) ht
162162
}
163163

164164
api.proxyWorkspaceApplication(proxyApplication{
165-
Workspace: workspace,
166-
Agent: agent,
167-
AppName: app.AppName,
168-
Port: app.Port,
169-
Path: r.URL.Path,
170-
DashboardOnError: false,
165+
Workspace: workspace,
166+
Agent: agent,
167+
AppName: app.AppName,
168+
Port: app.Port,
169+
Path: r.URL.Path,
171170
}, rw, r)
172171
})).ServeHTTP(rw, r.WithContext(ctx))
173172
})
174173
}
175174
}
176175

177176
func (api *API) parseWorkspaceApplicationHostname(rw http.ResponseWriter, r *http.Request, next http.Handler, host string) (httpapi.ApplicationURL, bool) {
178-
ctx := r.Context()
179-
// Check if the hostname matches the access URL. If it does, the
180-
// user was definitely trying to connect to the dashboard/API.
177+
// Check if the hostname matches the access URL. If it does, the user was
178+
// definitely trying to connect to the dashboard/API.
181179
if httpapi.HostnamesMatch(api.AccessURL.Hostname(), host) {
182180
next.ServeHTTP(rw, r)
183181
return httpapi.ApplicationURL{}, false
184182
}
185183

186-
// Split the subdomain so we can parse the application details and
187-
// verify it matches the configured app hostname later.
184+
// Split the subdomain so we can parse the application details and verify it
185+
// matches the configured app hostname later.
188186
subdomain, rest := httpapi.SplitSubdomain(host)
189187
if rest == "" {
190-
// If there are no periods in the hostname, then it can't be a
191-
// valid application URL.
188+
// If there are no periods in the hostname, then it can't be a valid
189+
// application URL.
192190
next.ServeHTTP(rw, r)
193191
return httpapi.ApplicationURL{}, false
194192
}
@@ -197,27 +195,34 @@ func (api *API) parseWorkspaceApplicationHostname(rw http.ResponseWriter, r *htt
197195
// Parse the application URL from the subdomain.
198196
app, err := httpapi.ParseSubdomainAppURL(subdomain)
199197
if err != nil {
200-
// If it isn't a valid app URL and the base domain doesn't match
201-
// the configured app hostname, this request was probably
202-
// destined for the dashboard/API router.
198+
// If it isn't a valid app URL and the base domain doesn't match the
199+
// configured app hostname, this request was probably destined for the
200+
// dashboard/API router.
203201
if !matchingBaseHostname {
204202
next.ServeHTTP(rw, r)
205203
return httpapi.ApplicationURL{}, false
206204
}
207205

208-
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
209-
Message: "Could not parse subdomain application URL.",
210-
Detail: err.Error(),
206+
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
207+
Status: http.StatusBadRequest,
208+
Title: "Invalid application URL",
209+
Description: fmt.Sprintf("Could not parse subdomain application URL %q: %s", subdomain, err.Error()),
210+
RetryEnabled: false,
211+
DashboardURL: api.AccessURL.String(),
211212
})
212213
return httpapi.ApplicationURL{}, false
213214
}
214215

215-
// At this point we've verified that the subdomain looks like a
216-
// valid application URL, so the base hostname should match the
217-
// configured app hostname.
216+
// At this point we've verified that the subdomain looks like a valid
217+
// application URL, so the base hostname should match the configured app
218+
// hostname.
218219
if !matchingBaseHostname {
219-
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
220-
Message: "The server does not accept application requests on this hostname.",
220+
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
221+
Status: http.StatusNotFound,
222+
Title: "Not Found",
223+
Description: "The server does not accept application requests on this hostname.",
224+
RetryEnabled: false,
225+
DashboardURL: api.AccessURL.String(),
221226
})
222227
return httpapi.ApplicationURL{}, false
223228
}
@@ -230,12 +235,10 @@ func (api *API) parseWorkspaceApplicationHostname(rw http.ResponseWriter, r *htt
230235
// they will be redirected to the route below. If the user does have a session
231236
// key but insufficient permissions a static error page will be rendered.
232237
func (api *API) verifyWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.Request, workspace database.Workspace, host string) bool {
233-
ctx := r.Context()
234238
_, ok := httpmw.APIKeyOptional(r)
235239
if ok {
236240
if !api.Authorize(r, rbac.ActionCreate, workspace.ApplicationConnectRBAC()) {
237-
// TODO: This should be a static error page.
238-
httpapi.ResourceNotFound(rw)
241+
renderApplicationNotFound(rw, r, api.AccessURL)
239242
return false
240243
}
241244

@@ -249,9 +252,14 @@ func (api *API) verifyWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.R
249252
// Exchange the encoded API key for a real one.
250253
_, apiKey, err := decryptAPIKey(r.Context(), api.Database, encryptedAPIKey)
251254
if err != nil {
252-
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
253-
Message: "Could not decrypt API key. Please remove the query parameter and try again.",
254-
Detail: err.Error(),
255+
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
256+
Status: http.StatusBadRequest,
257+
Title: "Bad Request",
258+
Description: "Could not decrypt API key. Please remove the query parameter and try again.",
259+
// Retry is disabled because the user needs to remove the query
260+
// parameter before they try again.
261+
RetryEnabled: false,
262+
DashboardURL: api.AccessURL.String(),
255263
})
256264
return false
257265
}
@@ -302,6 +310,10 @@ func (api *API) verifyWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.R
302310

303311
// workspaceApplicationAuth is an endpoint on the main router that handles
304312
// redirects from the subdomain handler.
313+
//
314+
// This endpoint is under /api so we don't return the friendly error page here.
315+
// Any errors on this endpoint should be errors that are unlikely to happen
316+
// in production unless the user messes with the URL.
305317
func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request) {
306318
ctx := r.Context()
307319
if api.AppHostname == "" {
@@ -413,11 +425,6 @@ type proxyApplication struct {
413425
Port uint16
414426
// Path must either be empty or have a leading slash.
415427
Path string
416-
417-
// DashboardOnError determines whether or not the dashboard should be
418-
// rendered on error. This should be set for proxy path URLs but not
419-
// hostname based URLs.
420-
DashboardOnError bool
421428
}
422429

423430
func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.ResponseWriter, r *http.Request) {
@@ -439,17 +446,28 @@ func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.Res
439446
AgentID: proxyApp.Agent.ID,
440447
Name: proxyApp.AppName,
441448
})
449+
if xerrors.Is(err, sql.ErrNoRows) {
450+
renderApplicationNotFound(rw, r, api.AccessURL)
451+
return
452+
}
442453
if err != nil {
443-
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
444-
Message: "Internal error fetching workspace application.",
445-
Detail: err.Error(),
454+
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
455+
Status: http.StatusInternalServerError,
456+
Title: "Internal Server Error",
457+
Description: "Could not fetch workspace application: " + err.Error(),
458+
RetryEnabled: true,
459+
DashboardURL: api.AccessURL.String(),
446460
})
447461
return
448462
}
449463

450464
if !app.Url.Valid {
451-
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
452-
Message: fmt.Sprintf("Application %s does not have a url.", app.Name),
465+
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
466+
Status: http.StatusBadRequest,
467+
Title: "Bad Request",
468+
Description: fmt.Sprintf("Application %q does not have a URL set.", app.Name),
469+
RetryEnabled: true,
470+
DashboardURL: api.AccessURL.String(),
453471
})
454472
return
455473
}
@@ -458,9 +476,12 @@ func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.Res
458476

459477
appURL, err := url.Parse(internalURL)
460478
if err != nil {
461-
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
462-
Message: fmt.Sprintf("App URL %q is invalid.", internalURL),
463-
Detail: err.Error(),
479+
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
480+
Status: http.StatusBadRequest,
481+
Title: "Bad Request",
482+
Description: fmt.Sprintf("Application has an invalid URL %q: %s", internalURL, err.Error()),
483+
RetryEnabled: true,
484+
DashboardURL: api.AccessURL.String(),
464485
})
465486
return
466487
}
@@ -489,28 +510,23 @@ func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.Res
489510

490511
proxy := httputil.NewSingleHostReverseProxy(appURL)
491512
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
492-
if proxyApp.DashboardOnError {
493-
// To pass friendly errors to the frontend, special meta tags are
494-
// overridden in the index.html with the content passed here.
495-
r = r.WithContext(site.WithAPIResponse(ctx, site.APIResponse{
496-
StatusCode: http.StatusBadGateway,
497-
Message: err.Error(),
498-
}))
499-
api.siteHandler.ServeHTTP(w, r)
500-
return
501-
}
502-
503-
httpapi.Write(ctx, w, http.StatusBadGateway, codersdk.Response{
504-
Message: "Failed to proxy request to application.",
505-
Detail: err.Error(),
513+
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
514+
Status: http.StatusBadGateway,
515+
Title: "Bad Gateway",
516+
Description: "Failed to proxy request to application: " + err.Error(),
517+
RetryEnabled: true,
518+
DashboardURL: api.AccessURL.String(),
506519
})
507520
}
508521

509522
conn, release, err := api.workspaceAgentCache.Acquire(r, proxyApp.Agent.ID)
510523
if err != nil {
511-
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
512-
Message: "Failed to dial workspace agent.",
513-
Detail: err.Error(),
524+
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
525+
Status: http.StatusBadGateway,
526+
Title: "Bad Gateway",
527+
Description: "Could not connect to workspace agent: " + err.Error(),
528+
RetryEnabled: true,
529+
DashboardURL: api.AccessURL.String(),
514530
})
515531
return
516532
}
@@ -648,3 +664,15 @@ func decryptAPIKey(ctx context.Context, db database.Store, encryptedAPIKey strin
648664

649665
return key, payload.APIKey, nil
650666
}
667+
668+
// renderApplicationNotFound should always be used when the app is not found or
669+
// the current user doesn't have permission to access it.
670+
func renderApplicationNotFound(rw http.ResponseWriter, r *http.Request, accessURL *url.URL) {
671+
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
672+
Status: http.StatusNotFound,
673+
Title: "Application not found",
674+
Description: "The application or workspace you are trying to access does not exist.",
675+
RetryEnabled: false,
676+
DashboardURL: accessURL.String(),
677+
})
678+
}

coderd/workspaceapps_test.go

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -258,8 +258,7 @@ func TestWorkspaceAppsProxyPath(t *testing.T) {
258258
resp, err := client.Request(ctx, http.MethodGet, "/@me/"+workspace.Name+"/apps/fake/", nil)
259259
require.NoError(t, err)
260260
defer resp.Body.Close()
261-
// this is 200 OK because it returns a dashboard page
262-
require.Equal(t, http.StatusOK, resp.StatusCode)
261+
require.Equal(t, http.StatusBadGateway, resp.StatusCode)
263262
})
264263
}
265264

@@ -529,10 +528,9 @@ func TestWorkspaceAppsProxySubdomainBlocked(t *testing.T) {
529528

530529
// Should have an error response.
531530
require.Equal(t, http.StatusNotFound, resp.StatusCode)
532-
var resBody codersdk.Response
533-
err = json.NewDecoder(resp.Body).Decode(&resBody)
531+
body, err := io.ReadAll(resp.Body)
534532
require.NoError(t, err)
535-
require.Contains(t, resBody.Message, "does not accept application requests on this hostname")
533+
require.Contains(t, string(body), "does not accept application requests on this hostname")
536534
})
537535

538536
t.Run("InvalidSubdomain", func(t *testing.T) {
@@ -547,12 +545,11 @@ func TestWorkspaceAppsProxySubdomainBlocked(t *testing.T) {
547545
require.NoError(t, err)
548546
defer resp.Body.Close()
549547

550-
// Should have an error response.
548+
// Should have a HTML error response.
551549
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
552-
var resBody codersdk.Response
553-
err = json.NewDecoder(resp.Body).Decode(&resBody)
550+
body, err := io.ReadAll(resp.Body)
554551
require.NoError(t, err)
555-
require.Contains(t, resBody.Message, "Could not parse subdomain application URL")
552+
require.Contains(t, string(body), "Could not parse subdomain application URL")
556553
})
557554
}
558555

site/index.html

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,6 @@
1616
<meta property="og:type" content="website" />
1717
<meta property="csp-nonce" content="{{ .CSP.Nonce }}" />
1818
<meta property="csrf-token" content="{{ .CSRF.Token }}" />
19-
<meta
20-
id="api-response"
21-
data-statuscode="{{ .APIResponse.StatusCode }}"
22-
data-message="{{ .APIResponse.Message }}"
23-
/>
2419
<!-- We need to set data-react-helmet to be able to override it in the workspace page -->
2520
<link
2621
rel="alternate icon"

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy