diff --git a/x/config/config.go b/x/config/config.go index 11b71547..ac73f286 100644 --- a/x/config/config.go +++ b/x/config/config.go @@ -32,7 +32,7 @@ func parseConfigPart(oneDialerConfig string) (*url.URL, error) { if oneDialerConfig == "" { return nil, errors.New("empty config part") } - // Make it ":" it it's only "" to parse as a URL. + // Make it ":" if it's only "" to parse as a URL. if !strings.Contains(oneDialerConfig, ":") { oneDialerConfig += ":" } @@ -177,3 +177,43 @@ func NewPacketListener(transportConfig string) (transport.PacketListener, error) return nil, fmt.Errorf("config scheme '%v' is not supported", url.Scheme) } } + +func SanitizeConfig(transportConfig string) (string, error) { + // Do nothing if the config is empty + if transportConfig == "" { + return "", nil + } + // Split the string into parts + parts := strings.Split(transportConfig, "|") + + // Iterate through each part + for i, part := range parts { + u, err := parseConfigPart(part) + if err != nil { + return "", fmt.Errorf("failed to parse config part: %w", err) + } + scheme := strings.ToLower(u.Scheme) + switch scheme { + case "ss": + parts[i], _ = sanitizeShadowsocksURL(u) + case "socks5": + parts[i], _ = sanitizeSocks5URL(u) + case "override", "split", "tls", "tlsfrag": + // No sanitization needed + parts[i] = u.String() + default: + parts[i] = scheme + "://UNKNOWN" + } + } + // Join the parts back into a string + return strings.Join(parts, "|"), nil +} + +func sanitizeSocks5URL(u *url.URL) (string, error) { + const redactedPlaceholder = "REDACTED" + if u.User != nil { + u.User = url.User(redactedPlaceholder) + return u.String(), nil + } + return u.String(), nil +} diff --git a/x/config/config_test.go b/x/config/config_test.go new file mode 100644 index 00000000..01abfb17 --- /dev/null +++ b/x/config/config_test.go @@ -0,0 +1,104 @@ +package config + +import ( + "encoding/base64" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSanitizeConfig(t *testing.T) { + // Test that empty config is accepted. + _, err := SanitizeConfig("") + require.NoError(t, err) + + // Test that a invalid cypher is rejected. + sanitizedConfig, err := SanitizeConfig("split:5|ss://jhvdsjkfhvkhsadvf@example.com:1234?prefix=HTTP%2F1.1%20") + require.NoError(t, err) + require.Equal(t, "split:5|ss://ERROR", sanitizedConfig) + + // Test that a valid config is accepted and user info is redacted. + sanitizedConfig, err = SanitizeConfig("split:5|ss://Y2hhY2hhMjAtaWV0Zi1wb2x5MTMwNTpLeTUyN2duU3FEVFB3R0JpQ1RxUnlT@example.com:1234?prefix=HTTP%2F1.1%20") + require.NoError(t, err) + require.Equal(t, "split:5|ss://REDACTED@example.com:1234?prefix=HTTP%2F1.1%20", sanitizedConfig) + + // Test sanitizer with unknown transport. + sanitizedConfig, err = SanitizeConfig("split:5|vless://ac08785d-203d-4db4-915c-eb4e23435fd62@example.com:443?path=%2Fvless&security=tls&encryption=none&alpn=h2&host=sub.hello.com&fp=chrome&type=ws&sni=sub.hello.com#vless-ws-tls-cdn") + require.NoError(t, err) + require.Equal(t, "split:5|vless://UNKNOWN", sanitizedConfig) + + // Test sanitizer with transport that don't have user info. + sanitizedConfig, err = SanitizeConfig("split:5|tlsfrag:5") + require.NoError(t, err) + require.Equal(t, "split:5|tlsfrag:5", sanitizedConfig) + + // Test sanitization on an unknown transport. + sanitizedConfig, err = SanitizeConfig("transport://hjdbfjhbqfjheqrf") + require.NoError(t, err) + require.Equal(t, "transport://UNKNOWN", sanitizedConfig) + + // Test that an invalid config is rejected. + _, err = SanitizeConfig("::hghg") + require.Error(t, err) +} + +func TestShowsocksLagacyBase64URL(t *testing.T) { + encoded := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString([]byte("aes-256-gcm:1234567@example.com:1234?prefix=HTTP%2F1.1%20")) + u, err := parseConfigPart("ss://" + string(encoded) + "#outline-123") + require.NoError(t, err) + config, err := parseShadowsocksLegacyBase64URL(u) + require.Equal(t, "example.com:1234", config.serverAddress) + require.Equal(t, "HTTP/1.1 ", string(config.prefix)) + require.NoError(t, err) +} + +func TestParseShadowsocksURL(t *testing.T) { + encoded := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString([]byte("aes-256-gcm:1234567@example.com:1234?prefix=HTTP%2F1.1%20")) + u, err := parseConfigPart("ss://" + string(encoded) + "#outline-123") + require.NoError(t, err) + config, err := parseShadowsocksURL(u) + require.Equal(t, "example.com:1234", config.serverAddress) + require.Equal(t, "HTTP/1.1 ", string(config.prefix)) + require.NoError(t, err) + + encoded = base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString([]byte("aes-256-gcm:1234567")) + u, err = parseConfigPart("ss://" + string(encoded) + "@example.com:1234?prefix=HTTP%2F1.1%20" + "#outline-123") + require.NoError(t, err) + config, err = parseShadowsocksURL(u) + require.Equal(t, "example.com:1234", config.serverAddress) + require.Equal(t, "HTTP/1.1 ", string(config.prefix)) + require.NoError(t, err) +} + +func TestSocks5URLSanitization(t *testing.T) { + configString := "socks5://myuser:mypassword@192.168.1.100:1080" + sanitizedConfig, err := SanitizeConfig(configString) + require.NoError(t, err) + require.Equal(t, "socks5://REDACTED@192.168.1.100:1080", sanitizedConfig) +} + +func TestParseShadowsocksSIP002URLUnsuccessful(t *testing.T) { + encoded := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString([]byte("aes-256-gcm:1234567@example.com:1234?prefix=HTTP%2F1.1%20")) + u, err := parseConfigPart("ss://" + string(encoded) + "#outline-123") + require.NoError(t, err) + _, err = parseShadowsocksSIP002URL(u) + require.Error(t, err) +} + +func TestParseShadowsocksSIP002URLUnsupportedCypher(t *testing.T) { + configString := "ss://Y2hhY2hhMjAtaWV0Zi1wb2x5MTMwnTpLeTUyN2duU3FEVFB3R0JpQ1RxUnlT@example.com:1234?prefix=HTTP%2F1.1%20" + u, err := parseConfigPart(configString) + require.NoError(t, err) + _, err = parseShadowsocksSIP002URL(u) + require.Error(t, err) +} + +func TestParseShadowsocksSIP002URLSuccessful(t *testing.T) { + configString := "ss://Y2hhY2hhMjAtaWV0Zi1wb2x5MTMwNTpLeTUyN2duU3FEVFB3R0JpQ1RxUnlT@example.com:1234?prefix=HTTP%2F1.1%20" + u, err := parseConfigPart(configString) + require.NoError(t, err) + config, err := parseShadowsocksSIP002URL(u) + require.NoError(t, err) + require.Equal(t, "example.com:1234", config.serverAddress) + require.Equal(t, "HTTP/1.1 ", string(config.prefix)) +} diff --git a/x/config/shadowsocks.go b/x/config/shadowsocks.go index effdc546..e8620a23 100644 --- a/x/config/shadowsocks.go +++ b/x/config/shadowsocks.go @@ -72,6 +72,65 @@ type shadowsocksConfig struct { } func parseShadowsocksURL(url *url.URL) (*shadowsocksConfig, error) { + // attempt to decode as SIP002 URI format and + // fall back to legacy base64 format if decoding fails + config, err := parseShadowsocksSIP002URL(url) + if err == nil { + return config, nil + } + return parseShadowsocksLegacyBase64URL(url) +} + +// parseShadowsocksLegacyBase64URL parses URL based on legacy base64 format: +// https://shadowsocks.org/doc/configs.html#uri-and-qr-code +func parseShadowsocksLegacyBase64URL(url *url.URL) (*shadowsocksConfig, error) { + config := &shadowsocksConfig{} + if url.Host == "" { + return nil, errors.New("host not specified") + } + decoded, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(url.Host) + if err != nil { + // If decoding fails, return the original url with error + return nil, fmt.Errorf("failed to decode host string [%v]: %w", url.String(), err) + } + var fragment string + if url.Fragment != "" { + fragment = "#" + url.Fragment + } else { + fragment = "" + } + newURL, err := url.Parse(strings.ToLower(url.Scheme) + "://" + string(decoded) + fragment) + if err != nil { + // if parsing fails, return the original url with error + return nil, fmt.Errorf("failed to parse config part: %w", err) + } + // extend this check to see if decoded string contains contains other valid fields + if newURL.User == nil { + return nil, fmt.Errorf("invalid user info: %w", err) + } + cipherInfoBytes := newURL.User.String() + cipherName, secret, found := strings.Cut(string(cipherInfoBytes), ":") + if !found { + return nil, errors.New("invalid cipher info: no ':' separator") + } + config.serverAddress = newURL.Host + config.cryptoKey, err = shadowsocks.NewEncryptionKey(cipherName, secret) + if err != nil { + return nil, fmt.Errorf("failed to create cipher: %w", err) + } + prefixStr := newURL.Query().Get("prefix") + if len(prefixStr) > 0 { + config.prefix, err = parseStringPrefix(prefixStr) + if err != nil { + return nil, fmt.Errorf("failed to parse prefix: %w", err) + } + } + return config, nil +} + +// parseShadowsocksSIP002URL parses URL based on SIP002 format: +// https://shadowsocks.org/doc/sip002.html +func parseShadowsocksSIP002URL(url *url.URL) (*shadowsocksConfig, error) { config := &shadowsocksConfig{} if url.Host == "" { return nil, errors.New("host not specified") @@ -110,3 +169,12 @@ func parseStringPrefix(utf8Str string) ([]byte, error) { } return rawBytes, nil } + +func sanitizeShadowsocksURL(u *url.URL) (string, error) { + const redactedPlaceholder = "REDACTED" + config, err := parseShadowsocksURL(u) + if err != nil { + return "ss://ERROR", err + } + return "ss://" + redactedPlaceholder + "@" + config.serverAddress + "?prefix=" + url.PathEscape(string(config.prefix)), nil +} diff --git a/x/examples/test-connectivity/main.go b/x/examples/test-connectivity/main.go index ea14290d..8cc76c2c 100644 --- a/x/examples/test-connectivity/main.go +++ b/x/examples/test-connectivity/main.go @@ -45,7 +45,7 @@ type connectivityReport struct { Resolver string `json:"resolver"` Proto string `json:"proto"` // TODO(fortuna): add sanitized transport config. - // Transport string `json:"transport"` + Transport string `json:"transport"` // Observations Time time.Time `json:"time"` @@ -193,12 +193,16 @@ func main() { success = true } debugLog.Printf("Test %v %v result: %v", proto, resolverAddress, result) + sanitizedConfig, err := config.SanitizeConfig(*transportFlag) + if err != nil { + log.Fatalf("Failed to sanitize config: %v", err) + } var r report.Report = connectivityReport{ Resolver: resolverAddress, Proto: proto, Time: startTime.UTC().Truncate(time.Second), // TODO(fortuna): Add sanitized config: - // Transport: config.SanitizedConfig(*transportFlag), + Transport: sanitizedConfig, DurationMs: testDuration.Milliseconds(), Error: makeErrorRecord(result), }