Skip to content

chore: add workspace proxies to the backend #7032

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 51 commits into from
Apr 17, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
ca5b50c
feat: Implement start of external workspace proxies
Emyrk Apr 5, 2023
5fc7832
Add more init code
Emyrk Apr 5, 2023
391fe74
feat: add proxysdk and proxy tokeng
deansheather Apr 6, 2023
23d0a4c
Comments and import cleanup
Emyrk Apr 6, 2023
7cce9a2
Move to wsproxy, make unit test work, update audit log resources
Emyrk Apr 6, 2023
2aebe77
Merge remote-tracking branch 'origin/main' into dreamteam/external_proxy
Emyrk Apr 6, 2023
020b4b5
Add proxy token provider
Emyrk Apr 6, 2023
dc5af55
Merge remote-tracking branch 'origin/main' into dreamteam/external_proxy
Emyrk Apr 6, 2023
c5225ae
Begin writing unit test for external proxy
Emyrk Apr 6, 2023
d6a1217
Add option validation
Emyrk Apr 7, 2023
1e163d9
Fix access url passing
Emyrk Apr 7, 2023
e86a518
Healthz and buildinfo endpoints
Emyrk Apr 7, 2023
20b44c6
do stuff
deansheather Apr 11, 2023
68c3bb1
Linting
Emyrk Apr 11, 2023
ec04552
Check workspace proxy hostnames for subdomain apps
Emyrk Apr 11, 2023
07323e5
Path based redirects redirect to dashboardurl
Emyrk Apr 11, 2023
ffa8b00
Merge remote-tracking branch 'origin/main' into dreamteam/external_proxy
Emyrk Apr 11, 2023
a96a73b
Just commit something
Emyrk Apr 11, 2023
2d7e242
use query instead of proxycache
deansheather Apr 12, 2023
e80e7e0
Merge remote-tracking branch 'origin/main' into dreamteam/external_proxy
Emyrk Apr 12, 2023
be25c51
Make gen
Emyrk Apr 12, 2023
208eaf1
MAke gen
Emyrk Apr 12, 2023
6cfb62c
Linting
Emyrk Apr 12, 2023
a112e29
Bump migration
Emyrk Apr 13, 2023
d6edd29
Smuggling for path apps on proxies
deansheather Apr 13, 2023
5db3d25
Reuse system rbac subject
Emyrk Apr 13, 2023
7e4ed87
Add TODO
Emyrk Apr 13, 2023
7140420
Merge remote-tracking branch 'origin/main' into dreamteam/external_proxy
Emyrk Apr 13, 2023
fb30e1a
Give moons exec perms
Emyrk Apr 13, 2023
22aadf1
Merge remote-tracking branch 'origin/main' into dreamteam/external_proxy
Emyrk Apr 13, 2023
a66ffd7
Merge remote-tracking branch 'origin/main' into dreamteam/external_proxy
Emyrk Apr 14, 2023
a483f3e
Fix merge mistake
Emyrk Apr 14, 2023
50fa1ca
Renames from PR feedback
Emyrk Apr 14, 2023
b7f3b86
Update enterprise/audit/table.go
Emyrk Apr 17, 2023
bb032c3
Renames and formatting
Emyrk Apr 17, 2023
6ab0dea
Make gen
Emyrk Apr 17, 2023
12c6f8d
Fix compile
Emyrk Apr 17, 2023
224fa2f
Add comments to sql columns
Emyrk Apr 17, 2023
06fb88b
ExternalProxy -> WorkspaceProxy
Emyrk Apr 17, 2023
82d10d9
Remove Actor function
Emyrk Apr 17, 2023
dc884eb
comments
deansheather Apr 17, 2023
dbbd2ba
comments
deansheather Apr 17, 2023
1322f99
Use correct MW
Emyrk Apr 17, 2023
784fb68
Make gen/fmt/lint
Emyrk Apr 17, 2023
b72ef2f
Group vs route to fix swagger
Emyrk Apr 17, 2023
a4f205e
comments
deansheather Apr 17, 2023
8508138
comments
deansheather Apr 17, 2023
cfe484c
comments
deansheather Apr 17, 2023
d4d9bf9
tests for RequireAPIKeyOrWorkspaceProxyAuth
deansheather Apr 17, 2023
fdbd31e
tests for ExtractWorkspaceProxy
deansheather Apr 17, 2023
efef018
Merge branch 'main' into dreamteam/external_proxy
deansheather Apr 17, 2023
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
Smuggling for path apps on proxies
  • Loading branch information
deansheather committed Apr 13, 2023
commit d6edd290f82d1a5f54ebb6783f0ebbdc39d8f7db
325 changes: 210 additions & 115 deletions coderd/workspaceapps/apptest/apptest.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"net/http/cookiejar"
"net/http/httputil"
"net/url"
"path"
"runtime"
"strconv"
"strings"
Expand All @@ -24,6 +25,7 @@ import (

"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/coderd/workspaceapps"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/testutil"
)
Expand Down Expand Up @@ -122,9 +124,13 @@ func Run(t *testing.T, factory DeploymentFactory) {
require.Contains(t, string(body), "Path-based applications are disabled")
})

t.Run("LoginWithoutAuth", func(t *testing.T) {
t.Run("LoginWithoutAuthOnPrimary", func(t *testing.T) {
t.Parallel()

if !appDetails.AppHostServesAPI {
t.Skip("This test only applies when testing apps on the primary.")
}

unauthedClient := appDetails.AppClient(t)
unauthedClient.SetSessionToken("")

Expand All @@ -143,6 +149,43 @@ func Run(t *testing.T, factory DeploymentFactory) {
require.True(t, loc.Query().Has("redirect"))
})

t.Run("LoginWithoutAuthOnProxy", func(t *testing.T) {
t.Parallel()

if appDetails.AppHostServesAPI {
t.Skip("This test only applies when testing apps on workspace proxies.")
}

unauthedClient := appDetails.AppClient(t)
unauthedClient.SetSessionToken("")

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

u := appDetails.PathAppURL(appDetails.OwnerApp)
resp, err := requestWithRetries(ctx, t, unauthedClient, http.MethodGet, u.String(), nil)
require.NoError(t, err)
defer resp.Body.Close()

require.Equal(t, http.StatusSeeOther, resp.StatusCode)
loc, err := resp.Location()
require.NoError(t, err)
require.Equal(t, appDetails.APIClient.URL.Host, loc.Host)
require.Equal(t, "/api/v2/applications/auth-redirect", loc.Path)

redirectURIStr := loc.Query().Get("redirect_uri")
require.NotEmpty(t, redirectURIStr)
redirectURI, err := url.Parse(redirectURIStr)
require.NoError(t, err)

require.Equal(t, u.Scheme, redirectURI.Scheme)
require.Equal(t, u.Host, redirectURI.Host)
// TODO(@dean): I have no idea how but the trailing slash on this
// request is getting stripped.
require.Equal(t, u.Path, redirectURI.Path+"/")
require.Equal(t, u.RawQuery, redirectURI.RawQuery)
})

t.Run("NoAccessShould404", func(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -304,129 +347,181 @@ func Run(t *testing.T, factory DeploymentFactory) {

appDetails := setupProxyTest(t, nil)

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
cases := []struct {
name string
appURL *url.URL
verifyCookie func(t *testing.T, c *http.Cookie)
}{
{
name: "Subdomain",
appURL: appDetails.SubdomainAppURL(appDetails.OwnerApp),
verifyCookie: func(t *testing.T, c *http.Cookie) {
// TODO(@dean): fix these asserts, they don't seem to
// work. I wonder if Go strips the domain from the
// cookie object if it's invalid or something.
// domain := strings.SplitN(appDetails.Options.AppHost, ".", 2)
// require.Equal(t, "."+domain[1], c.Domain, "incorrect domain on app token cookie")
},
},
{
name: "Path",
appURL: appDetails.PathAppURL(appDetails.OwnerApp),
verifyCookie: func(t *testing.T, c *http.Cookie) {
// TODO(@dean): fix these asserts, they don't seem to
// work. I wonder if Go strips the domain from the
// cookie object if it's invalid or something.
// require.Equal(t, "", c.Domain, "incorrect domain on app token cookie")
},
},
}

// Get the current user and API key.
user, err := appDetails.APIClient.User(ctx, codersdk.Me)
require.NoError(t, err)
currentAPIKey, err := appDetails.APIClient.APIKeyByID(ctx, appDetails.FirstUser.UserID.String(), strings.Split(appDetails.APIClient.SessionToken(), "-")[0])
require.NoError(t, err)
for _, c := range cases {
c := c

appClient := appDetails.AppClient(t)
appClient.SetSessionToken("")
if c.name == "Path" && appDetails.AppHostServesAPI {
// Workspace application auth does not apply to path apps
// served from the primary access URL as no smuggling needs
// to take place (they're already logged in with a session
// token).
continue
}

// Try to load the application without authentication.
u := appDetails.SubdomainAppURL(appDetails.OwnerApp)
u.Path = "/test"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
require.NoError(t, err)
t.Run(c.name, func(t *testing.T) {
t.Parallel()

var resp *http.Response
resp, err = doWithRetries(t, appClient, req)
require.NoError(t, err)
resp.Body.Close()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()

// Check that the Location is correct.
gotLocation, err := resp.Location()
require.NoError(t, err)
// This should always redirect to the primary access URL.
require.Equal(t, appDetails.APIClient.URL.Host, gotLocation.Host)
require.Equal(t, "/api/v2/applications/auth-redirect", gotLocation.Path)
require.Equal(t, u.String(), gotLocation.Query().Get("redirect_uri"))

// Load the application auth-redirect endpoint.
resp, err = requestWithRetries(ctx, t, appDetails.APIClient, http.MethodGet, "/api/v2/applications/auth-redirect", nil, codersdk.WithQueryParam(
"redirect_uri", u.String(),
))
require.NoError(t, err)
defer resp.Body.Close()
// Get the current user and API key.
user, err := appDetails.APIClient.User(ctx, codersdk.Me)
require.NoError(t, err)
currentAPIKey, err := appDetails.APIClient.APIKeyByID(ctx, appDetails.FirstUser.UserID.String(), strings.Split(appDetails.APIClient.SessionToken(), "-")[0])
require.NoError(t, err)

require.Equal(t, http.StatusSeeOther, resp.StatusCode)
gotLocation, err = resp.Location()
require.NoError(t, err)
appClient := appDetails.AppClient(t)
appClient.SetSessionToken("")

// Copy the query parameters and then check equality.
u.RawQuery = gotLocation.RawQuery
require.Equal(t, u, gotLocation)

// Verify the API key is set.
var encryptedAPIKey string
for k, v := range gotLocation.Query() {
// The query parameter may change dynamically in the future and is
// not exported, so we just use a fuzzy check instead.
if strings.Contains(k, "api_key") {
encryptedAPIKey = v[0]
}
}
require.NotEmpty(t, encryptedAPIKey, "no API key was set in the query parameters")
// Try to load the application without authentication.
u := c.appURL
u.Path = path.Join(u.Path, "/test")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
require.NoError(t, err)

// Decrypt the API key by following the request.
t.Log("navigating to: ", gotLocation.String())
req, err = http.NewRequestWithContext(ctx, "GET", gotLocation.String(), nil)
require.NoError(t, err)
resp, err = doWithRetries(t, appClient, req)
require.NoError(t, err)
resp.Body.Close()
require.Equal(t, http.StatusSeeOther, resp.StatusCode)
cookies := resp.Cookies()
require.Len(t, cookies, 1)
apiKey := cookies[0].Value
var resp *http.Response
resp, err = doWithRetries(t, appClient, req)
require.NoError(t, err)

// Fetch the API key from the API.
apiKeyInfo, err := appDetails.APIClient.APIKeyByID(ctx, appDetails.FirstUser.UserID.String(), strings.Split(apiKey, "-")[0])
require.NoError(t, err)
require.Equal(t, user.ID, apiKeyInfo.UserID)
require.Equal(t, codersdk.LoginTypePassword, apiKeyInfo.LoginType)
require.WithinDuration(t, currentAPIKey.ExpiresAt, apiKeyInfo.ExpiresAt, 5*time.Second)
require.EqualValues(t, currentAPIKey.LifetimeSeconds, apiKeyInfo.LifetimeSeconds)

// Verify the API key permissions
appTokenAPIClient := codersdk.New(appDetails.APIClient.URL)
appTokenAPIClient.SetSessionToken(apiKey)
appTokenAPIClient.HTTPClient.CheckRedirect = appDetails.APIClient.HTTPClient.CheckRedirect
appTokenAPIClient.HTTPClient.Transport = appDetails.APIClient.HTTPClient.Transport

var (
canCreateApplicationConnect = "can-create-application_connect"
canReadUserMe = "can-read-user-me"
)
authRes, err := appTokenAPIClient.AuthCheck(ctx, codersdk.AuthorizationRequest{
Checks: map[string]codersdk.AuthorizationCheck{
canCreateApplicationConnect: {
Object: codersdk.AuthorizationObject{
ResourceType: "application_connect",
OwnerID: "me",
OrganizationID: appDetails.FirstUser.OrganizationID.String(),
},
Action: "create",
},
canReadUserMe: {
Object: codersdk.AuthorizationObject{
ResourceType: "user",
OwnerID: "me",
ResourceID: appDetails.FirstUser.UserID.String(),
if !assert.Equal(t, http.StatusSeeOther, resp.StatusCode) {
dump, err := httputil.DumpResponse(resp, true)
require.NoError(t, err)
t.Log(string(dump))
}
resp.Body.Close()

// Check that the Location is correct.
gotLocation, err := resp.Location()
require.NoError(t, err)
// This should always redirect to the primary access URL.
require.Equal(t, appDetails.APIClient.URL.Host, gotLocation.Host)
require.Equal(t, "/api/v2/applications/auth-redirect", gotLocation.Path)
require.Equal(t, u.String(), gotLocation.Query().Get("redirect_uri"))

// Load the application auth-redirect endpoint.
resp, err = requestWithRetries(ctx, t, appDetails.APIClient, http.MethodGet, "/api/v2/applications/auth-redirect", nil, codersdk.WithQueryParam(
"redirect_uri", u.String(),
))
require.NoError(t, err)
defer resp.Body.Close()

require.Equal(t, http.StatusSeeOther, resp.StatusCode)
gotLocation, err = resp.Location()
require.NoError(t, err)

// Copy the query parameters and then check equality.
u.RawQuery = gotLocation.RawQuery
require.Equal(t, u, gotLocation)

// Verify the API key is set.
encryptedAPIKey := gotLocation.Query().Get(workspaceapps.SubdomainProxyAPIKeyParam)
require.NotEmpty(t, encryptedAPIKey, "no API key was set in the query parameters")

// Decrypt the API key by following the request.
t.Log("navigating to: ", gotLocation.String())
req, err = http.NewRequestWithContext(ctx, "GET", gotLocation.String(), nil)
require.NoError(t, err)
resp, err = doWithRetries(t, appClient, req)
require.NoError(t, err)
resp.Body.Close()
require.Equal(t, http.StatusSeeOther, resp.StatusCode)

cookies := resp.Cookies()
var cookie *http.Cookie
for _, c := range cookies {
if c.Name == codersdk.DevURLSessionTokenCookie {
cookie = c
break
}
}
require.NotNil(t, cookie, "no app session token cookie was set")
c.verifyCookie(t, cookie)
apiKey := cookie.Value

// Fetch the API key from the API.
apiKeyInfo, err := appDetails.APIClient.APIKeyByID(ctx, appDetails.FirstUser.UserID.String(), strings.Split(apiKey, "-")[0])
require.NoError(t, err)
require.Equal(t, user.ID, apiKeyInfo.UserID)
require.Equal(t, codersdk.LoginTypePassword, apiKeyInfo.LoginType)
require.WithinDuration(t, currentAPIKey.ExpiresAt, apiKeyInfo.ExpiresAt, 5*time.Second)
require.EqualValues(t, currentAPIKey.LifetimeSeconds, apiKeyInfo.LifetimeSeconds)

// Verify the API key permissions
appTokenAPIClient := codersdk.New(appDetails.APIClient.URL)
appTokenAPIClient.SetSessionToken(apiKey)
appTokenAPIClient.HTTPClient.CheckRedirect = appDetails.APIClient.HTTPClient.CheckRedirect
appTokenAPIClient.HTTPClient.Transport = appDetails.APIClient.HTTPClient.Transport

var (
canCreateApplicationConnect = "can-create-application_connect"
canReadUserMe = "can-read-user-me"
)
authRes, err := appTokenAPIClient.AuthCheck(ctx, codersdk.AuthorizationRequest{
Checks: map[string]codersdk.AuthorizationCheck{
canCreateApplicationConnect: {
Object: codersdk.AuthorizationObject{
ResourceType: "application_connect",
OwnerID: "me",
OrganizationID: appDetails.FirstUser.OrganizationID.String(),
},
Action: "create",
},
canReadUserMe: {
Object: codersdk.AuthorizationObject{
ResourceType: "user",
OwnerID: "me",
ResourceID: appDetails.FirstUser.UserID.String(),
},
Action: "read",
},
},
Action: "read",
},
},
})
require.NoError(t, err)

require.True(t, authRes[canCreateApplicationConnect])
require.False(t, authRes[canReadUserMe])

// Load the application page with the API key set.
gotLocation, err = resp.Location()
require.NoError(t, err)
t.Log("navigating to: ", gotLocation.String())
req, err = http.NewRequestWithContext(ctx, "GET", gotLocation.String(), nil)
require.NoError(t, err)
req.Header.Set(codersdk.SessionTokenHeader, apiKey)
resp, err = doWithRetries(t, appClient, req)
require.NoError(t, err)
resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
})
require.NoError(t, err)

require.True(t, authRes[canCreateApplicationConnect])
require.False(t, authRes[canReadUserMe])

// Load the application page with the API key set.
gotLocation, err = resp.Location()
require.NoError(t, err)
t.Log("navigating to: ", gotLocation.String())
req, err = http.NewRequestWithContext(ctx, "GET", gotLocation.String(), nil)
require.NoError(t, err)
req.Header.Set(codersdk.SessionTokenHeader, apiKey)
resp, err = doWithRetries(t, appClient, req)
require.NoError(t, err)
resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
})
}
})
})

Expand Down Expand Up @@ -866,7 +961,7 @@ func Run(t *testing.T, factory DeploymentFactory) {
require.NoError(t, err, msg)

expectedPath := "/login"
if !isPathApp {
if !isPathApp || !appDetails.AppHostServesAPI {
expectedPath = "/api/v2/applications/auth-redirect"
}
assert.Equal(t, expectedPath, location.Path, "should not have access, expected redirect to applicable login endpoint. "+msg)
Expand Down
Loading