[v3] Sanitise browser URL (#4500)

* Sanitise the browser urls!
This commit is contained in:
Lea Anthony 2025-08-12 21:20:36 +10:00 committed by GitHub
commit fb664a7c48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 321 additions and 2 deletions

View file

@ -17,6 +17,7 @@ After processing, the content will be moved to the main changelog and this file
## Added
<!-- New features, capabilities, or enhancements -->
- 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
<!-- Changes in existing functionality -->

View file

@ -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

View file

@ -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
}

View file

@ -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,<script>alert('XSS')</script>",
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/<script>",
shouldErr: true,
errMsg: "shell metacharacters",
},
{
name: "URL with parentheses",
url: "https://example.com/(test)",
shouldErr: true,
errMsg: "shell metacharacters",
},
{
name: "URL with curly braces",
url: "https://example.com/{test}",
shouldErr: true,
errMsg: "shell metacharacters",
},
{
name: "URL with square brackets",
url: "https://example.com/[test]",
shouldErr: true,
errMsg: "shell metacharacters",
},
{
name: "URL with asterisk",
url: "https://example.com/*",
shouldErr: true,
errMsg: "shell metacharacters",
},
{
name: "URL with tilde",
url: "https://example.com/~user",
shouldErr: true,
errMsg: "shell metacharacters",
},
{
name: "URL with exclamation",
url: "https://example.com/!test",
shouldErr: true,
errMsg: "shell metacharacters",
},
{
name: "zero-width space",
url: "https://example.com/\u200B",
shouldErr: true,
errMsg: "dangerous unicode",
},
{
name: "zero-width non-joiner",
url: "https://example.com/\u200C",
shouldErr: true,
errMsg: "dangerous unicode",
},
{
name: "zero-width joiner",
url: "https://example.com/\u200D",
shouldErr: true,
errMsg: "dangerous unicode",
},
{
name: "right-to-left override",
url: "https://example.com/\u202E",
shouldErr: true,
errMsg: "dangerous unicode",
},
{
name: "invalid URL format",
url: "ht!tp://[invalid",
shouldErr: true,
errMsg: "shell metacharacters",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result, err := application.ValidateAndSanitizeURL(tc.url)
if tc.shouldErr {
if err == nil {
t.Errorf("expected error containing '%s', got nil", tc.errMsg)
} else if !strings.Contains(err.Error(), tc.errMsg) {
t.Errorf("expected error containing '%s', got '%s'", tc.errMsg, err.Error())
}
} else {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if result != tc.expected {
t.Errorf("expected '%s', got '%s'", tc.expected, result)
}
}
})
}
}