diff --git a/frontend/src/lib/hooks/redirect-uri.ts b/frontend/src/lib/hooks/redirect-uri.ts index c4fc9a12..920a13a5 100644 --- a/frontend/src/lib/hooks/redirect-uri.ts +++ b/frontend/src/lib/hooks/redirect-uri.ts @@ -88,12 +88,32 @@ const getEffectivePort = (url: URL): string => { return "80"; }; +// https://www.geeksforgeeks.org/javascript/how-to-check-if-a-string-is-a-valid-ip-address-format-in-javascript +const isIP = (str: string): boolean => { + const ipv4 = + /^(\d{1,3}\.){3}\d{1,3}$/; + const ipv6 = + /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/; + return ipv4.test(str) || ipv6.test(str) || str.startsWith("["); +} + +const trimPeriod = (str: string): string => { + if(str.lastIndexOf('.') === (str.length - 1)){ + str = str.substring(0, str.length - 1); + } + return str +} + export const isTrustedDomain = ( url: URL, appUrl: URL, cookieDomain: string, subdomainsEnabled: boolean, ): boolean => { + if (isIP(url.hostname)) { + return false; + } + if (url.protocol != appUrl.protocol) { return false; } @@ -102,7 +122,7 @@ export const isTrustedDomain = ( return false; } - if (url.hostname == appUrl.hostname) { + if (trimPeriod(url.hostname) == trimPeriod(appUrl.hostname)) { return true; } @@ -110,9 +130,6 @@ export const isTrustedDomain = ( return false; } - if (url.hostname.endsWith("." + cookieDomain.toLowerCase())) { - return true; - } - - return false; + return trimPeriod(url.hostname).endsWith("." + cookieDomain.toLowerCase()) + || trimPeriod(url.hostname) == cookieDomain.toLowerCase(); }; diff --git a/internal/bootstrap/app_bootstrap.go b/internal/bootstrap/app_bootstrap.go index f82f8a12..5762b97a 100644 --- a/internal/bootstrap/app_bootstrap.go +++ b/internal/bootstrap/app_bootstrap.go @@ -88,17 +88,13 @@ func (app *BootstrapApp) Setup() error { app.log.App.Info().Msgf("Starting Tinyauth version: %s", model.Version) // get app url - if app.config.AppURL == "" { - return errors.New("app url cannot be empty, perhaps config loading failed") - } - - appUrl, err := url.Parse(app.config.AppURL) + appURL, err := utils.SafeParseAppURL(app.config.AppURL) if err != nil { return fmt.Errorf("failed to parse app url: %w", err) } - app.runtime.AppURL = strings.ToLower(appUrl.Scheme + "://" + appUrl.Host) + app.runtime.AppURL = appURL // validate session config if app.config.Auth.SessionMaxLifetime != 0 && app.config.Auth.SessionMaxLifetime < app.config.Auth.SessionExpiry { @@ -172,7 +168,13 @@ func (app *BootstrapApp) Setup() error { app.runtime.CookieDomain = cookieDomain // cookie names - app.runtime.UUID = utils.GenerateUUID(appUrl.Hostname()) + u, err := url.Parse(app.runtime.AppURL) + + if err != nil { + return fmt.Errorf("failed to parse app url: %w", err) + } + + app.runtime.UUID = utils.GenerateUUID(u.Hostname()) cookieId := strings.Split(app.runtime.UUID, "-")[0] // first 8 characters of the uuid should be good enough diff --git a/internal/controller/oauth_controller.go b/internal/controller/oauth_controller.go index ee0f92fa..afaa0120 100644 --- a/internal/controller/oauth_controller.go +++ b/internal/controller/oauth_controller.go @@ -351,7 +351,8 @@ func (controller *OAuthController) isRedirectSafe(redirectURI string) bool { return false } - if strings.HasSuffix(hostname, "."+strings.ToLower(controller.runtime.CookieDomain)) { + if strings.HasSuffix(hostname, "."+strings.ToLower(controller.runtime.CookieDomain)) || + hostname == controller.runtime.CookieDomain { return true } diff --git a/internal/utils/app_utils.go b/internal/utils/app_utils.go index 7d30e63a..65fa1999 100644 --- a/internal/utils/app_utils.go +++ b/internal/utils/app_utils.go @@ -7,10 +7,55 @@ import ( "strings" "github.com/weppos/publicsuffix-go/publicsuffix" + "golang.org/x/net/idna" ) -// GetCookieDomain parses the app url and returns the domain value to use for cookies. +var ( + ErrEmptyURL = fmt.Errorf("invalid url") +) + +func SafeParseAppURL(str string) (string, error) { + if strings.TrimSpace(str) == "" { + return "", ErrEmptyURL + } + + u, err := url.Parse(str) + + if err != nil { + return "", fmt.Errorf("invalid url: %w", err) + } + + if u.Host == "" || + u.Scheme != "http" && + u.Scheme != "https" { + return "", fmt.Errorf("invalid url, must be in format https(s)://host") + } + + hostname := strings.ToLower(u.Hostname()) + hostname = strings.TrimSuffix(hostname, ".") + + if netIP := net.ParseIP(hostname); netIP != nil { + return "", fmt.Errorf("ip addresses not allowed") + } + + hostname, err = idna.Lookup.ToASCII(hostname) + + if err != nil { + return "", fmt.Errorf("failed to convert hostname to ascii: %w", err) + } + + appURL := fmt.Sprintf("%s://%s", u.Scheme, hostname) + + if u.Port() != "" { + appURL += ":" + u.Port() + } + + return appURL, nil +} + +// GetCookieDomain parses the app URL and returns the domain value to use for cookies. // When auth for subdomains is enabled, it strips the leftmost label +// GetCookieDomain assumes the app URL is first parsed with SafeParseAppURL // (e.g. sub1.sub2.domain.com -> sub2.domain.com), otherwise it returns the full hostname. func GetCookieDomain(appUrl string, subdomainsEnabled bool) (string, error) { u, err := url.Parse(appUrl) @@ -21,10 +66,6 @@ func GetCookieDomain(appUrl string, subdomainsEnabled bool) (string, error) { hostname := strings.ToLower(u.Hostname()) - if netIP := net.ParseIP(hostname); netIP != nil { - return "", fmt.Errorf("ip addresses not allowed") - } - parts := strings.Split(hostname, ".") if len(parts) < 2 { diff --git a/internal/utils/app_utils_test.go b/internal/utils/app_utils_test.go index 296c168a..8c9e9bc5 100644 --- a/internal/utils/app_utils_test.go +++ b/internal/utils/app_utils_test.go @@ -7,7 +7,86 @@ import ( "github.com/tinyauthapp/tinyauth/internal/utils" ) -func TestGetRootDomain(t *testing.T) { +func TestSafeParseAPPURL(t *testing.T) { + // Normal app url + appURL := "http://sub.tinyauth.app" + expected := "http://sub.tinyauth.app" + result, err := utils.SafeParseAppURL(appURL) + assert.NoError(t, err) + assert.Equal(t, expected, result) + + // Strip path + appURL = "http://sub.tinyauth.app/path" + expected = "http://sub.tinyauth.app" + result, err = utils.SafeParseAppURL(appURL) + assert.NoError(t, err) + assert.Equal(t, expected, result) + + // Preserve port + appURL = "http://sub.tinyauth.app:8080" + expected = "http://sub.tinyauth.app:8080" + result, err = utils.SafeParseAppURL(appURL) + assert.NoError(t, err) + assert.Equal(t, expected, result) + + // Remove trailing dot + appURL = "http://sub.tinyauth.app." + expected = "http://sub.tinyauth.app" + result, err = utils.SafeParseAppURL(appURL) + assert.NoError(t, err) + assert.Equal(t, expected, result) + + // Convert to ascii + appURL = "http://bücher.example.com" + expected = "http://xn--bcher-kva.example.com" + result, err = utils.SafeParseAppURL(appURL) + assert.NoError(t, err) + assert.Equal(t, expected, result) + + // Lowercase + appURL = "HTTP://SUb.tinyAUth.aPP" + expected = "http://sub.tinyauth.app" + result, err = utils.SafeParseAppURL(appURL) + assert.NoError(t, err) + assert.Equal(t, expected, result) + + // Empty string + appURL = "" + _, err = utils.SafeParseAppURL(appURL) + assert.ErrorIs(t, err, utils.ErrEmptyURL) + + // Invalid URL + appURL = "invalidurl" + _, err = utils.SafeParseAppURL(appURL) + assert.ErrorContains(t, err, "invalid url") + + // Non http or https URL + appURL = "ftp://sub.tinyauth.app" + _, err = utils.SafeParseAppURL(appURL) + assert.ErrorContains(t, err, "invalid url") + + // Invalid punycode + appURL = "http://ab--cd.example.com" + _, err = utils.SafeParseAppURL(appURL) + assert.ErrorContains(t, err, "failed to convert hostname to ascii") + + // IP address + appURL = "http://10.10.10.10" + _, err = utils.SafeParseAppURL(appURL) + assert.ErrorContains(t, err, "ip addresses not allowed") + + // IPv6 address + appURL = "http://[::1]:8080" + _, err = utils.SafeParseAppURL(appURL) + assert.ErrorContains(t, err, "ip addresses not allowed") + + // Invalid URL + appURL = "://" + _, err = utils.SafeParseAppURL(appURL) + assert.ErrorContains(t, err, "invalid url") +} + +func TestGetCookieDomain(t *testing.T) { // Normal case domain := "http://sub.tinyauth.app" expected := "tinyauth.app" @@ -27,11 +106,6 @@ func TestGetRootDomain(t *testing.T) { _, err = utils.GetCookieDomain(domain, true) assert.EqualError(t, err, "invalid app url, must be in format subdomain.domain.tld or domain.tld") - // IP address - domain = "http://10.10.10.10" - _, err = utils.GetCookieDomain(domain, true) - assert.ErrorContains(t, err, "ip addresses not allowed") - // Invalid URL domain = "http://[::1]:namedport" _, err = utils.GetCookieDomain(domain, true) @@ -56,6 +130,11 @@ func TestGetRootDomain(t *testing.T) { _, err = utils.GetCookieDomain(domain, true) assert.ErrorContains(t, err, "domain in public suffix list, cannot set cookies") + // Domain managed by ICANN without subdomain + domain = "http://co.uk" + _, err = utils.GetCookieDomain(domain, true) + assert.ErrorContains(t, err, "domain in public suffix list, cannot set cookies") + // Domain without subdomain domain = "http://tinyauth.app" expected = "tinyauth.app"