From 4fe5ae944724c611c4bbaee99515a91bb4189f5e Mon Sep 17 00:00:00 2001 From: eduard256 Date: Sun, 22 Mar 2026 17:13:38 +0000 Subject: [PATCH] URL-encode credentials with special characters in stream URLs Passwords containing @, #, :, ?, /, %, &, space and other special characters broke URL parsing, causing streams to not be detected. Replaced fmt.Sprintf string concatenation with url.URL struct for building RTSP/HTTP URLs. Credentials in userinfo are now handled via url.UserPassword() which encodes special chars automatically. Split replacePlaceholders into two phases: - Phase 1: safe placeholders (channel, width, IP, port) - Phase 2: credential placeholders with context-aware encoding: - Query string: url.Values.Set + Encode (auto percent-encoding) - Path segments: url.PathEscape Normal passwords (letters, digits, -._~) produce identical URLs as before -- encoding is a no-op for safe characters. Fixes #10 --- internal/camera/stream/builder.go | 255 ++++++--- internal/camera/stream/special_chars_test.go | 559 +++++++++++++++++++ 2 files changed, 726 insertions(+), 88 deletions(-) create mode 100644 internal/camera/stream/special_chars_test.go diff --git a/internal/camera/stream/builder.go b/internal/camera/stream/builder.go index 9307c14..94749ff 100644 --- a/internal/camera/stream/builder.go +++ b/internal/camera/stream/builder.go @@ -4,7 +4,6 @@ import ( "encoding/base64" "fmt" "net/url" - "regexp" "strconv" "strings" @@ -94,62 +93,52 @@ func (b *Builder) BuildURL(entry models.CameraEntry, ctx BuildContext) string { ctx.Protocol = entry.Protocol } - // Replace placeholders in URL path + // Replace placeholders in URL path (credentials are handled separately + // to ensure proper encoding depending on their position in the URL). path := b.replacePlaceholders(entry.URL, ctx) b.logger.Debug("placeholders replaced", "original", entry.URL, "after_replacement", path) - // Build the complete URL + // Build the complete URL using url.URL struct for correct encoding var fullURL string // Check if the URL already contains authentication parameters hasAuthInURL := b.hasAuthenticationParams(path) b.logger.Debug("auth params detection", "has_auth_in_url", hasAuthInURL, "path", path) + // Determine host string (omit default port for cleaner URLs) + host := b.buildHost(ctx.IP, ctx.Port, ctx.Protocol) + + // Split path and query for url.URL (it expects them separately) + pathPart, queryPart := b.splitPathQuery(path) + + // Ensure path starts with exactly one slash + if !strings.HasPrefix(pathPart, "/") { + pathPart = "/" + pathPart + } + + u := &url.URL{ + Scheme: ctx.Protocol, + Host: host, + Path: pathPart, + RawQuery: queryPart, + } + switch ctx.Protocol { - case "rtsp": + case "rtsp", "rtsps": if ctx.Username != "" && ctx.Password != "" && !hasAuthInURL { - // Standard ports can be omitted - if ctx.Port == 554 { - fullURL = fmt.Sprintf("rtsp://%s:%s@%s/%s", - ctx.Username, ctx.Password, ctx.IP, path) - } else { - fullURL = fmt.Sprintf("rtsp://%s:%s@%s:%d/%s", - ctx.Username, ctx.Password, ctx.IP, ctx.Port, path) - } - } else { - if ctx.Port == 554 { - fullURL = fmt.Sprintf("rtsp://%s/%s", ctx.IP, path) - } else { - fullURL = fmt.Sprintf("rtsp://%s:%d/%s", ctx.IP, ctx.Port, path) - } + u.User = url.UserPassword(ctx.Username, ctx.Password) } case "http", "https": - // For HTTP, check if auth should be in URL or parameters - if ctx.Username != "" && ctx.Password != "" && !hasAuthInURL { - // Don't put auth in URL for HTTP, will use Basic Auth header - if (ctx.Protocol == "http" && ctx.Port == 80) || - (ctx.Protocol == "https" && ctx.Port == 443) { - fullURL = fmt.Sprintf("%s://%s/%s", ctx.Protocol, ctx.IP, path) - } else { - fullURL = fmt.Sprintf("%s://%s:%d/%s", ctx.Protocol, ctx.IP, ctx.Port, path) - } - } else { - if (ctx.Protocol == "http" && ctx.Port == 80) || - (ctx.Protocol == "https" && ctx.Port == 443) { - fullURL = fmt.Sprintf("%s://%s/%s", ctx.Protocol, ctx.IP, path) - } else { - fullURL = fmt.Sprintf("%s://%s:%d/%s", ctx.Protocol, ctx.IP, ctx.Port, path) - } - } + // For HTTP, credentials are NOT embedded in the URL by BuildURL. + // BuildURLsFromEntry handles auth variants (userinfo, query params, etc.) + // separately with url.UserPassword for proper encoding. default: - // Generic URL construction - fullURL = fmt.Sprintf("%s://%s:%d/%s", ctx.Protocol, ctx.IP, ctx.Port, path) + // Generic: no credentials in URL } - // Clean up double slashes (except after protocol://) - fullURL = b.cleanURL(fullURL) + fullURL = u.String() b.logger.Debug("BuildURL complete", "final_url", fullURL, @@ -162,23 +151,42 @@ func (b *Builder) BuildURL(entry models.CameraEntry, ctx BuildContext) string { return fullURL } -// replacePlaceholders replaces all placeholders in the URL +// credentialPlaceholders lists all placeholder strings that represent +// username or password values. These must NOT be replaced via simple string +// substitution because they require context-aware encoding (different for +// query parameters, path segments, and userinfo). +var credentialPlaceholders = []string{ + "[USERNAME]", "[username]", "[USER]", "[user]", + "[PASSWORD]", "[password]", "[PASWORD]", "[pasword]", + "[PASS]", "[pass]", "[PWD]", "[pwd]", +} + +// replacePlaceholders replaces all placeholders in the URL. +// +// Credential placeholders ([USERNAME], [PASSWORD], etc.) are handled in two +// phases to ensure correct encoding: +// 1. Non-credential placeholders (channel, resolution, IP, etc.) are replaced +// first — these contain only safe characters. +// 2. Credential placeholders are then replaced with proper encoding: +// - In query strings: via url.Values.Set + Encode (automatic encoding) +// - In path segments: via url.PathEscape func (b *Builder) replacePlaceholders(urlPath string, ctx BuildContext) string { result := urlPath - // Generate base64 auth for [AUTH] placeholder + // Generate base64 auth for [AUTH] placeholder (already safe — base64 has no + // characters that need URL encoding) auth := "" if ctx.Username != "" && ctx.Password != "" { auth = base64.StdEncoding.EncodeToString([]byte(ctx.Username + ":" + ctx.Password)) } - // Common placeholders - replacements := map[string]string{ + // Phase 1: Replace non-credential placeholders (all values are safe strings) + safeReplacements := map[string]string{ "[CHANNEL]": strconv.Itoa(ctx.Channel), "[channel]": strconv.Itoa(ctx.Channel), - "[CHANNEL+1]": strconv.Itoa(ctx.Channel + 1), // For Hikvision-style channels (101, 201, 301...) + "[CHANNEL+1]": strconv.Itoa(ctx.Channel + 1), "[channel+1]": strconv.Itoa(ctx.Channel + 1), - "{CHANNEL}": strconv.Itoa(ctx.Channel), // BUBBLE protocol uses {channel} + "{CHANNEL}": strconv.Itoa(ctx.Channel), "{channel}": strconv.Itoa(ctx.Channel), "{CHANNEL+1}": strconv.Itoa(ctx.Channel + 1), "{channel+1}": strconv.Itoa(ctx.Channel + 1), @@ -186,43 +194,35 @@ func (b *Builder) replacePlaceholders(urlPath string, ctx BuildContext) string { "[width]": strconv.Itoa(ctx.Width), "[HEIGHT]": strconv.Itoa(ctx.Height), "[height]": strconv.Itoa(ctx.Height), - "[USERNAME]": ctx.Username, - "[username]": ctx.Username, - "[PASSWORD]": ctx.Password, - "[password]": ctx.Password, - "[PASWORD]": ctx.Password, // Handle typo in database - "[pasword]": ctx.Password, - "[USER]": ctx.Username, - "[user]": ctx.Username, - "[PASS]": ctx.Password, - "[pass]": ctx.Password, - "[PWD]": ctx.Password, - "[pwd]": ctx.Password, "[IP]": ctx.IP, "[ip]": ctx.IP, "[PORT]": strconv.Itoa(ctx.Port), "[port]": strconv.Itoa(ctx.Port), - "[AUTH]": auth, // base64(username:password) for basic auth + "[AUTH]": auth, "[auth]": auth, - "[TOKEN]": "", // Empty for now + "[TOKEN]": "", "[token]": "", } - // Replace all placeholders - for placeholder, value := range replacements { + for placeholder, value := range safeReplacements { result = strings.ReplaceAll(result, placeholder, value) } - // Handle query parameter placeholders (only for auth params) - result = b.replaceQueryParams(result, ctx) + // Phase 2: Replace credential placeholders with proper encoding. + // First handle query parameters (via url.Values for safe encoding), + // then handle any remaining credential placeholders in the path. + result = b.replaceQueryCredentials(result, ctx) + result = b.replacePathCredentials(result, ctx) return result } - -// replaceQueryParams handles query parameter replacements -func (b *Builder) replaceQueryParams(urlPath string, ctx BuildContext) string { - // Parse URL to handle query params +// replaceQueryCredentials handles credential replacement in query parameters. +// It parses the query string while credential placeholders are still intact +// (safe ASCII strings like "[PASSWORD]"), replaces them with real values via +// url.Values.Set, and re-encodes. This ensures special characters in passwords +// are always properly percent-encoded. +func (b *Builder) replaceQueryCredentials(urlPath string, ctx BuildContext) string { parts := strings.SplitN(urlPath, "?", 2) if len(parts) < 2 { return urlPath @@ -231,30 +231,103 @@ func (b *Builder) replaceQueryParams(urlPath string, ctx BuildContext) string { basePath := parts[0] queryString := parts[1] - // Parse query parameters + // Parse the query string — placeholders like [PASSWORD] are safe to parse + // because they contain no special URL characters. params, err := url.ParseQuery(queryString) if err != nil { return urlPath } - // ONLY replace authentication parameters - // DO NOT replace channel, width, height - they should stay as-is from URL patterns - for key := range params { - lowerKey := strings.ToLower(key) + // Username placeholder values that should be replaced + usernamePlaceholders := map[string]bool{ + "[USERNAME]": true, "[username]": true, + "[USER]": true, "[user]": true, + } + // Password placeholder values that should be replaced + passwordPlaceholders := map[string]bool{ + "[PASSWORD]": true, "[password]": true, + "[PASWORD]": true, "[pasword]": true, + "[PASS]": true, "[pass]": true, + "[PWD]": true, "[pwd]": true, + } + + changed := false + for key, values := range params { + for _, val := range values { + if usernamePlaceholders[val] { + params.Set(key, ctx.Username) + changed = true + } else if passwordPlaceholders[val] { + params.Set(key, ctx.Password) + changed = true + } + } + + // Also handle auth-named keys whose values are still placeholders + // or already contain the raw value from a previous step. + // This covers patterns like "?user=admin&pwd=12345" that come from + // replaceQueryParams in the old code. + lowerKey := strings.ToLower(key) switch lowerKey { case "user", "username", "usr", "loginuse": - params.Set(key, ctx.Username) + if params.Get(key) == "" || isCredentialPlaceholder(params.Get(key)) { + params.Set(key, ctx.Username) + changed = true + } case "password", "pass", "pwd", "loginpas", "passwd": - params.Set(key, ctx.Password) - // Removed: channel, width, height replacements - they were breaking working URLs + if params.Get(key) == "" || isCredentialPlaceholder(params.Get(key)) { + params.Set(key, ctx.Password) + changed = true + } } } - // Rebuild URL + if !changed { + return urlPath + } + + // params.Encode() automatically percent-encodes all values return basePath + "?" + params.Encode() } +// replacePathCredentials replaces any remaining credential placeholders in the +// path portion of the URL using url.PathEscape for safe encoding. +func (b *Builder) replacePathCredentials(urlPath string, ctx BuildContext) string { + // Map of credential placeholders to their escaped values for use in paths + pathReplacements := map[string]string{ + "[USERNAME]": url.PathEscape(ctx.Username), + "[username]": url.PathEscape(ctx.Username), + "[USER]": url.PathEscape(ctx.Username), + "[user]": url.PathEscape(ctx.Username), + "[PASSWORD]": url.PathEscape(ctx.Password), + "[password]": url.PathEscape(ctx.Password), + "[PASWORD]": url.PathEscape(ctx.Password), + "[pasword]": url.PathEscape(ctx.Password), + "[PASS]": url.PathEscape(ctx.Password), + "[pass]": url.PathEscape(ctx.Password), + "[PWD]": url.PathEscape(ctx.Password), + "[pwd]": url.PathEscape(ctx.Password), + } + + for placeholder, value := range pathReplacements { + urlPath = strings.ReplaceAll(urlPath, placeholder, value) + } + + return urlPath +} + +// isCredentialPlaceholder checks if a string is one of the known credential +// placeholder tokens. +func isCredentialPlaceholder(s string) bool { + for _, p := range credentialPlaceholders { + if s == p { + return true + } + } + return false +} + // hasAuthenticationParams checks if URL contains auth parameters func (b *Builder) hasAuthenticationParams(urlPath string) bool { @@ -273,21 +346,27 @@ func (b *Builder) hasAuthenticationParams(urlPath string) bool { return false } -// cleanURL cleans up the URL -func (b *Builder) cleanURL(fullURL string) string { - // Remove double slashes except after protocol:// - protocolEnd := strings.Index(fullURL, "://") - if protocolEnd > 0 { - protocol := fullURL[:protocolEnd+3] - rest := fullURL[protocolEnd+3:] +// buildHost returns the host:port string, omitting the port when it matches +// the default for the given protocol. +func (b *Builder) buildHost(ip string, port int, protocol string) string { + isDefault := (protocol == "http" && port == 80) || + (protocol == "https" && port == 443) || + (protocol == "rtsp" && port == 554) || + (protocol == "rtsps" && port == 322) - // Replace multiple slashes with single slash - rest = regexp.MustCompile(`/{2,}`).ReplaceAllString(rest, "/") - - return protocol + rest + if isDefault || port == 0 { + return ip } + return fmt.Sprintf("%s:%d", ip, port) +} - return fullURL +// splitPathQuery splits a path string into path and raw query components. +// The input may contain "?" separating the path from the query string. +func (b *Builder) splitPathQuery(path string) (string, string) { + if idx := strings.IndexByte(path, '?'); idx >= 0 { + return path[:idx], path[idx+1:] + } + return path, "" } // BuildURLsFromEntry generates all possible URLs from a camera entry diff --git a/internal/camera/stream/special_chars_test.go b/internal/camera/stream/special_chars_test.go new file mode 100644 index 0000000..a32e81d --- /dev/null +++ b/internal/camera/stream/special_chars_test.go @@ -0,0 +1,559 @@ +package stream + +import ( + "net/url" + "strings" + "testing" + + "github.com/eduard256/Strix/internal/models" +) + +// Passwords with various special characters that real users might use. +// Each one exercises a different URL-parsing edge case. +var specialPasswords = []struct { + name string + password string + breaking string // which URL component this character breaks without escaping +}{ + {"at sign", "p@ssword", "userinfo delimiter — splits user:pass from host"}, + {"colon", "p:ssword", "userinfo separator — splits username from password"}, + {"hash", "p#ssword", "fragment delimiter — truncates everything after it"}, + {"ampersand", "p&ssword", "query param separator — splits password into two params"}, + {"equals", "p=ssword", "query value delimiter — corrupts key=value parsing"}, + {"question mark", "p?ssword", "query start — creates phantom query string"}, + {"slash", "p/ssword", "path separator — changes URL path structure"}, + {"percent", "p%ssword", "escape prefix — creates invalid percent-encoding"}, + {"space", "p ssword", "whitespace — breaks URL parsing entirely"}, + {"plus", "p+ssword", "query space encoding — decoded as space in query strings"}, + {"dollar", "p$ssword", "shell/URI special character"}, + {"exclamation", "p!ssword", "sub-delimiter in RFC 3986"}, + {"mixed special", "p@ss:w#rd$1&2", "multiple special characters combined"}, + {"all dangerous", "P@:?#&=+$ !", "all URL-breaking characters at once"}, + {"url-like", "http://evil", "password that looks like a URL"}, + {"chinese", "密码test", "unicode characters in password"}, +} + +// --------------------------------------------------------------------------- +// RTSP URL tests +// --------------------------------------------------------------------------- + +// TestRTSP_SpecialCharsInPassword_URLMustBeParseable verifies that RTSP URLs +// built with special-character passwords can be parsed back by url.Parse +// without losing or corrupting the host, scheme, or userinfo. +func TestRTSP_SpecialCharsInPassword_URLMustBeParseable(t *testing.T) { + logger := &mockLogger{} + builder := NewBuilder([]string{}, logger) + + entry := models.CameraEntry{ + Type: "FFMPEG", + Protocol: "rtsp", + Port: 554, + URL: "/live/main", + } + + for _, sp := range specialPasswords { + t.Run(sp.name, func(t *testing.T) { + ctx := BuildContext{ + IP: "192.168.1.100", + Username: "admin", + Password: sp.password, + Port: 554, + } + + urls := builder.BuildURLsFromEntry(entry, ctx) + if len(urls) == 0 { + t.Fatal("no URLs generated") + } + + for i, rawURL := range urls { + u, err := url.Parse(rawURL) + if err != nil { + t.Errorf("[%d] url.Parse failed: %v\n raw URL: %s", i, err, rawURL) + continue + } + + // Scheme must be rtsp + if u.Scheme != "rtsp" { + t.Errorf("[%d] wrong scheme %q, want \"rtsp\"\n raw URL: %s", i, u.Scheme, rawURL) + } + + // Host must be the camera IP, not garbage from a mis-parsed password + host := u.Hostname() + if host != "192.168.1.100" { + t.Errorf("[%d] wrong host %q, want \"192.168.1.100\"\n raw URL: %s", i, host, rawURL) + } + + // Password must round-trip correctly + if u.User != nil { + got, ok := u.User.Password() + if !ok { + t.Errorf("[%d] password not present in parsed URL\n raw URL: %s", i, rawURL) + } else if got != sp.password { + t.Errorf("[%d] password mismatch: got %q, want %q\n raw URL: %s", i, got, sp.password, rawURL) + } + } + + // Path must start with /live/main + if !strings.HasPrefix(u.Path, "/live/main") { + t.Errorf("[%d] wrong path %q, want prefix \"/live/main\"\n raw URL: %s", i, u.Path, rawURL) + } + + // Fragment must be empty (# in password must not leak) + if u.Fragment != "" { + t.Errorf("[%d] unexpected fragment %q — '#' in password leaked\n raw URL: %s", i, u.Fragment, rawURL) + } + } + }) + } +} + +// TestRTSP_SpecialCharsInPassword_CountUnchanged verifies that the number +// of generated URLs does not change based on password content. +// A simple password and a complex one should produce the same URL count. +func TestRTSP_SpecialCharsInPassword_CountUnchanged(t *testing.T) { + logger := &mockLogger{} + builder := NewBuilder([]string{}, logger) + + entry := models.CameraEntry{ + Type: "FFMPEG", + Protocol: "rtsp", + Port: 554, + URL: "/stream1", + } + + // Baseline: simple password + baseCtx := BuildContext{ + IP: "192.168.1.100", + Username: "admin", + Password: "simple123", + Port: 554, + } + baseURLs := builder.BuildURLsFromEntry(entry, baseCtx) + baseCount := len(baseURLs) + + for _, sp := range specialPasswords { + t.Run(sp.name, func(t *testing.T) { + ctx := BuildContext{ + IP: "192.168.1.100", + Username: "admin", + Password: sp.password, + Port: 554, + } + + urls := builder.BuildURLsFromEntry(entry, ctx) + if len(urls) != baseCount { + t.Errorf("URL count changed: simple password produces %d, %q produces %d", + baseCount, sp.password, len(urls)) + t.Logf(" simple URLs: %v", baseURLs) + t.Logf(" special URLs: %v", urls) + } + }) + } +} + +// TestRTSP_NormalPassword_NoChange ensures that encoding does not alter URLs +// when the password contains only safe characters (letters, digits, - . _ ~). +func TestRTSP_NormalPassword_NoChange(t *testing.T) { + logger := &mockLogger{} + builder := NewBuilder([]string{}, logger) + + entry := models.CameraEntry{ + Type: "FFMPEG", + Protocol: "rtsp", + Port: 554, + URL: "/Streaming/Channels/101", + } + + normalPasswords := []string{ + "admin", + "Admin123", + "test-password", + "hello_world", + "dots.in.password", + "tilde~ok", + "UPPERCASE", + "1234567890", + } + + for _, pass := range normalPasswords { + t.Run(pass, func(t *testing.T) { + ctx := BuildContext{ + IP: "192.168.1.100", + Username: "admin", + Password: pass, + Port: 554, + } + + urls := builder.BuildURLsFromEntry(entry, ctx) + if len(urls) == 0 { + t.Fatal("no URLs generated") + } + + for _, rawURL := range urls { + // Normal passwords must NOT contain any percent-encoding + // because all their characters are unreserved. + if strings.Contains(rawURL, "%") { + t.Errorf("normal password %q was percent-encoded in URL: %s", pass, rawURL) + } + + // Must contain the literal password string + if !strings.Contains(rawURL, pass) { + t.Errorf("URL does not contain literal password %q: %s", pass, rawURL) + } + } + }) + } +} + +// --------------------------------------------------------------------------- +// HTTP query string tests +// --------------------------------------------------------------------------- + +// TestHTTP_SpecialCharsInPassword_QueryPlaceholders tests URLs where +// the password goes into a query parameter via [PASSWORD] placeholder. +// These are patterns like "snapshot.cgi?user=[USERNAME]&pwd=[PASSWORD]". +func TestHTTP_SpecialCharsInPassword_QueryPlaceholders(t *testing.T) { + logger := &mockLogger{} + builder := NewBuilder([]string{}, logger) + + entry := models.CameraEntry{ + Type: "JPEG", + Protocol: "http", + Port: 80, + URL: "snapshot.cgi?user=[USERNAME]&pwd=[PASSWORD]", + } + + for _, sp := range specialPasswords { + t.Run(sp.name, func(t *testing.T) { + ctx := BuildContext{ + IP: "192.168.1.100", + Username: "admin", + Password: sp.password, + Port: 80, + } + + urls := builder.BuildURLsFromEntry(entry, ctx) + if len(urls) == 0 { + t.Fatal("no URLs generated") + } + + for i, rawURL := range urls { + u, err := url.Parse(rawURL) + if err != nil { + t.Errorf("[%d] url.Parse failed: %v\n raw URL: %s", i, err, rawURL) + continue + } + + // Host must be correct + if u.Hostname() != "192.168.1.100" { + t.Errorf("[%d] wrong host %q\n raw URL: %s", i, u.Hostname(), rawURL) + } + + // Fragment must be empty + if u.Fragment != "" { + t.Errorf("[%d] fragment leak %q — '#' in password broke URL\n raw URL: %s", + i, u.Fragment, rawURL) + } + + // If URL has query params, check pwd round-trips + q := u.Query() + if pwd := q.Get("pwd"); pwd != "" { + if pwd != sp.password { + t.Errorf("[%d] pwd param mismatch: got %q, want %q\n raw URL: %s", + i, pwd, sp.password, rawURL) + } + } + + // Ampersand in password must NOT create extra query params + // e.g. password "p&ssword" must not produce key "ssword" + if strings.Contains(sp.password, "&") { + // Extract the part after & as potential rogue key + parts := strings.SplitN(sp.password, "&", 2) + rogueKey := strings.SplitN(parts[1], "&", 2)[0] + rogueKey = strings.SplitN(rogueKey, "=", 2)[0] + if rogueKey != "" && q.Has(rogueKey) { + t.Errorf("[%d] ampersand in password created rogue query param %q\n raw URL: %s", + i, rogueKey, rawURL) + } + } + } + }) + } +} + +// TestHTTP_SpecialCharsInPassword_PathPlaceholders tests patterns where +// credentials appear in the URL path, e.g. +// "/user=[USERNAME]_password=[PASSWORD]_channel=1_stream=0.sdp" +func TestHTTP_SpecialCharsInPassword_PathPlaceholders(t *testing.T) { + logger := &mockLogger{} + builder := NewBuilder([]string{}, logger) + + entry := models.CameraEntry{ + Type: "FFMPEG", + Protocol: "rtsp", + Port: 554, + URL: "/user=[USERNAME]_password=[PASSWORD]_channel=1_stream=0.sdp", + } + + for _, sp := range specialPasswords { + t.Run(sp.name, func(t *testing.T) { + ctx := BuildContext{ + IP: "192.168.1.100", + Username: "admin", + Password: sp.password, + Port: 554, + } + + urls := builder.BuildURLsFromEntry(entry, ctx) + if len(urls) == 0 { + t.Fatal("no URLs generated") + } + + for i, rawURL := range urls { + u, err := url.Parse(rawURL) + if err != nil { + t.Errorf("[%d] url.Parse failed: %v\n raw URL: %s", i, err, rawURL) + continue + } + + // Host must be correct + if u.Hostname() != "192.168.1.100" { + t.Errorf("[%d] wrong host %q, want \"192.168.1.100\"\n raw URL: %s", + i, u.Hostname(), rawURL) + } + + // Scheme must be rtsp + if u.Scheme != "rtsp" { + t.Errorf("[%d] wrong scheme %q, want \"rtsp\"\n raw URL: %s", + i, u.Scheme, rawURL) + } + + // Fragment must be empty + if u.Fragment != "" { + t.Errorf("[%d] fragment leak %q\n raw URL: %s", i, u.Fragment, rawURL) + } + } + }) + } +} + +// TestHTTP_SpecialCharsInPassword_UserInfo tests HTTP URLs where +// credentials are embedded in the userinfo part (user:pass@host). +func TestHTTP_SpecialCharsInPassword_UserInfo(t *testing.T) { + logger := &mockLogger{} + builder := NewBuilder([]string{}, logger) + + entry := models.CameraEntry{ + Type: "JPEG", + Protocol: "http", + Port: 80, + URL: "snapshot.jpg", + } + + for _, sp := range specialPasswords { + t.Run(sp.name, func(t *testing.T) { + ctx := BuildContext{ + IP: "192.168.1.100", + Username: "admin", + Password: sp.password, + Port: 80, + } + + urls := builder.BuildURLsFromEntry(entry, ctx) + if len(urls) == 0 { + t.Fatal("no URLs generated") + } + + for i, rawURL := range urls { + u, err := url.Parse(rawURL) + if err != nil { + t.Errorf("[%d] url.Parse failed: %v\n raw URL: %s", i, err, rawURL) + continue + } + + // Host must be correct + if u.Hostname() != "192.168.1.100" { + t.Errorf("[%d] wrong host %q\n raw URL: %s", i, u.Hostname(), rawURL) + } + + // If userinfo present, password must round-trip + if u.User != nil { + if got, ok := u.User.Password(); ok { + if got != sp.password { + t.Errorf("[%d] userinfo password mismatch: got %q, want %q\n raw URL: %s", + i, got, sp.password, rawURL) + } + } + } + + // Fragment must be empty + if u.Fragment != "" { + t.Errorf("[%d] fragment leak %q\n raw URL: %s", i, u.Fragment, rawURL) + } + } + }) + } +} + +// TestHTTP_SpecialCharsInPassword_CountUnchanged ensures HTTP URL count +// stays the same regardless of password content. +func TestHTTP_SpecialCharsInPassword_CountUnchanged(t *testing.T) { + logger := &mockLogger{} + builder := NewBuilder([]string{}, logger) + + entry := models.CameraEntry{ + Type: "JPEG", + Protocol: "http", + Port: 80, + URL: "snapshot.jpg", + } + + baseCtx := BuildContext{ + IP: "192.168.1.100", + Username: "admin", + Password: "simple123", + Port: 80, + } + baseURLs := builder.BuildURLsFromEntry(entry, baseCtx) + baseCount := len(baseURLs) + + for _, sp := range specialPasswords { + t.Run(sp.name, func(t *testing.T) { + ctx := BuildContext{ + IP: "192.168.1.100", + Username: "admin", + Password: sp.password, + Port: 80, + } + + urls := builder.BuildURLsFromEntry(entry, ctx) + if len(urls) != baseCount { + t.Errorf("URL count changed: simple=%d, special(%q)=%d", + baseCount, sp.password, len(urls)) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Username special char tests +// --------------------------------------------------------------------------- + +// TestSpecialCharsInUsername verifies that usernames with special characters +// are also handled correctly (less common but possible). +func TestSpecialCharsInUsername(t *testing.T) { + logger := &mockLogger{} + builder := NewBuilder([]string{}, logger) + + entry := models.CameraEntry{ + Type: "FFMPEG", + Protocol: "rtsp", + Port: 554, + URL: "/stream1", + } + + specialUsernames := []string{ + "user@domain", + "user:name", + "user#1", + "admin&root", + } + + for _, username := range specialUsernames { + t.Run(username, func(t *testing.T) { + ctx := BuildContext{ + IP: "192.168.1.100", + Username: username, + Password: "password123", + Port: 554, + } + + urls := builder.BuildURLsFromEntry(entry, ctx) + if len(urls) == 0 { + t.Fatal("no URLs generated") + } + + for i, rawURL := range urls { + u, err := url.Parse(rawURL) + if err != nil { + t.Errorf("[%d] url.Parse failed: %v\n raw URL: %s", i, err, rawURL) + continue + } + + if u.Hostname() != "192.168.1.100" { + t.Errorf("[%d] wrong host %q\n raw URL: %s", i, u.Hostname(), rawURL) + } + + if u.User != nil { + if got := u.User.Username(); got != username { + t.Errorf("[%d] username mismatch: got %q, want %q\n raw URL: %s", + i, got, username, rawURL) + } + } + } + }) + } +} + +// --------------------------------------------------------------------------- +// Regression: normal passwords must not be affected +// --------------------------------------------------------------------------- + +// TestHTTP_NormalPassword_NoPercentEncoding ensures that simple passwords +// do not get percent-encoded in the userinfo part, so we don't break +// cameras that might do byte-level comparison. +func TestHTTP_NormalPassword_NoPercentEncoding(t *testing.T) { + logger := &mockLogger{} + builder := NewBuilder([]string{}, logger) + + entry := models.CameraEntry{ + Type: "JPEG", + Protocol: "http", + Port: 80, + URL: "snapshot.cgi?user=[USERNAME]&pwd=[PASSWORD]", + } + + normalPasswords := []string{ + "admin123", + "Password", + "test-pass", + "hello_world", + "dots.dots", + "tilde~ok", + } + + for _, pass := range normalPasswords { + t.Run(pass, func(t *testing.T) { + ctx := BuildContext{ + IP: "192.168.1.100", + Username: "admin", + Password: pass, + Port: 80, + } + + urls := builder.BuildURLsFromEntry(entry, ctx) + + for _, rawURL := range urls { + u, err := url.Parse(rawURL) + if err != nil { + t.Errorf("url.Parse failed: %v\n URL: %s", err, rawURL) + continue + } + + // Check query params: value must match exactly. + // Skip URLs where pwd is empty (the no-auth variant). + q := u.Query() + if pwd := q.Get("pwd"); pwd != "" && pwd != pass { + t.Errorf("pwd param %q != expected %q\n URL: %s", pwd, pass, rawURL) + } + + // Only check raw query encoding on URLs that actually have + // the password in query params (skip no-auth and userinfo-only variants). + if q.Get("pwd") != "" && !strings.Contains(u.RawQuery, pass) { + t.Errorf("safe password %q was percent-encoded in query: %s\n URL: %s", + pass, u.RawQuery, rawURL) + } + } + }) + } +}