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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user