chore: move app URL parsing to its own package (#11651)

* chore: move app url parsing to it's own package
This commit is contained in:
Steven Masley
2024-01-17 10:41:42 -06:00
committed by GitHub
parent 1aee8da4b6
commit b246f08d84
32 changed files with 165 additions and 133 deletions
-193
View File
@@ -1,193 +0,0 @@
package httpapi
import (
"fmt"
"net"
"regexp"
"strings"
"golang.org/x/xerrors"
)
var (
// Remove the "starts with" and "ends with" regex components.
nameRegex = strings.Trim(UsernameValidRegex.String(), "^$")
appURL = regexp.MustCompile(fmt.Sprintf(
// {PORT/APP_SLUG}--{AGENT_NAME}--{WORKSPACE_NAME}--{USERNAME}
`^(?P<AppSlug>%[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])?$`)
)
// ApplicationURL is a parsed application URL hostname.
type ApplicationURL struct {
Prefix string
AppSlugOrPort string
AgentName string
WorkspaceName string
Username string
}
// 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 {
var appURL strings.Builder
_, _ = appURL.WriteString(a.Prefix)
_, _ = appURL.WriteString(a.AppSlugOrPort)
_, _ = appURL.WriteString("--")
_, _ = appURL.WriteString(a.AgentName)
_, _ = appURL.WriteString("--")
_, _ = appURL.WriteString(a.WorkspaceName)
_, _ = appURL.WriteString("--")
_, _ = appURL.WriteString(a.Username)
return appURL.String()
}
// ParseSubdomainAppURL parses an ApplicationURL from the given subdomain. If
// the subdomain is not a valid application URL hostname, returns a non-nil
// error. If the hostname is not a subdomain of the given base hostname, returns
// a non-nil error.
//
// Subdomains should be in the form:
//
// ({PREFIX}---)?{PORT/APP_SLUG}--{AGENT_NAME}--{WORKSPACE_NAME}--{USERNAME}
// e.g.
// https://8080--main--dev--dean.hi.c8s.io
// https://app--main--dev--dean.hi.c8s.io
// https://prefix---8080--main--dev--dean.hi.c8s.io
// https://prefix---app--main--dev--dean.hi.c8s.io
//
// The optional prefix is permitted to allow customers to put additional URL at
// the beginning of their application URL (i.e. if they want to simulate
// different subdomains on the same app/port).
//
// Prefix requires three hyphens at the end to separate it from the rest of the
// URL so we can add/remove segments in the future from the parsing logic.
//
// TODO(dean): make the agent name optional when using the app slug. This will
// reduce the character count for app URLs.
func ParseSubdomainAppURL(subdomain string) (ApplicationURL, error) {
var (
prefixSegments = strings.Split(subdomain, "---")
prefix = ""
)
if len(prefixSegments) > 1 {
prefix = strings.Join(prefixSegments[:len(prefixSegments)-1], "---") + "---"
subdomain = prefixSegments[len(prefixSegments)-1]
}
matches := appURL.FindAllStringSubmatch(subdomain, -1)
if len(matches) == 0 {
return ApplicationURL{}, xerrors.Errorf("invalid application url format: %q", subdomain)
}
matchGroup := matches[0]
return ApplicationURL{
Prefix: prefix,
AppSlugOrPort: matchGroup[appURL.SubexpIndex("AppSlug")],
AgentName: matchGroup[appURL.SubexpIndex("AgentName")],
WorkspaceName: matchGroup[appURL.SubexpIndex("WorkspaceName")],
Username: matchGroup[appURL.SubexpIndex("Username")],
}, nil
}
// 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)
}
// 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, so
// we strip the asterisk (which is only on the first one).
label = strings.TrimPrefix(label, "*")
// Put an "a" at the start to stand in for the asterisk in the regex
// test below. This makes `*.coder.com` become `a.coder.com` and
// `*--prod.coder.com` become `a--prod.coder.com`.
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
}
-400
View File
@@ -1,400 +0,0 @@
package httpapi_test
import (
"fmt"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/httpapi"
)
func TestApplicationURLString(t *testing.T) {
t.Parallel()
testCases := []struct {
Name string
URL httpapi.ApplicationURL
Expected string
}{
{
Name: "Empty",
URL: httpapi.ApplicationURL{},
Expected: "------",
},
{
Name: "AppName",
URL: httpapi.ApplicationURL{
AppSlugOrPort: "app",
AgentName: "agent",
WorkspaceName: "workspace",
Username: "user",
},
Expected: "app--agent--workspace--user",
},
{
Name: "Port",
URL: httpapi.ApplicationURL{
AppSlugOrPort: "8080",
AgentName: "agent",
WorkspaceName: "workspace",
Username: "user",
},
Expected: "8080--agent--workspace--user",
},
{
Name: "Prefix",
URL: httpapi.ApplicationURL{
Prefix: "yolo---",
AppSlugOrPort: "app",
AgentName: "agent",
WorkspaceName: "workspace",
Username: "user",
},
Expected: "yolo---app--agent--workspace--user",
},
}
for _, c := range testCases {
c := c
t.Run(c.Name, func(t *testing.T) {
t.Parallel()
require.Equal(t, c.Expected, c.URL.String())
})
}
}
func TestParseSubdomainAppURL(t *testing.T) {
t.Parallel()
testCases := []struct {
Name string
Subdomain string
Expected httpapi.ApplicationURL
ExpectedError string
}{
{
Name: "Invalid_Empty",
Subdomain: "test",
Expected: httpapi.ApplicationURL{},
ExpectedError: "invalid application url format",
},
{
Name: "Invalid_Workspace.Agent--App",
Subdomain: "workspace.agent--app",
Expected: httpapi.ApplicationURL{},
ExpectedError: "invalid application url format",
},
{
Name: "Invalid_Workspace--App",
Subdomain: "workspace--app",
Expected: httpapi.ApplicationURL{},
ExpectedError: "invalid application url format",
},
{
Name: "Invalid_App--Workspace--User",
Subdomain: "app--workspace--user",
Expected: httpapi.ApplicationURL{},
ExpectedError: "invalid application url format",
},
{
Name: "Invalid_TooManyComponents",
Subdomain: "1--2--3--4--5",
Expected: httpapi.ApplicationURL{},
ExpectedError: "invalid application url format",
},
// Correct
{
Name: "AppName--Agent--Workspace--User",
Subdomain: "app--agent--workspace--user",
Expected: httpapi.ApplicationURL{
AppSlugOrPort: "app",
AgentName: "agent",
WorkspaceName: "workspace",
Username: "user",
},
},
{
Name: "Port--Agent--Workspace--User",
Subdomain: "8080--agent--workspace--user",
Expected: httpapi.ApplicationURL{
AppSlugOrPort: "8080",
AgentName: "agent",
WorkspaceName: "workspace",
Username: "user",
},
},
{
Name: "HyphenatedNames",
Subdomain: "app-slug--agent-name--workspace-name--user-name",
Expected: httpapi.ApplicationURL{
AppSlugOrPort: "app-slug",
AgentName: "agent-name",
WorkspaceName: "workspace-name",
Username: "user-name",
},
},
{
Name: "Prefix",
Subdomain: "dean---was---here---app--agent--workspace--user",
Expected: httpapi.ApplicationURL{
Prefix: "dean---was---here---",
AppSlugOrPort: "app",
AgentName: "agent",
WorkspaceName: "workspace",
Username: "user",
},
},
}
for _, c := range testCases {
c := c
t.Run(c.Name, func(t *testing.T) {
t.Parallel()
app, err := httpapi.ParseSubdomainAppURL(c.Subdomain)
if c.ExpectedError == "" {
require.NoError(t, err)
require.Equal(t, c.Expected, app, "expected app")
} else {
require.ErrorContains(t, err, c.ExpectedError, "expected error")
}
})
}
}
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)
}
})
}
}