Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
6d21b37
feat: add --app-hostname flag to coder server
deansheather Sep 19, 2022
b8a7a45
chore: add test for subdomain proxy passthrough
deansheather Sep 19, 2022
3b875b2
fixup! chore: add test for subdomain proxy passthrough
deansheather Sep 19, 2022
39e9b6f
chore: reorganize subdomain app handler
deansheather Sep 19, 2022
3a099ba
chore: add authorization check endpoint
deansheather Sep 20, 2022
25b8182
Merge branch 'main' into dean/app-tokens
deansheather Sep 20, 2022
e864f27
chore: improve proxy auth tests
deansheather Sep 20, 2022
b5d2be3
chore: refactor ExtractAPIKey to accept struct
deansheather Sep 20, 2022
d9b404d
feat: end-to-end workspace application authentication
deansheather Sep 21, 2022
da5f656
Merge branch 'main' into dean/app-tokens
deansheather Sep 21, 2022
312e0d5
fixup! Merge branch 'main' into dean/app-tokens
deansheather Sep 21, 2022
16bbcbe
feat: use a custom cookie name for devurls to avoid clashes
deansheather Sep 21, 2022
a172cd5
feat: /api/v2/applications/host endpoint, PR comments
deansheather Sep 21, 2022
d4986d2
fixup! feat: /api/v2/applications/host endpoint, PR comments
deansheather Sep 21, 2022
9b56f02
fixup! feat: /api/v2/applications/host endpoint, PR comments
deansheather Sep 21, 2022
d9186a8
chore: more pr comments
deansheather Sep 21, 2022
35962fc
Remove checkUserPermissions
kylecarbs Sep 21, 2022
b1436ec
fixup! Remove checkUserPermissions
deansheather Sep 21, 2022
11e985f
Merge branch 'main' into dean/app-tokens
deansheather Sep 21, 2022
496fde3
fixup! Merge branch 'main' into dean/app-tokens
deansheather Sep 21, 2022
3e30a9f
chore: more security stuff
deansheather Sep 21, 2022
11e6061
fixup! chore: more security stuff
deansheather Sep 21, 2022
cf70650
chore: more comments
deansheather Sep 21, 2022
6d66f55
Merge branch 'main' into dean/app-tokens
deansheather Sep 22, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fixup! chore: add test for subdomain proxy passthrough
  • Loading branch information
deansheather committed Sep 19, 2022
commit 3b875b2cf454508af5d81c49daff9fc87e11d506
9 changes: 4 additions & 5 deletions coderd/httpapi/url.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,14 @@ var (
// SplitSubdomain splits a subdomain from the rest of the hostname. E.g.:
// - "foo.bar.com" becomes "foo", "bar.com"
// - "foo.bar.baz.com" becomes "foo", "bar.baz.com"
//
// An error is returned if the string doesn't contain a period.
func SplitSubdomain(hostname string) (subdomain string, rest string, err error) {
// - "foo" becomes "foo", ""
func SplitSubdomain(hostname string) (subdomain string, rest string) {
toks := strings.SplitN(hostname, ".", 2)
if len(toks) < 2 {
return "", "", xerrors.New("no subdomain")
return toks[0], ""
}

return toks[0], toks[1], nil
return toks[0], toks[1]
}

// ApplicationURL is a parsed application URL hostname.
Expand Down
17 changes: 2 additions & 15 deletions coderd/httpapi/url_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,49 +15,42 @@ func TestSplitSubdomain(t *testing.T) {
Host string
ExpectedSubdomain string
ExpectedRest string
ExpectedErr string
}{
{
Name: "Empty",
Host: "",
ExpectedSubdomain: "",
ExpectedRest: "",
ExpectedErr: "no subdomain",
},
{
Name: "NoSubdomain",
Host: "com",
ExpectedSubdomain: "",
ExpectedSubdomain: "com",
ExpectedRest: "",
ExpectedErr: "no subdomain",
},
{
Name: "Domain",
Host: "coder.com",
ExpectedSubdomain: "coder",
ExpectedRest: "com",
ExpectedErr: "",
},
{
Name: "Subdomain",
Host: "subdomain.coder.com",
ExpectedSubdomain: "subdomain",
ExpectedRest: "coder.com",
ExpectedErr: "",
},
{
Name: "DoubleSubdomain",
Host: "subdomain1.subdomain2.coder.com",
ExpectedSubdomain: "subdomain1",
ExpectedRest: "subdomain2.coder.com",
ExpectedErr: "",
},
{
Name: "WithPort",
Host: "subdomain.coder.com:8080",
ExpectedSubdomain: "subdomain",
ExpectedRest: "coder.com:8080",
ExpectedErr: "",
},
}

Expand All @@ -66,13 +59,7 @@ func TestSplitSubdomain(t *testing.T) {
t.Run(c.Name, func(t *testing.T) {
t.Parallel()

subdomain, rest, err := httpapi.SplitSubdomain(c.Host)
if c.ExpectedErr != "" {
require.Error(t, err)
require.Contains(t, err.Error(), c.ExpectedErr)
} else {
require.NoError(t, err)
}
subdomain, rest := httpapi.SplitSubdomain(c.Host)
require.Equal(t, c.ExpectedSubdomain, subdomain)
require.Equal(t, c.ExpectedRest, rest)
})
Expand Down
79 changes: 64 additions & 15 deletions coderd/workspaceapps.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,41 @@ func (api *API) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request)
}, rw, r)
}

// 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) {
Expand Down Expand Up @@ -77,38 +112,52 @@ func (api *API) handleSubdomainApplications(middlewares ...func(http.Handler) ht
return
}

// Check if the hostname matches the access URL.
// 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) {
// The user was definitely trying to connect to the
// dashboard/API.
next.ServeHTTP(rw, r)
return
}

// Split the subdomain and verify it matches the configured app
// hostname.
subdomain, rest, err := httpapi.SplitSubdomain(host)
if err != nil {
httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Could not split request Host header %q.", host),
Detail: err.Error(),
})
return
}
if !httpapi.HostnamesMatch(api.AppHostname, rest) {
httpapi.ResourceNotFound(rw)
// Split the subdomain so we can parse the application details and
// verify it matches the configured app hostname later.
subdomain, rest := httpapi.SplitSubdomain(host)
if rest == "" {
// If there are no periods in the hostname, then it can't be a
// valid application URL.
next.ServeHTTP(rw, r)
return
}
matchingBaseHostname := httpapi.HostnamesMatch(api.AppHostname, rest)

// Parse the application URL from the subdomain.
app, err := httpapi.ParseSubdomainAppURL(subdomain)
if err != nil {
// If it isn't a valid app URL and the base domain doesn't match
// the configured app hostname, this request was probably
// destined for the dashboard/API router.
if !matchingBaseHostname {
next.ServeHTTP(rw, r)
return
}

httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{
Message: "Could not parse subdomain application URL.",
Detail: err.Error(),
})
return
}

// At this point we've verified that the subdomain looks like a
// valid application URL, so the base hostname should match the
// configured app hostname.
if !matchingBaseHostname {
httpapi.Write(rw, http.StatusNotFound, codersdk.Response{
Message: "The server does not accept application requests on this hostname.",
})
return
}

workspaceAgentKey := fmt.Sprintf("%s.%s", app.WorkspaceName, app.AgentName)
chiCtx := chi.RouteContext(ctx)
chiCtx.URLParams.Add("workspace_and_agent", workspaceAgentKey)
Expand Down
72 changes: 71 additions & 1 deletion coderd/workspaceapps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,10 @@ func TestWorkspaceAppsProxyPath(t *testing.T) {
func TestWorkspaceAppsProxySubdomainPassthrough(t *testing.T) {
t.Parallel()

client := coderdtest.New(t, nil)
// No AppHostname set.
client := coderdtest.New(t, &coderdtest.Options{
AppHostname: "",
})
firstUser := coderdtest.CreateFirstUser(t, client)

// Configure the HTTP client to always route all requests to the coder test
Expand Down Expand Up @@ -261,6 +264,73 @@ func TestWorkspaceAppsProxySubdomainPassthrough(t *testing.T) {
require.Equal(t, firstUser.UserID, user.ID)
}

// This test ensures that the subdomain handler blocks the request if it looks
// like a workspace app request but the configured app hostname differs from the
// request, or the request is not a valid app subdomain but the hostname
// matches.
func TestWorkspaceAppsProxySubdomainBlocked(t *testing.T) {
t.Parallel()

setup := func(t *testing.T, appHostname string) *codersdk.Client {
client := coderdtest.New(t, &coderdtest.Options{
AppHostname: appHostname,
})
_ = coderdtest.CreateFirstUser(t, client)

// Configure the HTTP client to always route all requests to the coder test
// server.
defaultTransport, ok := http.DefaultTransport.(*http.Transport)
require.True(t, ok)
transport := defaultTransport.Clone()
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, client.URL.Host)
}
client.HTTPClient.Transport = transport

return client
}

t.Run("NotMatchingHostname", func(t *testing.T) {
t.Parallel()
client := setup(t, "test."+proxyTestSubdomain)

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()

uri := fmt.Sprintf("http://app--agent--workspace--username.%s/api/v2/users/me", proxyTestSubdomain)
resp, err := client.Request(ctx, http.MethodGet, uri, nil)
require.NoError(t, err)
defer resp.Body.Close()

// Should have an error response.
require.Equal(t, http.StatusNotFound, resp.StatusCode)
var resBody codersdk.Response
err = json.NewDecoder(resp.Body).Decode(&resBody)
require.NoError(t, err)
require.Contains(t, resBody.Message, "does not accept application requests on this hostname")
})

t.Run("InvalidSubdomain", func(t *testing.T) {
t.Parallel()
client := setup(t, proxyTestSubdomain)

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()

uri := fmt.Sprintf("http://not-an-app-subdomain.%s/api/v2/users/me", proxyTestSubdomain)
resp, err := client.Request(ctx, http.MethodGet, uri, nil)
require.NoError(t, err)
defer resp.Body.Close()

// Should have an error response.
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
var resBody codersdk.Response
err = json.NewDecoder(resp.Body).Decode(&resBody)
require.NoError(t, err)
require.Contains(t, resBody.Message, "Could not parse subdomain application URL")
})
}

func TestWorkspaceAppsProxySubdomain(t *testing.T) {
t.Parallel()
client, orgID, workspace, port := setupProxyTest(t)
Expand Down