diff --git a/v3/UNRELEASED_CHANGELOG.md b/v3/UNRELEASED_CHANGELOG.md index 8e4648038..0c73f0762 100644 --- a/v3/UNRELEASED_CHANGELOG.md +++ b/v3/UNRELEASED_CHANGELOG.md @@ -17,6 +17,7 @@ After processing, the content will be moved to the main changelog and this file ## Added +- Browser URL Sanitisation by @leaanthony in [#4500](https://github.dev/wailsapp/wails/pull/4500). Based on [#4484](https://github.com/wailsapp/wails/pull/4484) by @APShenkin. ## Changed diff --git a/v3/pkg/application/messageprocessor_browser.go b/v3/pkg/application/messageprocessor_browser.go index 469369b13..46c428b38 100644 --- a/v3/pkg/application/messageprocessor_browser.go +++ b/v3/pkg/application/messageprocessor_browser.go @@ -31,14 +31,21 @@ func (m *MessageProcessor) processBrowserMethod(method int, rw http.ResponseWrit return } - err := browser.OpenURL(*url) + sanitizedURL, err := ValidateAndSanitizeURL(*url) + if err != nil { + m.Error("OpenURL: invalid URL - %s", err.Error()) + m.httpError(rw, fmt.Sprintf("Invalid URL: %s", err.Error()), err) + return + } + + err = browser.OpenURL(sanitizedURL) if err != nil { m.httpError(rw, "OpenURL failed:", err) return } m.ok(rw) - m.Info("Runtime call:", "method", "Browser."+browserMethods[method], "url", *url) + m.Info("Runtime call:", "method", "Browser."+browserMethods[method], "url", sanitizedURL) default: m.httpError(rw, "Invalid browser call:", fmt.Errorf("unknown method: %d", method)) return diff --git a/v3/pkg/application/urlvalidator.go b/v3/pkg/application/urlvalidator.go new file mode 100644 index 000000000..f9eecf0bb --- /dev/null +++ b/v3/pkg/application/urlvalidator.go @@ -0,0 +1,49 @@ +package application + +import ( + "errors" + "fmt" + "net/url" + "regexp" + "strings" +) + +func ValidateAndSanitizeURL(rawURL string) (string, error) { + if strings.Contains(rawURL, "\x00") { + return "", errors.New("null bytes not allowed in URL") + } + + for i, r := range rawURL { + if r < 32 && r != 9 { + return "", fmt.Errorf("control character at position %d not allowed", i) + } + } + + shellDangerous := `[;|` + "`" + `$\\<>*{}\[\]()~! \t\n\r]` + if matched, _ := regexp.MatchString(shellDangerous, rawURL); matched { + return "", errors.New("shell metacharacters not allowed") + } + + unicodeDangerous := "[\u0000-\u001F\u007F\u00A0\u1680\u2000-\u200F\u2028-\u202F\u205F\u3000\uFEFF\u200B-\u200D\u2060\u2061\u2062\u2063\u2064\u206A-\u206F\uFFF0-\uFFFF]" + if matched, _ := regexp.MatchString(unicodeDangerous, rawURL); matched { + return "", errors.New("dangerous unicode characters not allowed") + } + + parsedURL, err := url.Parse(rawURL) + if err != nil { + return "", fmt.Errorf("invalid URL format: %v", err) + } + + scheme := strings.ToLower(parsedURL.Scheme) + + if scheme == "javascript" || scheme == "data" || scheme == "file" || scheme == "ftp" || scheme == "" { + return "", errors.New("scheme not allowed") + } + + if (scheme == "http" || scheme == "https") && parsedURL.Host == "" { + return "", fmt.Errorf("missing host for %s URL", scheme) + } + + sanitizedURL := parsedURL.String() + return sanitizedURL, nil +} diff --git a/v3/pkg/application/urlvalidator_test.go b/v3/pkg/application/urlvalidator_test.go new file mode 100644 index 000000000..803098899 --- /dev/null +++ b/v3/pkg/application/urlvalidator_test.go @@ -0,0 +1,262 @@ +package application_test + +import ( + "strings" + "testing" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +func TestValidateURL(t *testing.T) { + testCases := []struct { + name string + url string + shouldErr bool + errMsg string + expected string + }{ + { + name: "valid https URL", + url: "https://www.example.com", + shouldErr: false, + expected: "https://www.example.com", + }, + { + name: "valid http URL", + url: "http://example.com", + shouldErr: false, + expected: "http://example.com", + }, + { + name: "URL with query parameters", + url: "https://example.com/search?q=cats&dogs", + shouldErr: false, + expected: "https://example.com/search?q=cats&dogs", + }, + { + name: "URL with port", + url: "https://example.com:8080/path", + shouldErr: false, + expected: "https://example.com:8080/path", + }, + { + name: "URL with fragment", + url: "https://example.com/page#section", + shouldErr: false, + expected: "https://example.com/page#section", + }, + { + name: "urlencode params", + url: "http://google.com/ ----browser-subprocess-path=C:\\\\Users\\\\Public\\\\test.bat", + shouldErr: true, + errMsg: "shell metacharacters", + }, + { + name: "javascript scheme", + url: "javascript:alert('XSS')", + shouldErr: true, + errMsg: "shell metacharacters", + }, + { + name: "data scheme", + url: "data:text/html,", + shouldErr: true, + errMsg: "shell metacharacters", + }, + { + name: "file scheme", + url: "file:///etc/passwd", + shouldErr: true, + errMsg: "scheme not allowed", + }, + { + name: "ftp scheme", + url: "ftp://ftp.example.com/file", + shouldErr: true, + errMsg: "scheme not allowed", + }, + { + name: "missing scheme", + url: "example.com", + shouldErr: true, + errMsg: "scheme not allowed", + }, + { + name: "empty string", + url: "", + shouldErr: true, + errMsg: "scheme not allowed", + }, + { + name: "null byte in URL", + url: "https://example.com\x00/malicious", + shouldErr: true, + errMsg: "null bytes not allowed", + }, + { + name: "control character", + url: "https://example.com\x01", + shouldErr: true, + errMsg: "control character", + }, + { + name: "shell injection with semicolon", + url: "https://example.com/;rm -rf /", + shouldErr: true, + errMsg: "shell metacharacters", + }, + { + name: "shell injection with pipe", + url: "https://example.com/|cat /etc/passwd", + shouldErr: true, + errMsg: "shell metacharacters", + }, + { + name: "shell injection with backtick", + url: "https://example.com/`whoami`", + shouldErr: true, + errMsg: "shell metacharacters", + }, + { + name: "shell injection with dollar", + url: "https://example.com/$(whoami)", + shouldErr: true, + errMsg: "shell metacharacters", + }, + { + name: "unicode null", + url: "https://example.com/\u0000", + shouldErr: true, + errMsg: "null bytes not allowed", + }, + { + name: "missing host for http", + url: "http:///path", + shouldErr: true, + errMsg: "missing host", + }, + { + name: "missing host for https", + url: "https:///path", + shouldErr: true, + errMsg: "missing host", + }, + { + name: "URL with newline", + url: "https://example.com/path\n/newline", + shouldErr: true, + errMsg: "control character", + }, + { + name: "URL with carriage return", + url: "https://example.com/path\r/return", + shouldErr: true, + errMsg: "control character", + }, + { + name: "URL with tab", + url: "https://example.com/path\t/tab", + shouldErr: true, + errMsg: "shell metacharacters", + }, + { + name: "URL with space in path", + url: "https://example.com/path with spaces", + shouldErr: true, + errMsg: "shell metacharacters", + }, + { + name: "URL with angle brackets", + url: "https://example.com/