diff --git a/Sense/server/app/sense/adapters/onvif/client.go b/Sense/server/app/sense/adapters/onvif/client.go
index c03cb87..73a8ea9 100644
--- a/Sense/server/app/sense/adapters/onvif/client.go
+++ b/Sense/server/app/sense/adapters/onvif/client.go
@@ -3,11 +3,15 @@ package onvif
import (
"bytes"
"context"
+ "crypto/md5"
+ "crypto/rand"
+ "crypto/sha256"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
+ "strconv"
"strings"
"time"
)
@@ -34,10 +38,28 @@ func NewHTTPClient(timeout time.Duration) *HTTPClient {
if timeout <= 0 {
timeout = 8 * time.Second
}
- return &HTTPClient{client: &http.Client{Timeout: timeout}}
+ return &HTTPClient{client: &http.Client{
+ Timeout: timeout,
+ CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+ }}
}
func (c *HTTPClient) Profiles(ctx context.Context, address string, credential Credential) ([]Profile, error) {
- endpoint, err := validateEndpoint(address)
+ deviceEndpoint, err := validateEndpoint(address)
+ if err != nil {
+ return nil, err
+ }
+ capabilitiesBody := `All`
+ capabilities, err := c.soap(ctx, deviceEndpoint, credential, capabilitiesBody)
+ if err != nil {
+ return nil, err
+ }
+ mediaAddress, err := ParseMediaServiceAddress(capabilities)
+ if err != nil {
+ return nil, err
+ }
+ endpoint, err := normalizeServiceEndpoint(deviceEndpoint, mediaAddress)
if err != nil {
return nil, err
}
@@ -64,12 +86,18 @@ func (c *HTTPClient) Profiles(ctx context.Context, address string, credential Cr
return profiles, nil
}
func (c *HTTPClient) soap(ctx context.Context, endpoint string, credential Credential, body string) ([]byte, error) {
+ return c.soapAttempt(ctx, endpoint, credential, body, "")
+}
+
+func (c *HTTPClient) soapAttempt(ctx context.Context, endpoint string, credential Credential, body, authorization string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBufferString(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/soap+xml; charset=utf-8")
- if credential.Username != "" {
+ if authorization != "" {
+ req.Header.Set("Authorization", authorization)
+ } else if credential.Username != "" {
req.SetBasicAuth(credential.Username, credential.Password)
}
res, err := c.client.Do(req)
@@ -82,6 +110,16 @@ func (c *HTTPClient) soap(ctx context.Context, endpoint string, credential Crede
return nil, err
}
if res.StatusCode == http.StatusUnauthorized {
+ if authorization == "" && credential.Username != "" {
+ challenge, challengeErr := parseDigestChallenge(res.Header.Values("WWW-Authenticate"))
+ if challengeErr == nil {
+ digest, digestErr := digestAuthorization(http.MethodPost, req.URL.RequestURI(), credential, challenge)
+ if digestErr != nil {
+ return nil, digestErr
+ }
+ return c.soapAttempt(ctx, endpoint, credential, body, digest)
+ }
+ }
return nil, fmt.Errorf("authentication_failed")
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
@@ -89,6 +127,157 @@ func (c *HTTPClient) soap(ctx context.Context, endpoint string, credential Crede
}
return data, nil
}
+
+type digestChallenge struct {
+ realm string
+ nonce string
+ opaque string
+ algorithm string
+ qop string
+}
+
+func parseDigestChallenge(values []string) (digestChallenge, error) {
+ for _, value := range values {
+ if !strings.EqualFold(strings.TrimSpace(strings.SplitN(value, " ", 2)[0]), "Digest") {
+ continue
+ }
+ parts := strings.SplitN(strings.TrimSpace(value), " ", 2)
+ if len(parts) != 2 {
+ break
+ }
+ params, err := parseAuthParameters(parts[1])
+ if err != nil {
+ return digestChallenge{}, err
+ }
+ challenge := digestChallenge{
+ realm: strings.TrimSpace(params["realm"]), nonce: strings.TrimSpace(params["nonce"]),
+ opaque: strings.TrimSpace(params["opaque"]), algorithm: strings.ToUpper(strings.TrimSpace(params["algorithm"])),
+ }
+ if challenge.realm == "" || challenge.nonce == "" {
+ return digestChallenge{}, fmt.Errorf("invalid_digest_challenge")
+ }
+ if challenge.algorithm == "" {
+ challenge.algorithm = "MD5"
+ }
+ if challenge.algorithm != "MD5" && challenge.algorithm != "SHA-256" {
+ return digestChallenge{}, fmt.Errorf("unsupported_digest_algorithm")
+ }
+ qops := strings.Split(params["qop"], ",")
+ for _, qop := range qops {
+ if strings.EqualFold(strings.TrimSpace(qop), "auth") {
+ challenge.qop = "auth"
+ break
+ }
+ }
+ if params["qop"] != "" && challenge.qop == "" {
+ return digestChallenge{}, fmt.Errorf("unsupported_digest_qop")
+ }
+ return challenge, nil
+ }
+ return digestChallenge{}, fmt.Errorf("digest_challenge_not_found")
+}
+
+func parseAuthParameters(value string) (map[string]string, error) {
+ result := map[string]string{}
+ for position := 0; position < len(value); {
+ for position < len(value) && (value[position] == ' ' || value[position] == ',') {
+ position++
+ }
+ start := position
+ for position < len(value) && value[position] != '=' && value[position] != ',' {
+ position++
+ }
+ if position == start || position >= len(value) || value[position] != '=' {
+ return nil, fmt.Errorf("invalid_digest_challenge")
+ }
+ name := strings.ToLower(strings.TrimSpace(value[start:position]))
+ position++
+ var parameter string
+ if position < len(value) && value[position] == '"' {
+ position++
+ var builder strings.Builder
+ closed := false
+ for position < len(value) {
+ if value[position] == '"' {
+ position++
+ closed = true
+ break
+ }
+ if value[position] == '\\' && position+1 < len(value) {
+ position++
+ }
+ builder.WriteByte(value[position])
+ position++
+ }
+ if !closed {
+ return nil, fmt.Errorf("invalid_digest_challenge")
+ }
+ parameter = builder.String()
+ } else {
+ start = position
+ for position < len(value) && value[position] != ',' {
+ position++
+ }
+ parameter = strings.TrimSpace(value[start:position])
+ }
+ result[name] = parameter
+ }
+ return result, nil
+}
+
+func digestAuthorization(method, uri string, credential Credential, challenge digestChallenge) (string, error) {
+ cnonceBytes := make([]byte, 16)
+ if _, err := rand.Read(cnonceBytes); err != nil {
+ return "", fmt.Errorf("generate_digest_cnonce: %w", err)
+ }
+ cnonce := fmt.Sprintf("%x", cnonceBytes)
+ hash := func(value string) string {
+ if challenge.algorithm == "SHA-256" {
+ sum := sha256.Sum256([]byte(value))
+ return fmt.Sprintf("%x", sum)
+ }
+ sum := md5.Sum([]byte(value))
+ return fmt.Sprintf("%x", sum)
+ }
+ ha1 := hash(credential.Username + ":" + challenge.realm + ":" + credential.Password)
+ ha2 := hash(method + ":" + uri)
+ nonceCount := "00000001"
+ response := hash(ha1 + ":" + challenge.nonce + ":" + ha2)
+ if challenge.qop != "" {
+ response = hash(ha1 + ":" + challenge.nonce + ":" + nonceCount + ":" + cnonce + ":" + challenge.qop + ":" + ha2)
+ }
+ values := []string{
+ `username=` + strconv.Quote(credential.Username), `realm=` + strconv.Quote(challenge.realm),
+ `nonce=` + strconv.Quote(challenge.nonce), `uri=` + strconv.Quote(uri),
+ `response=` + strconv.Quote(response), `algorithm=` + challenge.algorithm,
+ }
+ if challenge.opaque != "" {
+ values = append(values, `opaque=`+strconv.Quote(challenge.opaque))
+ }
+ if challenge.qop != "" {
+ values = append(values, `qop=`+challenge.qop, `nc=`+nonceCount, `cnonce=`+strconv.Quote(cnonce))
+ }
+ return "Digest " + strings.Join(values, ", "), nil
+}
+
+func normalizeServiceEndpoint(deviceEndpoint, advertisedEndpoint string) (string, error) {
+ device, err := url.Parse(deviceEndpoint)
+ if err != nil {
+ return "", fmt.Errorf("invalid ONVIF address")
+ }
+ advertised, err := url.Parse(advertisedEndpoint)
+ if err != nil || advertised.Scheme == "" || advertised.Host == "" || advertised.User != nil {
+ return "", fmt.Errorf("invalid ONVIF media address")
+ }
+ if advertised.Scheme != "http" && advertised.Scheme != "https" {
+ return "", fmt.Errorf("unsupported ONVIF media scheme")
+ }
+ if !strings.EqualFold(advertised.Hostname(), device.Hostname()) {
+ advertised.Scheme = device.Scheme
+ advertised.Host = device.Host
+ }
+ return advertised.String(), nil
+}
func validateEndpoint(value string) (string, error) {
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
diff --git a/Sense/server/app/sense/adapters/onvif/client_test.go b/Sense/server/app/sense/adapters/onvif/client_test.go
new file mode 100644
index 0000000..b8f0de7
--- /dev/null
+++ b/Sense/server/app/sense/adapters/onvif/client_test.go
@@ -0,0 +1,143 @@
+package onvif
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+func TestProfilesDiscoversMediaServiceAndUsesDigest(t *testing.T) {
+ var digestRequests atomic.Int32
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/onvif/device_service":
+ fmt.Fprintf(w, `%s/onvif/media_service`, server.URL)
+ case "/onvif/media_service":
+ authorization := r.Header.Get("Authorization")
+ if !strings.HasPrefix(authorization, "Digest ") {
+ w.Header().Set("WWW-Authenticate", `Digest realm="camera", nonce="nonce-1", algorithm=MD5, qop="auth"`)
+ w.WriteHeader(http.StatusUnauthorized)
+ return
+ }
+ digestRequests.Add(1)
+ if strings.Contains(readRequestBody(t, r), "GetProfiles") {
+ fmt.Fprint(w, `MainH26419201080`)
+ return
+ }
+ fmt.Fprint(w, `rtsp://camera.invalid/live`)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ profiles, err := NewHTTPClient(2*time.Second).Profiles(context.Background(), server.URL+"/onvif/device_service", Credential{Username: "operator", Password: "secret"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(profiles) != 1 || profiles[0].Width != 1920 || profiles[0].StreamURI != "rtsp://camera.invalid/live" {
+ t.Fatalf("profiles=%#v", profiles)
+ }
+ if digestRequests.Load() != 2 {
+ t.Fatalf("digest requests=%d", digestRequests.Load())
+ }
+}
+
+func TestNormalizeServiceEndpoint(t *testing.T) {
+ tests := []struct {
+ name string
+ device string
+ advertised string
+ want string
+ wantError bool
+ }{
+ {name: "same host keeps media port", device: "http://camera.local:80/device", advertised: "http://camera.local:8000/media", want: "http://camera.local:8000/media"},
+ {name: "different host uses authorized origin", device: "http://192.0.2.10:8080/device", advertised: "http://unusable.local:9000/media?profile=1", want: "http://192.0.2.10:8080/media?profile=1"},
+ {name: "reject credentials", device: "http://camera.local/device", advertised: "http://user:pass@camera.local/media", wantError: true},
+ {name: "reject scheme", device: "http://camera.local/device", advertised: "ftp://camera.local/media", wantError: true},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ got, err := normalizeServiceEndpoint(test.device, test.advertised)
+ if test.wantError {
+ if err == nil {
+ t.Fatalf("got=%q", got)
+ }
+ return
+ }
+ if err != nil || got != test.want {
+ t.Fatalf("got=%q err=%v", got, err)
+ }
+ })
+ }
+}
+
+func TestRejectUnsupportedDigestChallenge(t *testing.T) {
+ for _, challenge := range []string{
+ `Digest realm="camera", nonce="n", algorithm=SHA-512, qop="auth"`,
+ `Digest realm="camera", nonce="n", algorithm=MD5, qop="auth-int"`,
+ `Digest realm="camera"`,
+ } {
+ if _, err := parseDigestChallenge([]string{challenge}); err == nil {
+ t.Fatalf("challenge accepted: %s", challenge)
+ }
+ }
+}
+
+func TestValidateEndpointRejectsCredentials(t *testing.T) {
+ if _, err := validateEndpoint("http://user:pass@camera.invalid/onvif"); err == nil {
+ t.Fatal("credential endpoint accepted")
+ }
+}
+
+func TestSOAPDoesNotFollowRedirect(t *testing.T) {
+ redirectTargetCalled := false
+ target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
+ redirectTargetCalled = true
+ }))
+ defer target.Close()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, target.URL, http.StatusFound)
+ }))
+ defer server.Close()
+
+ _, err := NewHTTPClient(time.Second).soap(context.Background(), server.URL, Credential{Username: "operator", Password: "secret"}, "")
+ if err == nil || redirectTargetCalled {
+ t.Fatalf("err=%v redirect_target_called=%v", err, redirectTargetCalled)
+ }
+}
+
+func readRequestBody(t *testing.T, r *http.Request) string {
+ t.Helper()
+ defer r.Body.Close()
+ data, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(data)
+}
+
+func TestDigestAuthorizationUsesRequestURI(t *testing.T) {
+ header, err := digestAuthorization(http.MethodPost, "/media?profile=1", Credential{Username: "operator", Password: "secret"}, digestChallenge{realm: "camera", nonce: "n", algorithm: "SHA-256", qop: "auth"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ parsed, err := parseAuthParameters(strings.TrimPrefix(header, "Digest "))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if parsed["uri"] != "/media?profile=1" || parsed["username"] != "operator" || parsed["response"] == "" {
+ t.Fatalf("invalid digest fields: %#v", parsed)
+ }
+ if _, err := url.Parse(parsed["uri"]); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/Sense/server/app/sense/adapters/onvif/parser.go b/Sense/server/app/sense/adapters/onvif/parser.go
index 56b2665..91f38cd 100644
--- a/Sense/server/app/sense/adapters/onvif/parser.go
+++ b/Sense/server/app/sense/adapters/onvif/parser.go
@@ -54,3 +54,32 @@ func ParseStreamURI(data []byte) (string, error) {
}
}
}
+
+func ParseMediaServiceAddress(data []byte) (string, error) {
+ decoder := xml.NewDecoder(strings.NewReader(string(data)))
+ mediaDepth := 0
+ for {
+ token, err := decoder.Token()
+ if err != nil {
+ return "", fmt.Errorf("media_service_not_found")
+ }
+ switch value := token.(type) {
+ case xml.StartElement:
+ if value.Name.Local == "Media" {
+ mediaDepth++
+ continue
+ }
+ if mediaDepth > 0 && value.Name.Local == "XAddr" {
+ var address string
+ if err := decoder.DecodeElement(&address, &value); err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(address), nil
+ }
+ case xml.EndElement:
+ if value.Name.Local == "Media" && mediaDepth > 0 {
+ mediaDepth--
+ }
+ }
+ }
+}
diff --git a/Sense/server/app/sense/adapters/onvif/parser_test.go b/Sense/server/app/sense/adapters/onvif/parser_test.go
index 1da8a27..9ef99b6 100644
--- a/Sense/server/app/sense/adapters/onvif/parser_test.go
+++ b/Sense/server/app/sense/adapters/onvif/parser_test.go
@@ -29,3 +29,17 @@ func TestRejectCredentialInStreamURI(t *testing.T) {
t.Fatal("credential URI accepted")
}
}
+
+func TestParseMediaServiceAddress(t *testing.T) {
+ data := []byte(`http://camera.invalid:8000/onvif/media_service`)
+ address, err := ParseMediaServiceAddress(data)
+ if err != nil || address != "http://camera.invalid:8000/onvif/media_service" {
+ t.Fatalf("address=%q err=%v", address, err)
+ }
+}
+
+func TestParseMediaServiceAddressRejectsMissingMedia(t *testing.T) {
+ if _, err := ParseMediaServiceAddress([]byte(``)); err == nil {
+ t.Fatal("missing media service accepted")
+ }
+}