mirror of
https://github.com/coder/coder.git
synced 2025-07-08 11:39:50 +00:00
I modified the proxy host cache we already had and were using for websocket csp headers to also include the wildcard app host, then used those for frame-src policies. I did not add frame-ancestors, since if I understand correctly, those would go on the app, and this middleware does not come into play there. Maybe we will want to add it on workspace apps like we do with cors, if we find apps are setting it to `none` or something. Closes https://github.com/coder/internal/issues/684
469 lines
10 KiB
Go
469 lines
10 KiB
Go
package appurl_test
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
|
|
)
|
|
|
|
func TestApplicationURLString(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCases := []struct {
|
|
Name string
|
|
URL appurl.ApplicationURL
|
|
Expected string
|
|
}{
|
|
{
|
|
Name: "Empty",
|
|
URL: appurl.ApplicationURL{},
|
|
Expected: "------",
|
|
},
|
|
{
|
|
Name: "AppName",
|
|
URL: appurl.ApplicationURL{
|
|
AppSlugOrPort: "app",
|
|
AgentName: "agent",
|
|
WorkspaceName: "workspace",
|
|
Username: "user",
|
|
},
|
|
Expected: "app--agent--workspace--user",
|
|
},
|
|
{
|
|
Name: "Port",
|
|
URL: appurl.ApplicationURL{
|
|
AppSlugOrPort: "8080",
|
|
AgentName: "agent",
|
|
WorkspaceName: "workspace",
|
|
Username: "user",
|
|
},
|
|
Expected: "8080--agent--workspace--user",
|
|
},
|
|
{
|
|
Name: "Prefix",
|
|
URL: appurl.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 appurl.ApplicationURL
|
|
ExpectedError string
|
|
}{
|
|
{
|
|
Name: "Invalid_Empty",
|
|
Subdomain: "test",
|
|
Expected: appurl.ApplicationURL{},
|
|
ExpectedError: "invalid application url format",
|
|
},
|
|
{
|
|
Name: "Invalid_Workspace.Agent--App",
|
|
Subdomain: "workspace.agent--app",
|
|
Expected: appurl.ApplicationURL{},
|
|
ExpectedError: "invalid application url format",
|
|
},
|
|
{
|
|
Name: "Invalid_Workspace--App",
|
|
Subdomain: "workspace--app",
|
|
Expected: appurl.ApplicationURL{},
|
|
ExpectedError: "invalid application url format",
|
|
},
|
|
{
|
|
Name: "Invalid_App--Workspace--User",
|
|
Subdomain: "app--workspace--user",
|
|
Expected: appurl.ApplicationURL{},
|
|
ExpectedError: "invalid application url format",
|
|
},
|
|
{
|
|
Name: "Invalid_TooManyComponents",
|
|
Subdomain: "1--2--3--4--5",
|
|
Expected: appurl.ApplicationURL{},
|
|
ExpectedError: "invalid application url format",
|
|
},
|
|
// Correct
|
|
{
|
|
Name: "AppName--Agent--Workspace--User",
|
|
Subdomain: "app--agent--workspace--user",
|
|
Expected: appurl.ApplicationURL{
|
|
AppSlugOrPort: "app",
|
|
AgentName: "agent",
|
|
WorkspaceName: "workspace",
|
|
Username: "user",
|
|
},
|
|
},
|
|
{
|
|
Name: "Port--Agent--Workspace--User",
|
|
Subdomain: "8080--agent--workspace--user",
|
|
Expected: appurl.ApplicationURL{
|
|
AppSlugOrPort: "8080",
|
|
AgentName: "agent",
|
|
WorkspaceName: "workspace",
|
|
Username: "user",
|
|
},
|
|
},
|
|
{
|
|
Name: "Port--Agent--Workspace--User",
|
|
Subdomain: "8080s--agent--workspace--user",
|
|
Expected: appurl.ApplicationURL{
|
|
AppSlugOrPort: "8080s",
|
|
AgentName: "agent",
|
|
WorkspaceName: "workspace",
|
|
Username: "user",
|
|
},
|
|
},
|
|
{
|
|
Name: "HyphenatedNames",
|
|
Subdomain: "app-slug--agent-name--workspace-name--user-name",
|
|
Expected: appurl.ApplicationURL{
|
|
AppSlugOrPort: "app-slug",
|
|
AgentName: "agent-name",
|
|
WorkspaceName: "workspace-name",
|
|
Username: "user-name",
|
|
},
|
|
},
|
|
{
|
|
Name: "Prefix",
|
|
Subdomain: "dean---was---here---app--agent--workspace--user",
|
|
Expected: appurl.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 := appurl.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_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_ContainsPort",
|
|
pattern: "*.hi.com:8080",
|
|
// Although a port is provided, the regex already matches any port.
|
|
// So it is ignored for validation purposes.
|
|
expectedRegex: `([^.]+)\.hi\.com`,
|
|
},
|
|
{
|
|
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 := appurl.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 := appurl.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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestConvertAppURLForCSP(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCases := []struct {
|
|
name string
|
|
host string
|
|
wildcard string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "Empty",
|
|
host: "example.com",
|
|
wildcard: "",
|
|
expected: "example.com",
|
|
},
|
|
{
|
|
name: "NoAsterisk",
|
|
host: "example.com",
|
|
wildcard: "coder.com",
|
|
expected: "coder.com",
|
|
},
|
|
{
|
|
name: "Asterisk",
|
|
host: "example.com",
|
|
wildcard: "*.coder.com",
|
|
expected: "*.coder.com",
|
|
},
|
|
{
|
|
name: "FirstPrefix",
|
|
host: "example.com",
|
|
wildcard: "*--apps.coder.com",
|
|
expected: "*.coder.com",
|
|
},
|
|
{
|
|
name: "FirstSuffix",
|
|
host: "example.com",
|
|
wildcard: "apps--*.coder.com",
|
|
expected: "*.coder.com",
|
|
},
|
|
{
|
|
name: "Middle",
|
|
host: "example.com",
|
|
wildcard: "apps.*.com",
|
|
expected: "example.com",
|
|
},
|
|
}
|
|
|
|
for _, c := range testCases {
|
|
c := c
|
|
t.Run(c.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
require.Equal(t, c.expected, appurl.ConvertAppHostForCSP(c.host, c.wildcard))
|
|
})
|
|
}
|
|
}
|