feat: allow bypassing current CORS magic based on template config (#18706)

Solves https://github.com/coder/coder/issues/15096

This is a slight rework/refactor of the earlier PRs from @dannykopping
and @Emyrk:
- https://github.com/coder/coder/pull/15669
- https://github.com/coder/coder/pull/15684
- https://github.com/coder/coder/pull/17596

Rather than having a per-app CORS behaviour setting and additionally a
template level setting for ports, this PR adds a single template level
CORS behaviour setting that is then used by all apps/ports for
workspaces created from that template.

The main changes are in `proxy.go` and `request.go` to:
a) get the CORS behaviour setting from the template
b) have `HandleSubdomain` bypass the CORS middleware handler if the
selected behaviour is `passthru`
c) in `proxyWorkspaceApp`, do not modify the response if the selected
behaviour is `passthru`

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added support for configuring CORS behavior ("simple" or "passthru")
at the template level for all shared ports.
* Introduced a new "CORS Behavior" setting in the template creation and
settings forms.
* API endpoints and responses now include the optional `cors_behavior`
property for templates.
* Workspace apps and proxy now honor the specified CORS behavior,
enabling conditional CORS middleware application.
* Enhanced workspace app tests with comprehensive scenarios covering
CORS behaviors and authentication states.

* **Bug Fixes**
  * None.

* **Documentation**
* Updated API and admin documentation to describe the new
`cors_behavior` property and its usage.
* Added examples and schema references for CORS behavior in relevant API
docs.

* **Tests**
* Extended automated tests to cover different CORS behavior scenarios
for templates and workspace apps.

* **Chores**
* Updated audit logging to track changes to the `cors_behavior` field on
templates.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Signed-off-by: Callum Styan <callumstyan@gmail.com>
This commit is contained in:
Callum Styan
2025-07-30 13:42:39 -07:00
committed by GitHub
parent 96e32d60a2
commit ffbfaf2a6f
36 changed files with 1149 additions and 108 deletions
+559 -5
View File
@@ -472,6 +472,409 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) {
})
})
t.Run("WorkspaceApplicationCORS", func(t *testing.T) {
t.Parallel()
const external = "https://example.com"
unauthenticatedClient := func(t *testing.T, appDetails *Details) *codersdk.Client {
c := appDetails.AppClient(t)
c.SetSessionToken("")
return c
}
authenticatedClient := func(t *testing.T, appDetails *Details) *codersdk.Client {
uc, _ := coderdtest.CreateAnotherUser(t, appDetails.SDKClient, appDetails.FirstUser.OrganizationID, rbac.RoleMember())
c := appDetails.AppClient(t)
c.SetSessionToken(uc.SessionToken())
return c
}
ownSubdomain := func(details *Details, app App) string {
url := details.SubdomainAppURL(app)
return url.Scheme + "://" + url.Host
}
externalOrigin := func(*Details, App) string {
return external
}
tests := []struct {
name string
app func(details *Details) App
client func(t *testing.T, appDetails *Details) *codersdk.Client
behavior codersdk.CORSBehavior
httpMethod string
origin func(details *Details, app App) string
expectedStatusCode int
checkRequestHeaders func(t *testing.T, origin string, req http.Header)
checkResponseHeaders func(t *testing.T, origin string, resp http.Header)
}{
// Public
{ // fails
// The default behavior is to accept preflight requests from the request origin if it matches the app's own subdomain.
name: "Default/Public/Preflight/Subdomain",
app: func(details *Details) App { return details.Apps.PublicCORSDefault },
behavior: codersdk.CORSBehaviorSimple,
client: unauthenticatedClient,
httpMethod: http.MethodOptions,
origin: ownSubdomain,
expectedStatusCode: http.StatusOK,
checkResponseHeaders: func(t *testing.T, origin string, resp http.Header) {
assert.Equal(t, origin, resp.Get("Access-Control-Allow-Origin"))
assert.Contains(t, resp.Get("Access-Control-Allow-Methods"), http.MethodGet)
assert.Equal(t, "true", resp.Get("Access-Control-Allow-Credentials"))
},
},
{ // passes
// The default behavior is to reject preflight requests from origins other than the app's own subdomain.
name: "Default/Public/Preflight/External",
app: func(details *Details) App { return details.Apps.PublicCORSDefault },
behavior: codersdk.CORSBehaviorSimple,
client: unauthenticatedClient,
httpMethod: http.MethodOptions,
origin: externalOrigin,
expectedStatusCode: http.StatusOK,
checkResponseHeaders: func(t *testing.T, origin string, resp http.Header) {
// We don't add a valid Allow-Origin header for requests we won't proxy.
assert.Empty(t, resp.Get("Access-Control-Allow-Origin"))
},
},
{ // fails
// A request without an Origin header would be rejected by an actual browser since it lacks CORS headers.
name: "Default/Public/GET/NoOrigin",
app: func(details *Details) App { return details.Apps.PublicCORSDefault },
behavior: codersdk.CORSBehaviorSimple,
client: unauthenticatedClient,
origin: func(*Details, App) string { return "" },
httpMethod: http.MethodGet,
expectedStatusCode: http.StatusOK,
checkResponseHeaders: func(t *testing.T, origin string, resp http.Header) {
assert.Empty(t, resp.Get("Access-Control-Allow-Origin"))
assert.Empty(t, resp.Get("Access-Control-Allow-Headers"))
assert.Empty(t, resp.Get("Access-Control-Allow-Credentials"))
// Added by the app handler.
assert.Equal(t, "simple", resp.Get("X-CORS-Handler"))
},
},
{ // fails
// The passthru behavior will pass through the request headers to the upstream app.
name: "Passthru/Public/Preflight/Subdomain",
app: func(details *Details) App { return details.Apps.PublicCORSPassthru },
behavior: codersdk.CORSBehaviorPassthru,
client: unauthenticatedClient,
origin: ownSubdomain,
httpMethod: http.MethodOptions,
expectedStatusCode: http.StatusOK,
checkRequestHeaders: func(t *testing.T, origin string, req http.Header) {
assert.Equal(t, origin, req.Get("Origin"))
assert.Equal(t, "GET", req.Get("Access-Control-Request-Method"))
},
checkResponseHeaders: func(t *testing.T, origin string, resp http.Header) {
assert.Equal(t, origin, resp.Get("Access-Control-Allow-Origin"))
assert.Equal(t, http.MethodGet, resp.Get("Access-Control-Allow-Methods"))
// Added by the app handler.
assert.Equal(t, "passthru", resp.Get("X-CORS-Handler"))
},
},
{ // fails
// Identical to the previous test, but the origin is different.
name: "Passthru/Public/PreflightOther",
app: func(details *Details) App { return details.Apps.PublicCORSPassthru },
behavior: codersdk.CORSBehaviorPassthru,
client: unauthenticatedClient,
origin: externalOrigin,
httpMethod: http.MethodOptions,
expectedStatusCode: http.StatusOK,
checkRequestHeaders: func(t *testing.T, origin string, req http.Header) {
assert.Equal(t, origin, req.Get("Origin"))
assert.Equal(t, "GET", req.Get("Access-Control-Request-Method"))
assert.Equal(t, "X-Got-Host", req.Get("Access-Control-Request-Headers"))
},
checkResponseHeaders: func(t *testing.T, origin string, resp http.Header) {
assert.Equal(t, origin, resp.Get("Access-Control-Allow-Origin"))
assert.Equal(t, http.MethodGet, resp.Get("Access-Control-Allow-Methods"))
// Added by the app handler.
assert.Equal(t, "passthru", resp.Get("X-CORS-Handler"))
},
},
{
// A request without an Origin header would be rejected by an actual browser since it lacks CORS headers.
name: "Passthru/Public/GET/NoOrigin",
app: func(details *Details) App { return details.Apps.PublicCORSPassthru },
behavior: codersdk.CORSBehaviorPassthru,
client: unauthenticatedClient,
origin: func(*Details, App) string { return "" },
httpMethod: http.MethodGet,
expectedStatusCode: http.StatusOK,
checkResponseHeaders: func(t *testing.T, origin string, resp http.Header) {
assert.Empty(t, resp.Get("Access-Control-Allow-Origin"))
assert.Empty(t, resp.Get("Access-Control-Allow-Headers"))
assert.Empty(t, resp.Get("Access-Control-Allow-Credentials"))
// Added by the app handler.
assert.Equal(t, "passthru", resp.Get("X-CORS-Handler"))
},
},
// Authenticated
{
// Same behavior as Default/Public/Preflight/Subdomain.
name: "Default/Authenticated/Preflight/Subdomain",
app: func(details *Details) App { return details.Apps.AuthenticatedCORSDefault },
behavior: codersdk.CORSBehaviorSimple,
client: authenticatedClient,
origin: ownSubdomain,
httpMethod: http.MethodOptions,
expectedStatusCode: http.StatusOK,
checkResponseHeaders: func(t *testing.T, origin string, resp http.Header) {
assert.Equal(t, origin, resp.Get("Access-Control-Allow-Origin"))
assert.Contains(t, resp.Get("Access-Control-Allow-Methods"), http.MethodGet)
assert.Equal(t, "true", resp.Get("Access-Control-Allow-Credentials"))
assert.Equal(t, "X-Got-Host", resp.Get("Access-Control-Allow-Headers"))
},
},
{
// Same behavior as Default/Public/Preflight/External.
name: "Default/Authenticated/Preflight/External",
app: func(details *Details) App { return details.Apps.AuthenticatedCORSDefault },
behavior: codersdk.CORSBehaviorSimple,
client: authenticatedClient,
origin: externalOrigin,
httpMethod: http.MethodOptions,
expectedStatusCode: http.StatusOK,
checkResponseHeaders: func(t *testing.T, origin string, resp http.Header) {
assert.Empty(t, resp.Get("Access-Control-Allow-Origin"))
},
},
{
// An authenticated request to the app is allowed from its own subdomain.
name: "Default/Authenticated/GET/Subdomain",
app: func(details *Details) App { return details.Apps.AuthenticatedCORSDefault },
behavior: codersdk.CORSBehaviorSimple,
client: authenticatedClient,
origin: ownSubdomain,
httpMethod: http.MethodGet,
expectedStatusCode: http.StatusOK,
checkResponseHeaders: func(t *testing.T, origin string, resp http.Header) {
assert.Equal(t, origin, resp.Get("Access-Control-Allow-Origin"))
assert.Equal(t, "true", resp.Get("Access-Control-Allow-Credentials"))
// Added by the app handler.
assert.Equal(t, "simple", resp.Get("X-CORS-Handler"))
},
},
{
// An authenticated request to the app is allowed from an external origin.
// The origin doesn't match the app's own subdomain, so the CORS headers are not added.
name: "Default/Authenticated/GET/External",
app: func(details *Details) App { return details.Apps.AuthenticatedCORSDefault },
behavior: codersdk.CORSBehaviorSimple,
client: authenticatedClient,
origin: externalOrigin,
httpMethod: http.MethodGet,
expectedStatusCode: http.StatusOK,
checkResponseHeaders: func(t *testing.T, origin string, resp http.Header) {
assert.Empty(t, resp.Get("Access-Control-Allow-Origin"))
assert.Empty(t, resp.Get("Access-Control-Allow-Headers"))
assert.Empty(t, resp.Get("Access-Control-Allow-Credentials"))
// Added by the app handler.
assert.Equal(t, "simple", resp.Get("X-CORS-Handler"))
},
},
{
// The request is rejected because the client is unauthenticated.
name: "Passthru/Unauthenticated/Preflight/Subdomain",
app: func(details *Details) App { return details.Apps.AuthenticatedCORSPassthru },
behavior: codersdk.CORSBehaviorPassthru,
client: unauthenticatedClient,
origin: ownSubdomain,
httpMethod: http.MethodOptions,
expectedStatusCode: http.StatusSeeOther,
checkResponseHeaders: func(t *testing.T, origin string, resp http.Header) {
assert.NotEmpty(t, resp.Get("Location"))
},
},
{
// Same behavior as the above test, but the origin is different.
name: "Passthru/Unauthenticated/Preflight/External",
app: func(details *Details) App { return details.Apps.AuthenticatedCORSPassthru },
behavior: codersdk.CORSBehaviorPassthru,
client: unauthenticatedClient,
origin: externalOrigin,
httpMethod: http.MethodOptions,
expectedStatusCode: http.StatusSeeOther,
checkResponseHeaders: func(t *testing.T, origin string, resp http.Header) {
assert.NotEmpty(t, resp.Get("Location"))
},
},
{
// The request is rejected because the client is unauthenticated.
name: "Passthru/Unauthenticated/GET/Subdomain",
app: func(details *Details) App { return details.Apps.AuthenticatedCORSPassthru },
behavior: codersdk.CORSBehaviorPassthru,
client: unauthenticatedClient,
origin: ownSubdomain,
httpMethod: http.MethodGet,
expectedStatusCode: http.StatusSeeOther,
checkResponseHeaders: func(t *testing.T, origin string, resp http.Header) {
assert.NotEmpty(t, resp.Get("Location"))
},
},
{
// Same behavior as the above test, but the origin is different.
name: "Passthru/Unauthenticated/GET/External",
app: func(details *Details) App { return details.Apps.AuthenticatedCORSPassthru },
behavior: codersdk.CORSBehaviorPassthru,
client: unauthenticatedClient,
origin: externalOrigin,
httpMethod: http.MethodGet,
expectedStatusCode: http.StatusSeeOther,
checkResponseHeaders: func(t *testing.T, origin string, resp http.Header) {
assert.NotEmpty(t, resp.Get("Location"))
},
},
{
// The request is allowed because the client is authenticated.
name: "Passthru/Authenticated/Preflight/Subdomain",
app: func(details *Details) App { return details.Apps.AuthenticatedCORSPassthru },
behavior: codersdk.CORSBehaviorPassthru,
client: authenticatedClient,
origin: ownSubdomain,
httpMethod: http.MethodOptions,
expectedStatusCode: http.StatusOK,
checkResponseHeaders: func(t *testing.T, origin string, resp http.Header) {
assert.Equal(t, origin, resp.Get("Access-Control-Allow-Origin"))
assert.Equal(t, http.MethodGet, resp.Get("Access-Control-Allow-Methods"))
// Added by the app handler.
assert.Equal(t, "passthru", resp.Get("X-CORS-Handler"))
},
},
{
// Same behavior as the above test, but the origin is different.
name: "Passthru/Authenticated/Preflight/External",
app: func(details *Details) App { return details.Apps.AuthenticatedCORSPassthru },
behavior: codersdk.CORSBehaviorPassthru,
client: authenticatedClient,
origin: externalOrigin,
httpMethod: http.MethodOptions,
expectedStatusCode: http.StatusOK,
checkResponseHeaders: func(t *testing.T, origin string, resp http.Header) {
assert.Equal(t, origin, resp.Get("Access-Control-Allow-Origin"))
assert.Equal(t, http.MethodGet, resp.Get("Access-Control-Allow-Methods"))
// Added by the app handler.
assert.Equal(t, "passthru", resp.Get("X-CORS-Handler"))
},
},
{
// The request is allowed because the client is authenticated.
name: "Passthru/Authenticated/GET/Subdomain",
app: func(details *Details) App { return details.Apps.AuthenticatedCORSPassthru },
behavior: codersdk.CORSBehaviorPassthru,
client: authenticatedClient,
origin: ownSubdomain,
httpMethod: http.MethodGet,
expectedStatusCode: http.StatusOK,
checkResponseHeaders: func(t *testing.T, origin string, resp http.Header) {
assert.Equal(t, origin, resp.Get("Access-Control-Allow-Origin"))
assert.Equal(t, http.MethodGet, resp.Get("Access-Control-Allow-Methods"))
// Added by the app handler.
assert.Equal(t, "passthru", resp.Get("X-CORS-Handler"))
},
},
{
// Same behavior as the above test, but the origin is different.
name: "Passthru/Authenticated/GET/External",
app: func(details *Details) App { return details.Apps.AuthenticatedCORSPassthru },
behavior: codersdk.CORSBehaviorPassthru,
client: authenticatedClient,
origin: externalOrigin,
httpMethod: http.MethodGet,
expectedStatusCode: http.StatusOK,
checkResponseHeaders: func(t *testing.T, origin string, resp http.Header) {
assert.Equal(t, origin, resp.Get("Access-Control-Allow-Origin"))
assert.Equal(t, http.MethodGet, resp.Get("Access-Control-Allow-Methods"))
// Added by the app handler.
assert.Equal(t, "passthru", resp.Get("X-CORS-Handler"))
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
var reqHeaders http.Header
// Setup an HTTP handler which is the "app"; this handler conditionally responds
// to requests based on the CORS behavior
appDetails := setupProxyTest(t, &DeploymentOptions{
handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := r.Cookie(codersdk.SessionTokenCookie)
assert.ErrorIs(t, err, http.ErrNoCookie)
// Store the request headers for later assertions
reqHeaders = r.Header
switch tc.behavior {
case codersdk.CORSBehaviorPassthru:
w.Header().Set("X-CORS-Handler", "passthru")
// Only allow GET and OPTIONS requests
if r.Method != http.MethodGet && r.Method != http.MethodOptions {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
// If the Origin header is present, add the CORS headers.
if origin := r.Header.Get("Origin"); origin != "" {
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", http.MethodGet)
}
w.WriteHeader(http.StatusOK)
case codersdk.CORSBehaviorSimple:
w.Header().Set("X-CORS-Handler", "simple")
}
}),
})
// Update the template CORS behavior.
b := tc.behavior
template, err := appDetails.SDKClient.UpdateTemplateMeta(ctx, appDetails.Workspace.TemplateID, codersdk.UpdateTemplateMeta{
CORSBehavior: &b,
})
require.NoError(t, err)
require.Equal(t, tc.behavior, template.CORSBehavior)
// Given: a client and a workspace app
client := tc.client(t, appDetails)
path := appDetails.SubdomainAppURL(tc.app(appDetails)).String()
origin := tc.origin(appDetails, tc.app(appDetails))
fmt.Println("method: ", tc.httpMethod)
// When: a preflight request is made to an app with a specified CORS behavior
resp, err := requestWithRetries(ctx, t, client, tc.httpMethod, path, nil, func(r *http.Request) {
// Mimic non-browser clients that don't send the Origin header.
if origin != "" {
r.Header.Set("Origin", origin)
}
r.Header.Set("Access-Control-Request-Method", "GET")
r.Header.Set("Access-Control-Request-Headers", "X-Got-Host")
})
require.NoError(t, err)
defer resp.Body.Close()
// Then: the request & response must match expectations
assert.Equal(t, tc.expectedStatusCode, resp.StatusCode)
assert.NoError(t, err)
if tc.checkRequestHeaders != nil {
tc.checkRequestHeaders(t, origin, reqHeaders)
}
tc.checkResponseHeaders(t, origin, resp.Header)
})
}
})
t.Run("WorkspaceApplicationAuth", func(t *testing.T) {
t.Parallel()
@@ -1340,6 +1743,153 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) {
})
})
t.Run("CORS", func(t *testing.T) {
t.Parallel()
// Set up test headers that should be returned by the app
testHeaders := http.Header{
"Access-Control-Allow-Origin": []string{"*"},
"Access-Control-Allow-Methods": []string{"GET, POST, OPTIONS"},
}
unauthenticatedClient := func(t *testing.T, appDetails *Details) *codersdk.Client {
c := appDetails.AppClient(t)
c.SetSessionToken("")
return c
}
authenticatedClient := func(t *testing.T, appDetails *Details) *codersdk.Client {
uc, _ := coderdtest.CreateAnotherUser(t, appDetails.SDKClient, appDetails.FirstUser.OrganizationID, rbac.RoleMember())
c := appDetails.AppClient(t)
c.SetSessionToken(uc.SessionToken())
return c
}
ownerClient := func(t *testing.T, appDetails *Details) *codersdk.Client {
c := appDetails.AppClient(t) // <-- Use same server as others
c.SetSessionToken(appDetails.SDKClient.SessionToken()) // But with owner auth
return c
}
tests := []struct {
name string
shareLevel codersdk.WorkspaceAgentPortShareLevel
behavior codersdk.CORSBehavior
client func(t *testing.T, appDetails *Details) *codersdk.Client
expectedStatusCode int
expectedCORSHeaders bool
}{
// Public
{
name: "Default/Public",
shareLevel: codersdk.WorkspaceAgentPortShareLevelPublic,
behavior: codersdk.CORSBehaviorSimple,
expectedCORSHeaders: false,
client: unauthenticatedClient,
expectedStatusCode: http.StatusOK,
},
{ // fails
name: "Passthru/Public",
shareLevel: codersdk.WorkspaceAgentPortShareLevelPublic,
behavior: codersdk.CORSBehaviorPassthru,
expectedCORSHeaders: true,
client: unauthenticatedClient,
expectedStatusCode: http.StatusOK,
},
// Authenticated
{
name: "Default/Authenticated",
shareLevel: codersdk.WorkspaceAgentPortShareLevelAuthenticated,
behavior: codersdk.CORSBehaviorSimple,
expectedCORSHeaders: false,
client: authenticatedClient,
expectedStatusCode: http.StatusOK,
},
{
name: "Passthru/Authenticated",
shareLevel: codersdk.WorkspaceAgentPortShareLevelAuthenticated,
behavior: codersdk.CORSBehaviorPassthru,
expectedCORSHeaders: true,
client: authenticatedClient,
expectedStatusCode: http.StatusOK,
},
{
// The CORS behavior will not affect unauthenticated requests.
// The request will be redirected to the login page.
name: "Passthru/Unauthenticated",
shareLevel: codersdk.WorkspaceAgentPortShareLevelAuthenticated,
behavior: codersdk.CORSBehaviorPassthru,
expectedCORSHeaders: false,
client: unauthenticatedClient,
expectedStatusCode: http.StatusSeeOther,
},
// Owner
{
name: "Default/Owner",
shareLevel: codersdk.WorkspaceAgentPortShareLevelAuthenticated, // Owner is not a valid share level for ports.
behavior: codersdk.CORSBehaviorSimple,
expectedCORSHeaders: false,
client: ownerClient,
expectedStatusCode: http.StatusOK,
},
{ // fails
name: "Passthru/Owner",
shareLevel: codersdk.WorkspaceAgentPortShareLevelAuthenticated, // Owner is not a valid share level for ports.
behavior: codersdk.CORSBehaviorPassthru,
expectedCORSHeaders: true,
client: ownerClient,
expectedStatusCode: http.StatusOK,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
appDetails := setupProxyTest(t, &DeploymentOptions{
headers: testHeaders,
})
port, err := strconv.ParseInt(appDetails.Apps.Port.AppSlugOrPort, 10, 32)
require.NoError(t, err)
// Update the template CORS behavior.
b := tc.behavior
template, err := appDetails.SDKClient.UpdateTemplateMeta(ctx, appDetails.Workspace.TemplateID, codersdk.UpdateTemplateMeta{
CORSBehavior: &b,
})
require.NoError(t, err)
require.Equal(t, tc.behavior, template.CORSBehavior)
// Set the port we have to be shared.
_, err = appDetails.SDKClient.UpsertWorkspaceAgentPortShare(ctx, appDetails.Workspace.ID, codersdk.UpsertWorkspaceAgentPortShareRequest{
AgentName: proxyTestAgentName,
Port: int32(port),
ShareLevel: tc.shareLevel,
Protocol: codersdk.WorkspaceAgentPortShareProtocolHTTP,
})
require.NoError(t, err)
client := tc.client(t, appDetails)
resp, err := requestWithRetries(ctx, t, client, http.MethodGet, appDetails.SubdomainAppURL(appDetails.Apps.Port).String(), nil)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, tc.expectedStatusCode, resp.StatusCode)
if tc.expectedCORSHeaders {
require.Equal(t, testHeaders.Get("Access-Control-Allow-Origin"), resp.Header.Get("Access-Control-Allow-Origin"), "allow origin did not match")
require.Equal(t, testHeaders.Get("Access-Control-Allow-Methods"), resp.Header.Get("Access-Control-Allow-Methods"), "allow methods did not match")
} else {
require.Empty(t, resp.Header.Get("Access-Control-Allow-Origin"))
require.Empty(t, resp.Header.Get("Access-Control-Allow-Methods"))
}
})
}
})
t.Run("AppSharing", func(t *testing.T) {
t.Parallel()
@@ -1386,7 +1936,7 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) {
forceURLTransport(t, client)
// Create workspace.
port := appServer(t, nil, false)
port := appServer(t, nil, false, nil)
workspace, _ = createWorkspaceWithApps(t, client, user.OrganizationIDs[0], user, port, false)
// Verify that the apps have the correct sharing levels set.
@@ -1397,10 +1947,14 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) {
agnt = workspaceBuild.Resources[0].Agents[0]
found := map[string]codersdk.WorkspaceAppSharingLevel{}
expected := map[string]codersdk.WorkspaceAppSharingLevel{
proxyTestAppNameFake: codersdk.WorkspaceAppSharingLevelOwner,
proxyTestAppNameOwner: codersdk.WorkspaceAppSharingLevelOwner,
proxyTestAppNameAuthenticated: codersdk.WorkspaceAppSharingLevelAuthenticated,
proxyTestAppNamePublic: codersdk.WorkspaceAppSharingLevelPublic,
proxyTestAppNameFake: codersdk.WorkspaceAppSharingLevelOwner,
proxyTestAppNameOwner: codersdk.WorkspaceAppSharingLevelOwner,
proxyTestAppNameAuthenticated: codersdk.WorkspaceAppSharingLevelAuthenticated,
proxyTestAppNamePublic: codersdk.WorkspaceAppSharingLevelPublic,
proxyTestAppNameAuthenticatedCORSPassthru: codersdk.WorkspaceAppSharingLevelAuthenticated,
proxyTestAppNamePublicCORSPassthru: codersdk.WorkspaceAppSharingLevelPublic,
proxyTestAppNameAuthenticatedCORSDefault: codersdk.WorkspaceAppSharingLevelAuthenticated,
proxyTestAppNamePublicCORSDefault: codersdk.WorkspaceAppSharingLevelPublic,
}
for _, app := range agnt.Apps {
found[app.DisplayName] = app.SharingLevel
+102 -25
View File
@@ -36,8 +36,13 @@ const (
proxyTestAppNameOwner = "test-app-owner"
proxyTestAppNameAuthenticated = "test-app-authenticated"
proxyTestAppNamePublic = "test-app-public"
proxyTestAppQuery = "query=true"
proxyTestAppBody = "hello world from apps test"
// nolint:gosec // Not a secret
proxyTestAppNameAuthenticatedCORSPassthru = "test-app-authenticated-cors-passthru"
proxyTestAppNamePublicCORSPassthru = "test-app-public-cors-passthru"
proxyTestAppNameAuthenticatedCORSDefault = "test-app-authenticated-cors-default"
proxyTestAppNamePublicCORSDefault = "test-app-public-cors-default"
proxyTestAppQuery = "query=true"
proxyTestAppBody = "hello world from apps test"
proxyTestSubdomainRaw = "*.test.coder.com"
proxyTestSubdomain = "test.coder.com"
@@ -60,6 +65,7 @@ type DeploymentOptions struct {
noWorkspace bool
port uint16
headers http.Header
handler http.Handler
}
// Deployment is a license-agnostic deployment with all the fields that apps
@@ -93,6 +99,9 @@ type App struct {
// Prefix should have ---.
Prefix string
Query string
// Control the behavior of CORS handling.
CORSBehavior codersdk.CORSBehavior
}
// Details are the full test details returned from setupProxyTestWithFactory.
@@ -109,12 +118,16 @@ type Details struct {
AppPort uint16
Apps struct {
Fake App
Owner App
Authenticated App
Public App
Port App
PortHTTPS App
Fake App
Owner App
Authenticated App
Public App
Port App
PortHTTPS App
PublicCORSPassthru App
AuthenticatedCORSPassthru App
PublicCORSDefault App
AuthenticatedCORSDefault App
}
}
@@ -201,7 +214,7 @@ func setupProxyTestWithFactory(t *testing.T, factory DeploymentFactory, opts *De
}
if opts.port == 0 {
opts.port = appServer(t, opts.headers, opts.ServeHTTPS)
opts.port = appServer(t, opts.headers, opts.ServeHTTPS, opts.handler)
}
workspace, agnt := createWorkspaceWithApps(t, deployment.SDKClient, deployment.FirstUser.OrganizationID, me, opts.port, opts.ServeHTTPS)
@@ -252,30 +265,64 @@ func setupProxyTestWithFactory(t *testing.T, factory DeploymentFactory, opts *De
AgentName: agnt.Name,
AppSlugOrPort: strconv.Itoa(int(opts.port)) + "s",
}
details.Apps.PublicCORSPassthru = App{
Username: me.Username,
WorkspaceName: workspace.Name,
AgentName: agnt.Name,
AppSlugOrPort: proxyTestAppNamePublicCORSPassthru,
CORSBehavior: codersdk.CORSBehaviorPassthru,
Query: proxyTestAppQuery,
}
details.Apps.AuthenticatedCORSPassthru = App{
Username: me.Username,
WorkspaceName: workspace.Name,
AgentName: agnt.Name,
AppSlugOrPort: proxyTestAppNameAuthenticatedCORSPassthru,
CORSBehavior: codersdk.CORSBehaviorPassthru,
Query: proxyTestAppQuery,
}
details.Apps.PublicCORSDefault = App{
Username: me.Username,
WorkspaceName: workspace.Name,
AgentName: agnt.Name,
AppSlugOrPort: proxyTestAppNamePublicCORSDefault,
Query: proxyTestAppQuery,
}
details.Apps.AuthenticatedCORSDefault = App{
Username: me.Username,
WorkspaceName: workspace.Name,
AgentName: agnt.Name,
AppSlugOrPort: proxyTestAppNameAuthenticatedCORSDefault,
Query: proxyTestAppQuery,
}
return details
}
//nolint:revive
func appServer(t *testing.T, headers http.Header, isHTTPS bool) uint16 {
server := httptest.NewUnstartedServer(
http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
_, err := r.Cookie(codersdk.SessionTokenCookie)
assert.ErrorIs(t, err, http.ErrNoCookie)
w.Header().Set("X-Forwarded-For", r.Header.Get("X-Forwarded-For"))
w.Header().Set("X-Got-Host", r.Host)
for name, values := range headers {
for _, value := range values {
w.Header().Add(name, value)
}
func appServer(t *testing.T, headers http.Header, isHTTPS bool, handler http.Handler) uint16 {
defaultHandler := http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
_, err := r.Cookie(codersdk.SessionTokenCookie)
assert.ErrorIs(t, err, http.ErrNoCookie)
w.Header().Set("X-Forwarded-For", r.Header.Get("X-Forwarded-For"))
w.Header().Set("X-Got-Host", r.Host)
for name, values := range headers {
for _, value := range values {
w.Header().Add(name, value)
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(proxyTestAppBody))
},
),
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(proxyTestAppBody))
},
)
if handler == nil {
handler = defaultHandler
}
server := httptest.NewUnstartedServer(handler)
server.Config.ReadHeaderTimeout = time.Minute
if isHTTPS {
server.StartTLS()
@@ -361,6 +408,36 @@ func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.U
Url: appURL,
Subdomain: true,
},
{
Slug: proxyTestAppNamePublicCORSPassthru,
DisplayName: proxyTestAppNamePublicCORSPassthru,
SharingLevel: proto.AppSharingLevel_PUBLIC,
Url: appURL,
Subdomain: true,
// CorsBehavior: proto.AppCORSBehavior_PASSTHRU,
},
{
Slug: proxyTestAppNameAuthenticatedCORSPassthru,
DisplayName: proxyTestAppNameAuthenticatedCORSPassthru,
SharingLevel: proto.AppSharingLevel_AUTHENTICATED,
Url: appURL,
Subdomain: true,
// CorsBehavior: proto.AppCORSBehavior_PASSTHRU,
},
{
Slug: proxyTestAppNamePublicCORSDefault,
DisplayName: proxyTestAppNamePublicCORSDefault,
SharingLevel: proto.AppSharingLevel_PUBLIC,
Url: appURL,
Subdomain: true,
},
{
Slug: proxyTestAppNameAuthenticatedCORSDefault,
DisplayName: proxyTestAppNameAuthenticatedCORSDefault,
SharingLevel: proto.AppSharingLevel_AUTHENTICATED,
Url: appURL,
Subdomain: true,
},
}
version := coderdtest.CreateTemplateVersion(t, client, orgID, &echo.Responses{
Parse: echo.ParseComplete,