4fe5ae9447
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
560 lines
16 KiB
Go
560 lines
16 KiB
Go
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)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|