Files
onvif/networking/networking.go
Cédric Verstraeten fe86ea942d Return errors for failed SOAP responses
Propagate HTTP 4xx/5xx errors from SOAP and digest requests, preserve responses, and ensure PTZ zero coordinates are serialized. Add regression tests for both behaviors.
2026-08-05 13:13:42 +02:00

214 lines
6.2 KiB
Go

package networking
import (
"bytes"
"crypto/md5"
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
"github.com/beevik/etree"
"github.com/juju/errors"
)
const soapContentType = "application/soap+xml; charset=utf-8"
// SendSoap send soap message
func SendSoap(httpClient *http.Client, endpoint, message string) (*http.Response, error) {
resp, err := httpClient.Post(endpoint, soapContentType, bytes.NewBufferString(message))
if err != nil {
return resp, errors.Annotate(err, "Post")
}
return resp, responseError(resp)
}
func responseError(resp *http.Response) error {
if resp.StatusCode >= 400 && resp.StatusCode < 600 {
return errors.Errorf("Server error: %d: %s", resp.StatusCode, resp.Status)
}
return nil
}
// SendSoapWithDigest sends a soap message and, when the device answers with an
// HTTP 401 digest challenge, transparently retries the request with the
// computed HTTP digest Authorization header.
//
// Any wsse:Security header present in the message is stripped before sending:
// when a device requires HTTP digest the credentials travel in the
// Authorization header, so keeping the WS-Security UsernameToken in the body
// would put the credentials on the wire twice. Other SOAP header blocks (for
// example WS-Addressing reference parameters) are preserved so vendor-specific
// routing keeps working across the retry.
func SendSoapWithDigest(httpClient *http.Client, endpoint, message, username, password string) (*http.Response, error) {
if httpClient == nil {
httpClient = new(http.Client)
}
// Avoid sending the credentials twice (WS-Security + digest) on the retry.
message = stripWSSecurityHeader(message)
resp, err := httpClient.Post(endpoint, soapContentType, bytes.NewBufferString(message))
if err != nil {
return resp, errors.Annotate(err, "Post")
}
// Only escalate to HTTP digest when the device explicitly asks for it.
if resp.StatusCode != http.StatusUnauthorized {
return resp, responseError(resp)
}
challenge := resp.Header.Get("WWW-Authenticate")
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(challenge)), "digest") {
// Not a digest challenge (e.g. Basic) - nothing more we can do here.
return resp, responseError(resp)
}
authorization := newDigestAuthorization(challenge, http.MethodPost, endpoint, username, password)
if authorization == "" {
return resp, errors.New("unsupported digest challenge")
}
// Release the challenge response before issuing the authenticated retry.
resp.Body.Close()
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBufferString(message))
if err != nil {
return nil, errors.Annotate(err, "new digest request")
}
req.Header.Set("Content-Type", soapContentType)
req.Header.Set("Authorization", authorization)
resp, err = httpClient.Do(req)
if err != nil {
return resp, errors.Annotate(err, "Post with digest")
}
return resp, responseError(resp)
}
// stripWSSecurityHeader removes the wsse:Security header block from a SOAP
// envelope, leaving all other header blocks intact. The message is returned
// unchanged if it cannot be parsed as XML or has no such header.
func stripWSSecurityHeader(message string) string {
doc := etree.NewDocument()
if err := doc.ReadFromString(message); err != nil {
return message
}
security := doc.FindElement("./Envelope/Header/Security")
if security == nil {
return message
}
header := doc.Root().SelectElement("Header")
if header == nil {
return message
}
header.RemoveChild(security)
data, err := doc.WriteToString()
if err != nil {
return message
}
return data
}
var digestParamRe = regexp.MustCompile(`(\w+)=(?:"([^"]*)"|([^,]+))`)
// parseDigestChallenge parses the parameters of a WWW-Authenticate: Digest header.
func parseDigestChallenge(challenge string) map[string]string {
challenge = strings.TrimSpace(challenge)
if i := strings.IndexAny(challenge, " \t"); i >= 0 && strings.EqualFold(challenge[:i], "Digest") {
challenge = challenge[i+1:]
}
result := make(map[string]string)
for _, m := range digestParamRe.FindAllStringSubmatch(challenge, -1) {
value := m[2]
if value == "" {
value = m[3]
}
result[strings.ToLower(m[1])] = strings.TrimSpace(value)
}
return result
}
// newDigestAuthorization builds an RFC 2617 HTTP digest Authorization header
// value. It supports the MD5 and MD5-sess algorithms and the "auth" qop, which
// covers the vast majority of ONVIF devices. It returns an empty string when the
// challenge is missing required parameters or requires an unsupported qop.
func newDigestAuthorization(challenge, method, uri, username, password string) string {
parts := parseDigestChallenge(challenge)
realm := parts["realm"]
nonce := parts["nonce"]
if realm == "" || nonce == "" {
return ""
}
opaque := parts["opaque"]
algorithm := parts["algorithm"]
qop := ""
if rawQop, ok := parts["qop"]; ok {
for _, candidate := range strings.Split(rawQop, ",") {
if strings.TrimSpace(candidate) == "auth" {
qop = "auth"
break
}
}
// The server offered qop but none we support (e.g. auth-int only).
if qop == "" {
return ""
}
}
digestURI := uri
if u, err := url.Parse(uri); err == nil {
digestURI = u.RequestURI()
}
cnonce := randomCnonce()
const nc = "00000001"
ha1 := md5Hex(username + ":" + realm + ":" + password)
if strings.EqualFold(algorithm, "MD5-sess") {
ha1 = md5Hex(ha1 + ":" + nonce + ":" + cnonce)
}
ha2 := md5Hex(method + ":" + digestURI)
var response string
if qop == "auth" {
response = md5Hex(strings.Join([]string{ha1, nonce, nc, cnonce, qop, ha2}, ":"))
} else {
response = md5Hex(ha1 + ":" + nonce + ":" + ha2)
}
var b strings.Builder
fmt.Fprintf(&b, `Digest username="%s", realm="%s", nonce="%s", uri="%s", response="%s"`,
username, realm, nonce, digestURI, response)
if qop == "auth" {
fmt.Fprintf(&b, `, qop=auth, nc=%s, cnonce="%s"`, nc, cnonce)
}
if algorithm != "" {
fmt.Fprintf(&b, `, algorithm=%s`, algorithm)
}
if opaque != "" {
fmt.Fprintf(&b, `, opaque="%s"`, opaque)
}
return b.String()
}
func md5Hex(s string) string {
sum := md5.Sum([]byte(s))
return hex.EncodeToString(sum[:])
}
func randomCnonce() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
return "00000000"
}
return hex.EncodeToString(b)
}