Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sanitize transport config and include in report (Alt Approach) #152

Merged
merged 20 commits into from
Jan 13, 2024
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion x/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ func parseConfigPart(oneDialerConfig string) (*url.URL, error) {
if oneDialerConfig == "" {
return nil, errors.New("empty config part")
}
// Make it "<scheme>:" it it's only "<scheme>" to parse as a URL.
// Make it "<scheme>:" if it's only "<scheme>" to parse as a URL.
if !strings.Contains(oneDialerConfig, ":") {
oneDialerConfig += ":"
}
Expand Down Expand Up @@ -171,3 +171,46 @@ 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, "|")
amircybersec marked this conversation as resolved.
Show resolved Hide resolved

// 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", "vless":
amircybersec marked this conversation as resolved.
Show resolved Hide resolved
parts[i], _ = sanitizeURLGeneric(u)
case "split", "tls", "tlsfrag":
fortuna marked this conversation as resolved.
Show resolved Hide resolved
amircybersec marked this conversation as resolved.
Show resolved Hide resolved
// No sanitization needed
parts[i] = u.String()
default:
parts[i] = scheme + "://UNKNOWN"
fortuna marked this conversation as resolved.
Show resolved Hide resolved
}
}
// Join the parts back into a string
return strings.Join(parts, "|"), nil
}

func sanitizeURLGeneric(u *url.URL) (string, error) {
const redactedPlaceholder = "REDACTED"
if u.User != nil {
u.User = url.User(redactedPlaceholder)
return u.String(), nil
} else {
// If no user info is found, return the scheme and redacted placeholder
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why? Perhaps just drop the path or search params?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sanitizeURLGeneric is a bit conservative in terms of not leaking any sensitive info. Right now if no UserInfo is detected, I redact everything.

Perhaps, I should further dissect the URL and keep url.host if it has the ip:port format and keep path, query params and fragments.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see my other comment here

scheme := strings.ToLower(u.Scheme)
return scheme + ":" + redactedPlaceholder, nil
}
}
96 changes: 96 additions & 0 deletions x/config/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
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://[email protected]: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.
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://[email protected]:1234?prefix=HTTP%2F1.1%20", sanitizedConfig)

// Test that a valid config is accepted.
sanitizedConfig, err = SanitizeConfig("split:5|vless://[email protected]: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")
fortuna marked this conversation as resolved.
Show resolved Hide resolved
require.NoError(t, err)
require.Equal(t, "split:5|vless://[email protected]: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", sanitizedConfig)

// Test that a valid config is accepted.
sanitizedConfig, err = SanitizeConfig("split:5|tlsfrag:5")
require.NoError(t, err)
require.Equal(t, "split:5|tlsfrag:5", sanitizedConfig)

// Test that a valid config is accepted.
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:[email protected]: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:[email protected]: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)
}

func TestSocks5URLSanitization(t *testing.T) {
configString := "socks5://myuser:[email protected]:1080"
sanitizedConfig, err := SanitizeConfig(configString)
require.NoError(t, err)
require.Equal(t, "socks5://[email protected]:1080", sanitizedConfig)
}

func TestParseShadowsocksSIP002URLUnsuccessful(t *testing.T) {
encoded := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString([]byte("aes-256-gcm:[email protected]: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))
}
64 changes: 64 additions & 0 deletions x/config/shadowsocks.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,60 @@ type shadowsocksConfig struct {
}

func parseShadowsocksURL(url *url.URL) (*shadowsocksConfig, error) {
// attempt to decode as legacy base64 URI
config, err := parseShadowsocksLegacyBase64URL(url)
amircybersec marked this conversation as resolved.
Show resolved Hide resolved
if err == nil {
return config, nil
}
return parseShadowsocksSIP002URL(url)
}

func parseShadowsocksLegacyBase64URL(url *url.URL) (*shadowsocksConfig, error) {
amircybersec marked this conversation as resolved.
Show resolved Hide resolved
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
}

func parseShadowsocksSIP002URL(url *url.URL) (*shadowsocksConfig, error) {
amircybersec marked this conversation as resolved.
Show resolved Hide resolved
config := &shadowsocksConfig{}
if url.Host == "" {
return nil, errors.New("host not specified")
Expand Down Expand Up @@ -110,3 +164,13 @@ 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 {
//fmt.Printf("Failed to parse config: %v", err)
amircybersec marked this conversation as resolved.
Show resolved Hide resolved
return "ss://ERROR", err
}
return "ss://" + redactedPlaceholder + "@" + config.serverAddress + "?prefix=" + url.PathEscape(string(config.prefix)), nil
}
1 change: 1 addition & 0 deletions x/connectivity/connectivity.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ func TestConnectivityWithResolver(ctx context.Context, resolver Resolver, testDo
if err := dnsConn.WriteMsg(&dnsRequest); err != nil {
return makeConnectivityError("send", err), nil
}

if _, err := dnsConn.ReadMsg(); err != nil {
// An early close on the connection may cause a "unexpected EOF" error. That's an application-layer error,
// not triggered by a syscall error so we don't capture an error code.
Expand Down
8 changes: 6 additions & 2 deletions x/examples/test-connectivity/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,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"`
Expand Down Expand Up @@ -192,12 +192,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),
}
Expand Down