mirror of
https://github.com/coder/coder.git
synced 2025-07-03 16:13:58 +00:00
feat: secure and cross-domain subdomain-based proxying (#4136)
Co-authored-by: Kyle Carberry <kyle@carberry.com>
This commit is contained in:
@ -2,6 +2,7 @@ package httpapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -21,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.
|
||||
@ -40,35 +40,31 @@ type ApplicationURL struct {
|
||||
AgentName string
|
||||
WorkspaceName string
|
||||
Username string
|
||||
// BaseHostname is the rest of the hostname minus the application URL part
|
||||
// and the first dot.
|
||||
BaseHostname string
|
||||
}
|
||||
|
||||
// String returns the application URL hostname without scheme.
|
||||
// String returns the application URL hostname without scheme. You will likely
|
||||
// want to append a period and the base hostname.
|
||||
func (a ApplicationURL) String() string {
|
||||
appNameOrPort := a.AppName
|
||||
if a.Port != 0 {
|
||||
appNameOrPort = strconv.Itoa(int(a.Port))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s--%s--%s--%s.%s", appNameOrPort, a.AgentName, a.WorkspaceName, a.Username, a.BaseHostname)
|
||||
return fmt.Sprintf("%s--%s--%s--%s", appNameOrPort, a.AgentName, a.WorkspaceName, a.Username)
|
||||
}
|
||||
|
||||
// ParseSubdomainAppURL parses an ApplicationURL from the given hostname. If
|
||||
// ParseSubdomainAppURL parses an ApplicationURL from the given subdomain. If
|
||||
// the subdomain is not a valid application URL hostname, returns a non-nil
|
||||
// error.
|
||||
// error. If the hostname is not a subdomain of the given base hostname, returns
|
||||
// a non-nil error.
|
||||
//
|
||||
// The base hostname should not include a scheme, leading asterisk or dot.
|
||||
//
|
||||
// Subdomains should be in the form:
|
||||
//
|
||||
// {PORT/APP_NAME}--{AGENT_NAME}--{WORKSPACE_NAME}--{USERNAME}
|
||||
// (eg. http://8080--main--dev--dean.hi.c8s.io)
|
||||
func ParseSubdomainAppURL(hostname string) (ApplicationURL, error) {
|
||||
subdomain, rest, err := SplitSubdomain(hostname)
|
||||
if err != nil {
|
||||
return ApplicationURL{}, xerrors.Errorf("split host domain %q: %w", hostname, err)
|
||||
}
|
||||
|
||||
// (eg. https://8080--main--dev--dean.hi.c8s.io)
|
||||
func ParseSubdomainAppURL(subdomain string) (ApplicationURL, error) {
|
||||
matches := appURL.FindAllStringSubmatch(subdomain, -1)
|
||||
if len(matches) == 0 {
|
||||
return ApplicationURL{}, xerrors.Errorf("invalid application url format: %q", subdomain)
|
||||
@ -82,7 +78,6 @@ func ParseSubdomainAppURL(hostname string) (ApplicationURL, error) {
|
||||
AgentName: matchGroup[appURL.SubexpIndex("AgentName")],
|
||||
WorkspaceName: matchGroup[appURL.SubexpIndex("WorkspaceName")],
|
||||
Username: matchGroup[appURL.SubexpIndex("Username")],
|
||||
BaseHostname: rest,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -98,3 +93,21 @@ func AppNameOrPort(val string) (string, uint16) {
|
||||
|
||||
return val, uint16(port)
|
||||
}
|
||||
|
||||
// HostnamesMatch returns true if the hostnames are equal, disregarding
|
||||
// capitalization, extra leading or trailing periods, and ports.
|
||||
func HostnamesMatch(a, b string) bool {
|
||||
a = strings.Trim(a, ".")
|
||||
b = strings.Trim(b, ".")
|
||||
|
||||
aHost, _, err := net.SplitHostPort(a)
|
||||
if err != nil {
|
||||
aHost = a
|
||||
}
|
||||
bHost, _, err := net.SplitHostPort(b)
|
||||
if err != nil {
|
||||
bHost = b
|
||||
}
|
||||
|
||||
return strings.EqualFold(aHost, bHost)
|
||||
}
|
||||
|
@ -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: "",
|
||||
},
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
@ -90,7 +77,7 @@ func TestApplicationURLString(t *testing.T) {
|
||||
{
|
||||
Name: "Empty",
|
||||
URL: httpapi.ApplicationURL{},
|
||||
Expected: "------.",
|
||||
Expected: "------",
|
||||
},
|
||||
{
|
||||
Name: "AppName",
|
||||
@ -100,9 +87,8 @@ func TestApplicationURLString(t *testing.T) {
|
||||
AgentName: "agent",
|
||||
WorkspaceName: "workspace",
|
||||
Username: "user",
|
||||
BaseHostname: "coder.com",
|
||||
},
|
||||
Expected: "app--agent--workspace--user.coder.com",
|
||||
Expected: "app--agent--workspace--user",
|
||||
},
|
||||
{
|
||||
Name: "Port",
|
||||
@ -112,9 +98,8 @@ func TestApplicationURLString(t *testing.T) {
|
||||
AgentName: "agent",
|
||||
WorkspaceName: "workspace",
|
||||
Username: "user",
|
||||
BaseHostname: "coder.com",
|
||||
},
|
||||
Expected: "8080--agent--workspace--user.coder.com",
|
||||
Expected: "8080--agent--workspace--user",
|
||||
},
|
||||
{
|
||||
Name: "Both",
|
||||
@ -124,10 +109,9 @@ func TestApplicationURLString(t *testing.T) {
|
||||
AgentName: "agent",
|
||||
WorkspaceName: "workspace",
|
||||
Username: "user",
|
||||
BaseHostname: "coder.com",
|
||||
},
|
||||
// Prioritizes port over app name.
|
||||
Expected: "8080--agent--workspace--user.coder.com",
|
||||
Expected: "8080--agent--workspace--user",
|
||||
},
|
||||
}
|
||||
|
||||
@ -145,93 +129,72 @@ func TestParseSubdomainAppURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := []struct {
|
||||
Name string
|
||||
Host string
|
||||
Subdomain string
|
||||
Expected httpapi.ApplicationURL
|
||||
ExpectedError string
|
||||
}{
|
||||
{
|
||||
Name: "Invalid_Split",
|
||||
Host: "com",
|
||||
Expected: httpapi.ApplicationURL{},
|
||||
ExpectedError: "no subdomain",
|
||||
},
|
||||
{
|
||||
Name: "Invalid_Empty",
|
||||
Host: "example.com",
|
||||
Subdomain: "test",
|
||||
Expected: httpapi.ApplicationURL{},
|
||||
ExpectedError: "invalid application url format",
|
||||
},
|
||||
{
|
||||
Name: "Invalid_Workspace.Agent--App",
|
||||
Host: "workspace.agent--app.coder.com",
|
||||
Subdomain: "workspace.agent--app",
|
||||
Expected: httpapi.ApplicationURL{},
|
||||
ExpectedError: "invalid application url format",
|
||||
},
|
||||
{
|
||||
Name: "Invalid_Workspace--App",
|
||||
Host: "workspace--app.coder.com",
|
||||
Subdomain: "workspace--app",
|
||||
Expected: httpapi.ApplicationURL{},
|
||||
ExpectedError: "invalid application url format",
|
||||
},
|
||||
{
|
||||
Name: "Invalid_App--Workspace--User",
|
||||
Host: "app--workspace--user.coder.com",
|
||||
Subdomain: "app--workspace--user",
|
||||
Expected: httpapi.ApplicationURL{},
|
||||
ExpectedError: "invalid application url format",
|
||||
},
|
||||
{
|
||||
Name: "Invalid_TooManyComponents",
|
||||
Host: "1--2--3--4--5.coder.com",
|
||||
Subdomain: "1--2--3--4--5",
|
||||
Expected: httpapi.ApplicationURL{},
|
||||
ExpectedError: "invalid application url format",
|
||||
},
|
||||
// Correct
|
||||
{
|
||||
Name: "AppName--Agent--Workspace--User",
|
||||
Host: "app--agent--workspace--user.coder.com",
|
||||
Name: "AppName--Agent--Workspace--User",
|
||||
Subdomain: "app--agent--workspace--user",
|
||||
Expected: httpapi.ApplicationURL{
|
||||
AppName: "app",
|
||||
Port: 0,
|
||||
AgentName: "agent",
|
||||
WorkspaceName: "workspace",
|
||||
Username: "user",
|
||||
BaseHostname: "coder.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Port--Agent--Workspace--User",
|
||||
Host: "8080--agent--workspace--user.coder.com",
|
||||
Name: "Port--Agent--Workspace--User",
|
||||
Subdomain: "8080--agent--workspace--user",
|
||||
Expected: httpapi.ApplicationURL{
|
||||
AppName: "",
|
||||
Port: 8080,
|
||||
AgentName: "agent",
|
||||
WorkspaceName: "workspace",
|
||||
Username: "user",
|
||||
BaseHostname: "coder.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "DeepSubdomain",
|
||||
Host: "app--agent--workspace--user.dev.dean-was-here.coder.com",
|
||||
Expected: httpapi.ApplicationURL{
|
||||
AppName: "app",
|
||||
Port: 0,
|
||||
AgentName: "agent",
|
||||
WorkspaceName: "workspace",
|
||||
Username: "user",
|
||||
BaseHostname: "dev.dean-was-here.coder.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "HyphenatedNames",
|
||||
Host: "app-name--agent-name--workspace-name--user-name.coder.com",
|
||||
Name: "HyphenatedNames",
|
||||
Subdomain: "app-name--agent-name--workspace-name--user-name",
|
||||
Expected: httpapi.ApplicationURL{
|
||||
AppName: "app-name",
|
||||
Port: 0,
|
||||
AgentName: "agent-name",
|
||||
WorkspaceName: "workspace-name",
|
||||
Username: "user-name",
|
||||
BaseHostname: "coder.com",
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -241,7 +204,7 @@ func TestParseSubdomainAppURL(t *testing.T) {
|
||||
t.Run(c.Name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, err := httpapi.ParseSubdomainAppURL(c.Host)
|
||||
app, err := httpapi.ParseSubdomainAppURL(c.Subdomain)
|
||||
if c.ExpectedError == "" {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, c.Expected, app, "expected app")
|
||||
|
Reference in New Issue
Block a user