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/