feat: allow suffix after wildcard in wildcard access URL (#4524)

This commit is contained in:
Dean Sheather
2022-10-15 04:25:11 +10:00
committed by GitHub
parent ccc008eb5e
commit a029817d3d
18 changed files with 566 additions and 180 deletions

View File

@ -23,7 +23,15 @@ func TestWorkspaceActivityBump(t *testing.T) {
setupActivityTest := func(t *testing.T) (client *codersdk.Client, workspace codersdk.Workspace, assertBumped func(want bool)) {
var ttlMillis int64 = 60 * 1000
client, _, workspace, _ = setupProxyTest(t, func(cwr *codersdk.CreateWorkspaceRequest) {
client = coderdtest.New(t, &coderdtest.Options{
AppHostname: proxyTestSubdomainRaw,
IncludeProvisionerDaemon: true,
AgentStatsRefreshInterval: time.Millisecond * 100,
MetricsCacheRefreshInterval: time.Millisecond * 100,
})
user := coderdtest.CreateFirstUser(t, client)
workspace = createWorkspaceWithApps(t, client, user.OrganizationID, 1234, func(cwr *codersdk.CreateWorkspaceRequest) {
cwr.TTLMillis = &ttlMillis
})

View File

@ -6,6 +6,7 @@ import (
"net/http"
"net/url"
"path/filepath"
"regexp"
"sync"
"sync/atomic"
"time"
@ -46,11 +47,16 @@ import (
type Options struct {
AccessURL *url.URL
// AppHostname should be the wildcard hostname to use for workspace
// applications without the asterisk or leading dot. E.g. "apps.coder.com".
// applications INCLUDING the asterisk, (optional) suffix and leading dot.
// E.g. "*.apps.coder.com" or "*-apps.coder.com".
AppHostname string
Logger slog.Logger
Database database.Store
Pubsub database.Pubsub
// AppHostnameRegex contains the regex version of options.AppHostname as
// generated by httpapi.CompileHostnamePattern(). It MUST be set if
// options.AppHostname is set.
AppHostnameRegex *regexp.Regexp
Logger slog.Logger
Database database.Store
Pubsub database.Pubsub
// CacheDir is used for caching files served by the API.
CacheDir string
@ -90,6 +96,9 @@ func New(options *Options) *API {
if options == nil {
options = &Options{}
}
if options.AppHostname != "" && options.AppHostnameRegex == nil || options.AppHostname == "" && options.AppHostnameRegex != nil {
panic("coderd: both AppHostname and AppHostnameRegex must be set or unset")
}
if options.AgentConnectionUpdateFrequency == 0 {
options.AgentConnectionUpdateFrequency = 3 * time.Second
}

View File

@ -11,7 +11,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) {
t.Parallel()
client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
// Required for any subdomain-based proxy tests to pass.
AppHostname: "test.coder.com",
AppHostname: "*.test.coder.com",
Authorizer: &coderdtest.RecordingAuthorizer{},
IncludeProvisionerDaemon: true,
})

View File

@ -20,6 +20,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"strconv"
"strings"
"testing"
@ -49,6 +50,7 @@ import (
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/database/dbtestutil"
"github.com/coder/coder/coderd/gitsshkey"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/coderd/telemetry"
"github.com/coder/coder/coderd/util/ptr"
@ -172,6 +174,13 @@ func NewOptions(t *testing.T, options *Options) (*httptest.Server, context.Cance
options.SSHKeygenAlgorithm = gitsshkey.AlgorithmEd25519
}
var appHostnameRegex *regexp.Regexp
if options.AppHostname != "" {
var err error
appHostnameRegex, err = httpapi.CompileHostnamePattern(options.AppHostname)
require.NoError(t, err)
}
return srv, cancelFunc, &coderd.Options{
AgentConnectionUpdateFrequency: 150 * time.Millisecond,
// Force a long disconnection timeout to ensure
@ -179,6 +188,7 @@ func NewOptions(t *testing.T, options *Options) (*httptest.Server, context.Cance
AgentInactiveDisconnectTimeout: testutil.WaitShort,
AccessURL: serverURL,
AppHostname: options.AppHostname,
AppHostnameRegex: appHostnameRegex,
Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
CacheDir: t.TempDir(),
Database: db,

View File

@ -17,21 +17,10 @@ var (
// {PORT/APP_NAME}--{AGENT_NAME}--{WORKSPACE_NAME}--{USERNAME}
`^(?P<AppName>%[1]s)--(?P<AgentName>%[1]s)--(?P<WorkspaceName>%[1]s)--(?P<Username>%[1]s)$`,
nameRegex))
validHostnameLabelRegex = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`)
)
// 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"
// - "foo" becomes "foo", ""
func SplitSubdomain(hostname string) (subdomain string, rest string) {
toks := strings.SplitN(hostname, ".", 2)
if len(toks) < 2 {
return toks[0], ""
}
return toks[0], toks[1]
}
// ApplicationURL is a parsed application URL hostname.
type ApplicationURL struct {
// Only one of AppName or Port will be set.
@ -111,3 +100,81 @@ func HostnamesMatch(a, b string) bool {
return strings.EqualFold(aHost, bHost)
}
// CompileHostnamePattern compiles a hostname pattern into a regular expression.
// A hostname pattern is a string that may contain a single wildcard character
// at the beginning. The wildcard character matches any number of hostname-safe
// characters excluding periods. The pattern is case-insensitive.
//
// The supplied pattern:
// - must not start or end with a period
// - must contain exactly one asterisk at the beginning
// - must not contain any other wildcard characters
// - must not contain any other characters that are not hostname-safe (including
// whitespace)
// - must contain at least two hostname labels/segments (i.e. "foo" or "*" are
// not valid patterns, but "foo.bar" and "*.bar" are).
//
// The returned regular expression will match an entire hostname with optional
// trailing periods and whitespace. The first submatch will be the wildcard
// match.
func CompileHostnamePattern(pattern string) (*regexp.Regexp, error) {
pattern = strings.ToLower(pattern)
if strings.Contains(pattern, "http:") || strings.Contains(pattern, "https:") {
return nil, xerrors.Errorf("hostname pattern must not contain a scheme: %q", pattern)
}
if strings.Contains(pattern, ":") {
return nil, xerrors.Errorf("hostname pattern must not contain a port: %q", pattern)
}
if strings.HasPrefix(pattern, ".") || strings.HasSuffix(pattern, ".") {
return nil, xerrors.Errorf("hostname pattern must not start or end with a period: %q", pattern)
}
if strings.Count(pattern, ".") < 1 {
return nil, xerrors.Errorf("hostname pattern must contain at least two labels/segments: %q", pattern)
}
if strings.Count(pattern, "*") != 1 {
return nil, xerrors.Errorf("hostname pattern must contain exactly one asterisk: %q", pattern)
}
if !strings.HasPrefix(pattern, "*") {
return nil, xerrors.Errorf("hostname pattern must only contain an asterisk at the beginning: %q", pattern)
}
for i, label := range strings.Split(pattern, ".") {
if i == 0 {
// We have to allow the asterisk to be a valid hostname label.
label = strings.TrimPrefix(label, "*")
label = "a" + label
}
if !validHostnameLabelRegex.MatchString(label) {
return nil, xerrors.Errorf("hostname pattern contains invalid label %q: %q", label, pattern)
}
}
// Replace periods with escaped periods.
regexPattern := strings.ReplaceAll(pattern, ".", "\\.")
// Capture wildcard match.
regexPattern = strings.Replace(regexPattern, "*", "([^.]+)", 1)
// Allow trailing period.
regexPattern = regexPattern + "\\.?"
// Allow optional port number.
regexPattern += "(:\\d+)?"
// Allow leading and trailing whitespace.
regexPattern = `^\s*` + regexPattern + `\s*$`
return regexp.Compile(regexPattern)
}
// ExecuteHostnamePattern executes a pattern generated by CompileHostnamePattern
// and returns the wildcard match. If the pattern does not match the hostname,
// returns false.
func ExecuteHostnamePattern(pattern *regexp.Regexp, hostname string) (string, bool) {
matches := pattern.FindStringSubmatch(hostname)
if len(matches) < 2 {
return "", false
}
return matches[1], true
}

View File

@ -1,6 +1,7 @@
package httpapi_test
import (
"fmt"
"testing"
"github.com/stretchr/testify/require"
@ -8,64 +9,6 @@ import (
"github.com/coder/coder/coderd/httpapi"
)
func TestSplitSubdomain(t *testing.T) {
t.Parallel()
testCases := []struct {
Name string
Host string
ExpectedSubdomain string
ExpectedRest string
}{
{
Name: "Empty",
Host: "",
ExpectedSubdomain: "",
ExpectedRest: "",
},
{
Name: "NoSubdomain",
Host: "com",
ExpectedSubdomain: "com",
ExpectedRest: "",
},
{
Name: "Domain",
Host: "coder.com",
ExpectedSubdomain: "coder",
ExpectedRest: "com",
},
{
Name: "Subdomain",
Host: "subdomain.coder.com",
ExpectedSubdomain: "subdomain",
ExpectedRest: "coder.com",
},
{
Name: "DoubleSubdomain",
Host: "subdomain1.subdomain2.coder.com",
ExpectedSubdomain: "subdomain1",
ExpectedRest: "subdomain2.coder.com",
},
{
Name: "WithPort",
Host: "subdomain.coder.com:8080",
ExpectedSubdomain: "subdomain",
ExpectedRest: "coder.com:8080",
},
}
for _, c := range testCases {
c := c
t.Run(c.Name, func(t *testing.T) {
t.Parallel()
subdomain, rest := httpapi.SplitSubdomain(c.Host)
require.Equal(t, c.ExpectedSubdomain, subdomain)
require.Equal(t, c.ExpectedRest, rest)
})
}
}
func TestApplicationURLString(t *testing.T) {
t.Parallel()
@ -214,3 +157,239 @@ func TestParseSubdomainAppURL(t *testing.T) {
})
}
}
func TestCompileHostnamePattern(t *testing.T) {
t.Parallel()
type matchCase struct {
input string
// empty string denotes no match
match string
}
type testCase struct {
name string
pattern string
errorContains string
// expectedRegex only needs to contain the inner part of the regex, not
// the prefix and suffix checks.
expectedRegex string
matchCases []matchCase
}
testCases := []testCase{
{
name: "Invalid_ContainsHTTP",
pattern: "http://*.hi.com",
errorContains: "must not contain a scheme",
},
{
name: "Invalid_ContainsHTTPS",
pattern: "https://*.hi.com",
errorContains: "must not contain a scheme",
},
{
name: "Invalid_ContainsPort",
pattern: "*.hi.com:8080",
errorContains: "must not contain a port",
},
{
name: "Invalid_StartPeriod",
pattern: ".hi.com",
errorContains: "must not start or end with a period",
},
{
name: "Invalid_EndPeriod",
pattern: "hi.com.",
errorContains: "must not start or end with a period",
},
{
name: "Invalid_Empty",
pattern: "",
errorContains: "must contain at least two labels",
},
{
name: "Invalid_SingleLabel",
pattern: "hi",
errorContains: "must contain at least two labels",
},
{
name: "Invalid_NoWildcard",
pattern: "hi.com",
errorContains: "must contain exactly one asterisk",
},
{
name: "Invalid_MultipleWildcards",
pattern: "**.hi.com",
errorContains: "must contain exactly one asterisk",
},
{
name: "Invalid_WildcardNotFirst",
pattern: "hi.*.com",
errorContains: "must only contain an asterisk at the beginning",
},
{
name: "Invalid_BadLabel1",
pattern: "*.h_i.com",
errorContains: "contains invalid label",
},
{
name: "Invalid_BadLabel2",
pattern: "*.hi-.com",
errorContains: "contains invalid label",
},
{
name: "Invalid_BadLabel3",
pattern: "*.-hi.com",
errorContains: "contains invalid label",
},
{
name: "Valid_Simple",
pattern: "*.hi",
expectedRegex: `([^.]+)\.hi`,
matchCases: []matchCase{
{
input: "hi",
match: "",
},
{
input: "hi.com",
match: "",
},
{
input: "hi.hi.hi",
match: "",
},
{
input: "abcd.hi",
match: "abcd",
},
{
input: "abcd.hi.",
match: "abcd",
},
{
input: " abcd.hi. ",
match: "abcd",
},
{
input: "abcd.hi:8080",
match: "abcd",
},
{
input: "ab__invalid__cd-.hi",
// Invalid subdomains still match the pattern because they
// managed to make it to the webserver anyways.
match: "ab__invalid__cd-",
},
},
},
{
name: "Valid_MultiLevel",
pattern: "*.hi.com",
expectedRegex: `([^.]+)\.hi\.com`,
matchCases: []matchCase{
{
input: "hi.com",
match: "",
},
{
input: "abcd.hi.com",
match: "abcd",
},
{
input: "ab__invalid__cd-.hi.com",
match: "ab__invalid__cd-",
},
},
},
{
name: "Valid_WildcardSuffix1",
pattern: `*a.hi.com`,
expectedRegex: `([^.]+)a\.hi\.com`,
matchCases: []matchCase{
{
input: "hi.com",
match: "",
},
{
input: "abcd.hi.com",
match: "",
},
{
input: "ab__invalid__cd-.hi.com",
match: "",
},
{
input: "abcda.hi.com",
match: "abcd",
},
{
input: "ab__invalid__cd-a.hi.com",
match: "ab__invalid__cd-",
},
},
},
{
name: "Valid_WildcardSuffix2",
pattern: `*-test.hi.com`,
expectedRegex: `([^.]+)-test\.hi\.com`,
matchCases: []matchCase{
{
input: "hi.com",
match: "",
},
{
input: "abcd.hi.com",
match: "",
},
{
input: "ab__invalid__cd-.hi.com",
match: "",
},
{
input: "abcd-test.hi.com",
match: "abcd",
},
{
input: "ab__invalid__cd-test.hi.com",
match: "ab__invalid__cd",
},
},
},
}
for _, c := range testCases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
regex, err := httpapi.CompileHostnamePattern(c.pattern)
if c.errorContains == "" {
require.NoError(t, err)
expected := `^\s*` + c.expectedRegex + `\.?(:\d+)?\s*$`
require.Equal(t, expected, regex.String(), "generated regex does not match")
for i, m := range c.matchCases {
m := m
t.Run(fmt.Sprintf("MatchCase%d", i), func(t *testing.T) {
t.Parallel()
match, ok := httpapi.ExecuteHostnamePattern(regex, m.input)
if m.match == "" {
require.False(t, ok)
} else {
require.True(t, ok)
require.Equal(t, m.match, match)
}
})
}
} else {
require.Error(t, err)
require.ErrorContains(t, err, c.errorContains)
}
})
}
}

View File

@ -139,7 +139,7 @@ func (api *API) handleSubdomainApplications(middlewares ...func(http.Handler) ht
// Step 1: Pass on if subdomain-based application proxying is not
// configured.
if api.AppHostname == "" {
if api.AppHostname == "" || api.AppHostnameRegex == nil {
next.ServeHTTP(rw, r)
return
}
@ -219,28 +219,25 @@ func (api *API) parseWorkspaceApplicationHostname(rw http.ResponseWriter, r *htt
return httpapi.ApplicationURL{}, false
}
// 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.
// If there are no periods in the hostname, then it can't be a valid
// application URL.
if !strings.Contains(host, ".") {
next.ServeHTTP(rw, r)
return httpapi.ApplicationURL{}, false
}
// Split the subdomain so we can parse the application details and verify it
// matches the configured app hostname later.
subdomain, ok := httpapi.ExecuteHostnamePattern(api.AppHostnameRegex, host)
if !ok {
// Doesn't match the regex, so it's not a valid application URL.
next.ServeHTTP(rw, r)
return httpapi.ApplicationURL{}, false
}
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.ApplicationURL{}, false
}
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusBadRequest,
Title: "Invalid application URL",
@ -251,20 +248,6 @@ func (api *API) parseWorkspaceApplicationHostname(rw http.ResponseWriter, r *htt
return httpapi.ApplicationURL{}, false
}
// 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 {
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusNotFound,
Title: "Not Found",
Description: "The server does not accept application requests on this hostname.",
RetryEnabled: false,
DashboardURL: api.AccessURL.String(),
})
return httpapi.ApplicationURL{}, false
}
return app, true
}
@ -506,8 +489,8 @@ func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request
// Ensure that the redirect URI is a subdomain of api.AppHostname and is a
// valid app subdomain.
subdomain, rest := httpapi.SplitSubdomain(u.Hostname())
if !httpapi.HostnamesMatch(api.AppHostname, rest) {
subdomain, ok := httpapi.ExecuteHostnamePattern(api.AppHostnameRegex, u.Host)
if !ok {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "The redirect_uri query parameter must be a valid app subdomain.",
})

View File

@ -35,15 +35,16 @@ const (
proxyTestAppNameAuthenticated = "test-app-authenticated"
proxyTestAppNamePublic = "test-app-public"
proxyTestAppQuery = "query=true"
proxyTestAppBody = "hello world"
proxyTestAppBody = "hello world from apps test"
proxyTestSubdomain = "test.coder.com"
proxyTestSubdomainRaw = "*.test.coder.com"
proxyTestSubdomain = "test.coder.com"
)
func TestGetAppHost(t *testing.T) {
t.Parallel()
cases := []string{"", "test.coder.com"}
cases := []string{"", proxyTestSubdomainRaw}
for _, c := range cases {
c := c
name := c
@ -75,7 +76,7 @@ func TestGetAppHost(t *testing.T) {
// setupProxyTest creates a workspace with an agent and some apps. It returns a
// codersdk client, the first user, the workspace, and the port number the test
// listener is running on.
func setupProxyTest(t *testing.T, workspaceMutators ...func(*codersdk.CreateWorkspaceRequest)) (*codersdk.Client, codersdk.CreateFirstUserResponse, codersdk.Workspace, uint16) {
func setupProxyTest(t *testing.T, customAppHost ...string) (*codersdk.Client, codersdk.CreateFirstUserResponse, codersdk.Workspace, uint16) {
// #nosec
ln, err := net.Listen("tcp", ":0")
require.NoError(t, err)
@ -96,17 +97,42 @@ func setupProxyTest(t *testing.T, workspaceMutators ...func(*codersdk.CreateWork
tcpAddr, ok := ln.Addr().(*net.TCPAddr)
require.True(t, ok)
appHost := proxyTestSubdomainRaw
if len(customAppHost) > 0 {
appHost = customAppHost[0]
}
client := coderdtest.New(t, &coderdtest.Options{
AppHostname: proxyTestSubdomain,
AppHostname: appHost,
IncludeProvisionerDaemon: true,
AgentStatsRefreshInterval: time.Millisecond * 100,
MetricsCacheRefreshInterval: time.Millisecond * 100,
})
user := coderdtest.CreateFirstUser(t, client)
workspace := createWorkspaceWithApps(t, client, user.OrganizationID, uint16(tcpAddr.Port))
// Configure the HTTP client to not follow redirects and to route all
// requests regardless of hostname to the coderd test server.
client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
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, user, workspace, uint16(tcpAddr.Port)
}
func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.UUID, port uint16, workspaceMutators ...func(*codersdk.CreateWorkspaceRequest)) codersdk.Workspace {
authToken := uuid.NewString()
appURL := fmt.Sprintf("http://127.0.0.1:%d?%s", tcpAddr.Port, proxyTestAppQuery)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
appURL := fmt.Sprintf("http://127.0.0.1:%d?%s", port, proxyTestAppQuery)
version := coderdtest.CreateTemplateVersion(t, client, orgID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionDryRun: echo.ProvisionComplete,
Provision: []*proto.Provision_Response{{
@ -150,9 +176,9 @@ func setupProxyTest(t *testing.T, workspaceMutators ...func(*codersdk.CreateWork
},
}},
})
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
template := coderdtest.CreateTemplate(t, client, orgID, version.ID)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, workspaceMutators...)
workspace := coderdtest.CreateWorkspace(t, client, orgID, template.ID, workspaceMutators...)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
agentClient := codersdk.New(client.URL)
@ -168,20 +194,7 @@ func setupProxyTest(t *testing.T, workspaceMutators ...func(*codersdk.CreateWork
})
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
// Configure the HTTP client to not follow redirects and to route all
// requests regardless of hostname to the coderd test server.
client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
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, user, workspace, uint16(tcpAddr.Port)
return workspace
}
func TestWorkspaceAppsProxyPath(t *testing.T) {
@ -528,28 +541,9 @@ func TestWorkspaceAppsProxySubdomainBlocked(t *testing.T) {
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)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Contains(t, string(body), "does not accept application requests on this hostname")
})
t.Run("InvalidSubdomain", func(t *testing.T) {
t.Parallel()
client := setup(t, proxyTestSubdomain)
client := setup(t, proxyTestSubdomainRaw)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
@ -569,11 +563,11 @@ func TestWorkspaceAppsProxySubdomainBlocked(t *testing.T) {
func TestWorkspaceAppsProxySubdomain(t *testing.T) {
t.Parallel()
client, firstUser, workspace, port := setupProxyTest(t)
client, firstUser, _, port := setupProxyTest(t)
// proxyURL generates a URL for the proxy subdomain. The default path is a
// slash.
proxyURL := func(t *testing.T, appNameOrPort interface{}, pathAndQuery ...string) string {
proxyURL := func(t *testing.T, client *codersdk.Client, appNameOrPort interface{}, pathAndQuery ...string) string {
t.Helper()
var (
@ -587,16 +581,30 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) {
require.True(t, ok)
}
me, err := client.User(context.Background(), codersdk.Me)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
me, err := client.User(ctx, codersdk.Me)
require.NoError(t, err, "get current user details")
hostname := httpapi.ApplicationURL{
workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
Owner: codersdk.Me,
})
require.NoError(t, err, "get workspaces")
require.Len(t, workspaces, 1, "expected 1 workspace")
appHost, err := client.GetAppHost(ctx)
require.NoError(t, err, "get app host")
subdomain := httpapi.ApplicationURL{
AppName: appName,
Port: port,
AgentName: proxyTestAgentName,
WorkspaceName: workspace.Name,
WorkspaceName: workspaces[0].Name,
Username: me.Username,
}.String() + "." + proxyTestSubdomain
}.String()
hostname := strings.Replace(appHost.Host, "*", subdomain, 1)
actualPath := "/"
query := ""
@ -625,7 +633,7 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := userClient.Request(ctx, http.MethodGet, proxyURL(t, proxyTestAppNameOwner), nil)
resp, err := userClient.Request(ctx, http.MethodGet, proxyURL(t, client, proxyTestAppNameOwner), nil)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusNotFound, resp.StatusCode)
@ -637,7 +645,7 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
slashlessURL := proxyURL(t, proxyTestAppNameOwner, "")
slashlessURL := proxyURL(t, client, proxyTestAppNameOwner, "")
resp, err := client.Request(ctx, http.MethodGet, slashlessURL, nil)
require.NoError(t, err)
defer resp.Body.Close()
@ -654,7 +662,7 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
querylessURL := proxyURL(t, proxyTestAppNameOwner, "/", "")
querylessURL := proxyURL(t, client, proxyTestAppNameOwner, "/", "")
resp, err := client.Request(ctx, http.MethodGet, querylessURL, nil)
require.NoError(t, err)
defer resp.Body.Close()
@ -671,7 +679,7 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := client.Request(ctx, http.MethodGet, proxyURL(t, proxyTestAppNameOwner, "/", proxyTestAppQuery), nil)
resp, err := client.Request(ctx, http.MethodGet, proxyURL(t, client, proxyTestAppNameOwner, "/", proxyTestAppQuery), nil)
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
@ -686,7 +694,7 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := client.Request(ctx, http.MethodGet, proxyURL(t, port, "/", proxyTestAppQuery), nil)
resp, err := client.Request(ctx, http.MethodGet, proxyURL(t, client, port, "/", proxyTestAppQuery), nil)
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
@ -701,7 +709,7 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := client.Request(ctx, http.MethodGet, proxyURL(t, proxyTestAppNameFake, "/", ""), nil)
resp, err := client.Request(ctx, http.MethodGet, proxyURL(t, client, proxyTestAppNameFake, "/", ""), nil)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusBadGateway, resp.StatusCode)
@ -714,7 +722,7 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) {
defer cancel()
port := uint16(codersdk.MinimumListeningPort - 1)
resp, err := client.Request(ctx, http.MethodGet, proxyURL(t, port, "/", proxyTestAppQuery), nil)
resp, err := client.Request(ctx, http.MethodGet, proxyURL(t, client, port, "/", proxyTestAppQuery), nil)
require.NoError(t, err)
defer resp.Body.Close()
@ -725,6 +733,72 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) {
require.NoError(t, err)
require.Contains(t, resBody.Message, "Coder reserves ports less than")
})
t.Run("SuffixWildcardOK", func(t *testing.T) {
t.Parallel()
client, _, _, _ := setupProxyTest(t, "*-suffix.test.coder.com")
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
u := proxyURL(t, client, proxyTestAppNameOwner, "/", proxyTestAppQuery)
t.Logf("url: %s", u)
resp, err := client.Request(ctx, http.MethodGet, u, nil)
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, proxyTestAppBody, string(body))
require.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("SuffixWildcardNotMatch", func(t *testing.T) {
t.Parallel()
client, _, _, _ := setupProxyTest(t, "*-suffix.test.coder.com")
t.Run("NoSuffix", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
u := proxyURL(t, client, proxyTestAppNameOwner, "/", proxyTestAppQuery)
// Replace the -suffix with nothing.
u = strings.Replace(u, "-suffix", "", 1)
resp, err := client.Request(ctx, http.MethodGet, u, nil)
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
// It's probably rendering the dashboard, so only ensure that the body
// doesn't match.
require.NotContains(t, string(body), proxyTestAppBody)
})
t.Run("DifferentSuffix", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
u := proxyURL(t, client, proxyTestAppNameOwner, "/", proxyTestAppQuery)
// Replace the -suffix with something else.
u = strings.Replace(u, "-suffix", "-not-suffix", 1)
resp, err := client.Request(ctx, http.MethodGet, u, nil)
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
// It's probably rendering the dashboard, so only ensure that the body
// doesn't match.
require.NotContains(t, string(body), proxyTestAppBody)
})
})
}
func TestAppSharing(t *testing.T) {