From 688080c39bc5dc987067325c4acf96c6afd83b57 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 13 Aug 2026 11:38:24 +0800 Subject: [PATCH] fix: support ONVIF Digest media services (#48) --- .../server/app/sense/adapters/onvif/client.go | 195 +++++++++++++++++- .../app/sense/adapters/onvif/client_test.go | 143 +++++++++++++ .../server/app/sense/adapters/onvif/parser.go | 29 +++ .../app/sense/adapters/onvif/parser_test.go | 14 ++ 4 files changed, 378 insertions(+), 3 deletions(-) create mode 100644 Sense/server/app/sense/adapters/onvif/client_test.go 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") + } +}