mirror of
https://github.com/kerberos-io/onvif.git
synced 2026-08-23 15:08:33 +00:00
fix(event/stream): address round-2 review findings
Critical
- Renew busy-loop on persistent failure: after a failed
renewPullPoint, the loop re-read the (now in-past)
GrantedTermination and nextRenewInterval floored to 1s, hammering
the camera at 1 Hz until reconnect. nextRenewIntervalAfterError
decouples the failure path from the stale grant and backs off
at opts.RetryBackoff. renewLoop also routes through s.now() so
test clocks can drive it deterministically.
- Lost-update race on GrantedTermination: a renew result for an
old subscription could overwrite the grant on a new one if
attemptRecreate swapped pullPoint mid-flight. A generation
counter on Stream tracks subscription rotation; the renew loop
captures the generation before the SOAP call and discards the
result if the subscription was rotated.
- Credential leak when <Security> straddled the 64 KiB error cap:
the non-greedy regex required a closing tag and missed the
truncated case. wsseSecurityRE now matches close-tag-or-EOF.
Belt-and-braces wssePasswordRE redacts <Password> elements
outside any Security wrapper.
- addHeaderChildren accepted well-formed-but-element-free input
and produced a header-less request. Now errors out.
Important
- Wrapper detection in buildRefParamsHeader was HasSuffix-based and
misfired on children named *ReferenceParameters. Replaced with the
unambiguous wrapper-only contract: input must be the full
<*:ReferenceParameters> element returned by extractReferenceParameters.
- Renamed SoapOption → SendSoapOption and WithHeader → WithSOAPHeader
to disambiguate at call sites.
- Renamed WithHeader's `xml` parameter to headerContent to avoid
shadowing the encoding/xml package name.
- Documented terminationTimeRE's "first match only" semantics so
nobody re-uses it from PullMessages context where multiple
TerminationTime elements appear.
- goleak is now a direct require (go mod tidy).
Performance
- Added gosoap.AddStringHeaderContents (plural) for multi-root
header content. AddStringHeaderContent remains as-is for
backwards compatibility with external consumers. Device.go's
addHeaderChildren workaround is gone — one etree parse per
SendSoapWithOptions call instead of two.
Migration
- Device.go's own CallOnvifFunction and the three examples now
call SendSoapWithOptions, modelling the canonical path.
Docs / tests
- Trust-boundary warning on SendSoapWithHeader/Options godoc.
- Comments on createPullPointResp/Alt explain why TerminationTime
is intentionally omitted (renew timing fixtures).
- New tests: digest retry strips WS-Security, duplicate
WithSOAPHeader is last-wins, malformed TerminationTime yields
zero, margin==base falls to base/2, empty SubscriptionReference
yields empty ref params, Security straddling cap is redacted,
bare Password redacted, wrapper-only contract is enforced.
This commit is contained in:
65
Device.go
65
Device.go
@@ -343,36 +343,39 @@ func (dev Device) SendSoap(endpoint string, xmlRequestBody string) (*http.Respon
|
||||
// wsa:IsReferenceParameter="true") back to vendors like AXIS that
|
||||
// identify pull-point subscriptions through them rather than the URL.
|
||||
//
|
||||
// xmlHeaderContent must be well-formed XML representing zero or more
|
||||
// SOAP Header child elements (sibling top-level elements are
|
||||
// supported; the spec lets each reference parameter be its own header
|
||||
// block). The caller is responsible for escaping any externally
|
||||
// sourced data inside it. Malformed XML returns an error before any
|
||||
// request is made.
|
||||
// xmlHeaderContent must be well-formed XML representing one or more
|
||||
// SOAP Header child elements (siblings are supported; the spec lets
|
||||
// each reference parameter be its own header block). Malformed or
|
||||
// element-free content errors before any request is made.
|
||||
//
|
||||
// SECURITY: do not pass content sourced from untrusted clients. The
|
||||
// API assumes the caller is authoritative for the envelope; injected
|
||||
// <wsse:Security> or <wsa:Action> in the header forwards verbatim and
|
||||
// may override envelope defaults under our credentials.
|
||||
func (dev Device) SendSoapWithHeader(endpoint, xmlRequestBody, xmlHeaderContent string) (*http.Response, error) {
|
||||
return dev.SendSoapWithOptions(endpoint, xmlRequestBody, WithHeader(xmlHeaderContent))
|
||||
return dev.SendSoapWithOptions(endpoint, xmlRequestBody, WithSOAPHeader(xmlHeaderContent))
|
||||
}
|
||||
|
||||
// SoapOption tweaks a single SendSoapWithOptions call. New options
|
||||
// SendSoapOption tweaks a single SendSoapWithOptions call. New options
|
||||
// (per-call timeout, context, custom envelope namespaces, ...) should
|
||||
// be added as WithX constructors here rather than as new method
|
||||
// variants on Device.
|
||||
type SoapOption func(*soapConfig)
|
||||
type SendSoapOption func(*soapConfig)
|
||||
|
||||
type soapConfig struct {
|
||||
headerContent string
|
||||
}
|
||||
|
||||
// WithHeader adds inner-Header XML to the envelope. See
|
||||
// WithSOAPHeader adds inner-Header XML to the envelope. See
|
||||
// SendSoapWithHeader for the content contract.
|
||||
func WithHeader(xml string) SoapOption {
|
||||
return func(c *soapConfig) { c.headerContent = xml }
|
||||
func WithSOAPHeader(headerContent string) SendSoapOption {
|
||||
return func(c *soapConfig) { c.headerContent = headerContent }
|
||||
}
|
||||
|
||||
// SendSoapWithOptions is the workhorse behind SendSoap and
|
||||
// SendSoapWithHeader; call it directly when you need to combine
|
||||
// options or pass options not surfaced by the convenience wrappers.
|
||||
func (dev Device) SendSoapWithOptions(endpoint, xmlRequestBody string, opts ...SoapOption) (*http.Response, error) {
|
||||
func (dev Device) SendSoapWithOptions(endpoint, xmlRequestBody string, opts ...SendSoapOption) (*http.Response, error) {
|
||||
var cfg soapConfig
|
||||
for _, o := range opts {
|
||||
o(&cfg)
|
||||
@@ -381,8 +384,10 @@ func (dev Device) SendSoapWithOptions(endpoint, xmlRequestBody string, opts ...S
|
||||
soap.AddStringBodyContent(xmlRequestBody)
|
||||
soap.AddRootNamespaces(Xlmns)
|
||||
soap.AddAction()
|
||||
if err := addHeaderChildren(&soap, cfg.headerContent); err != nil {
|
||||
return nil, err
|
||||
if cfg.headerContent != "" {
|
||||
if err := soap.AddStringHeaderContents(cfg.headerContent); err != nil {
|
||||
return nil, fmt.Errorf("add header content: %w", err)
|
||||
}
|
||||
}
|
||||
if dev.params.Username != "" && dev.params.Password != "" {
|
||||
soap.AddWSSecurity(dev.params.Username, dev.params.Password)
|
||||
@@ -398,34 +403,6 @@ func (dev Device) SendSoapWithOptions(endpoint, xmlRequestBody string, opts ...S
|
||||
return servResp, err
|
||||
}
|
||||
|
||||
// addHeaderChildren wraps the fragment so etree can parse multi-root
|
||||
// XML, then adds each top-level child as its own SOAP Header block.
|
||||
// gosoap.AddStringHeaderContent only accepts a single root element.
|
||||
func addHeaderChildren(soap *gosoap.SoapMessage, xmlHeaderContent string) error {
|
||||
if xmlHeaderContent == "" {
|
||||
return nil
|
||||
}
|
||||
doc := etree.NewDocument()
|
||||
if err := doc.ReadFromString("<wrap>" + xmlHeaderContent + "</wrap>"); err != nil {
|
||||
return fmt.Errorf("parse header content: %w", err)
|
||||
}
|
||||
wrap := doc.SelectElement("wrap")
|
||||
if wrap == nil {
|
||||
return errors.New("parse header content: missing wrap root")
|
||||
}
|
||||
for _, child := range wrap.ChildElements() {
|
||||
d := etree.NewDocument()
|
||||
d.SetRoot(child.Copy())
|
||||
s, err := d.WriteToString()
|
||||
if err != nil {
|
||||
return fmt.Errorf("serialise header child: %w", err)
|
||||
}
|
||||
if err := soap.AddStringHeaderContent(s); err != nil {
|
||||
return fmt.Errorf("add header child: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func createHttpRequest(httpMethod string, endpoint string, soap string) (req *http.Request, err error) {
|
||||
req, err = http.NewRequest(httpMethod, endpoint, bytes.NewBufferString(soap))
|
||||
@@ -457,7 +434,7 @@ func (dev *Device) CallOnvifFunction(serviceName, functionName string, data []by
|
||||
}
|
||||
xmlRequestBody := string(requestBody)
|
||||
|
||||
servResp, err := dev.SendSoap(endpoint, xmlRequestBody)
|
||||
servResp, err := dev.SendSoapWithOptions(endpoint, xmlRequestBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fail to send the '%s' request for the web service '%s', %v", functionName, serviceName, err)
|
||||
}
|
||||
|
||||
@@ -103,6 +103,23 @@ func TestDevice_SendSoapWithHeader_AcceptsMultipleTopLevelChildren(t *testing.T)
|
||||
assert.Contains(t, headerSlice, "Bar")
|
||||
}
|
||||
|
||||
func TestDevice_SendSoapWithHeader_RejectsElementFreeContent(t *testing.T) {
|
||||
var hits int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
dev := Device{params: DeviceParams{HttpClient: srv.Client()}}
|
||||
|
||||
// Well-formed XML but contains no child elements — would otherwise
|
||||
// parse, yield zero ChildElements, and send a header-less request.
|
||||
_, err := dev.SendSoapWithHeader(srv.URL, "<body/>", "just text content")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, 0, hits,
|
||||
"non-empty header content with no element children must fail fast")
|
||||
}
|
||||
|
||||
// SendSoapWithOptions is the variadic shape that future per-call
|
||||
// options (timeout, context, ...) will hang off. SendSoap and
|
||||
// SendSoapWithHeader stay as thin convenience wrappers so existing
|
||||
@@ -119,7 +136,7 @@ func TestDevice_SendSoapWithOptions_WithHeaderMatchesSendSoapWithHeader(t *testi
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
dev := Device{params: DeviceParams{HttpClient: srv.Client()}}
|
||||
resp, err := dev.SendSoapWithOptions(srv.URL, bodyXML, WithHeader(headerXML))
|
||||
resp, err := dev.SendSoapWithOptions(srv.URL, bodyXML, WithSOAPHeader(headerXML))
|
||||
require.NoError(t, err)
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
@@ -195,3 +212,57 @@ func TestDevice_SendSoapWithHeader_PropagatesMalformedHeaderError(t *testing.T)
|
||||
assert.Equal(t, 0, hits,
|
||||
"malformed header XML must fail fast — no request should reach the camera with a missing header block")
|
||||
}
|
||||
|
||||
// Digest retry: networking.SendSoapWithDigest strips the wsse:Security
|
||||
// element from the envelope before re-POSTing so credentials don't go
|
||||
// on the wire twice (once via WS-Security, once via the digest header).
|
||||
// Pin the behaviour from the Device layer.
|
||||
func TestDevice_SendSoapWithOptions_DigestRetryStripsWSSE(t *testing.T) {
|
||||
var authedBody string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
w.Header().Set("WWW-Authenticate", `Digest realm="onvif", nonce="abc", qop="auth"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
authedBody = string(b)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
dev := Device{params: DeviceParams{
|
||||
HttpClient: srv.Client(),
|
||||
Username: "admin",
|
||||
Password: "secret",
|
||||
}}
|
||||
resp, err := dev.SendSoapWithOptions(srv.URL, "<tev:X xmlns:tev='x'/>")
|
||||
require.NoError(t, err)
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
require.NotEmpty(t, authedBody, "expected an authenticated POST after the 401 challenge")
|
||||
assert.NotContains(t, authedBody, "UsernameToken",
|
||||
"digest retry must strip wsse:Security/UsernameToken; otherwise credentials go on the wire twice")
|
||||
}
|
||||
|
||||
// Last-write-wins on duplicate SendSoapOption — pin the behaviour so
|
||||
// the next maintainer adding an option doesn't accidentally introduce
|
||||
// a merge or first-wins semantic.
|
||||
func TestDevice_SendSoapWithOptions_DuplicateWithSOAPHeaderLastWins(t *testing.T) {
|
||||
var captured string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
captured = string(b)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
dev := Device{params: DeviceParams{HttpClient: srv.Client()}}
|
||||
_, err := dev.SendSoapWithOptions(srv.URL, "<body/>",
|
||||
WithSOAPHeader(`<a:First xmlns:a="x"/>`),
|
||||
WithSOAPHeader(`<b:Second xmlns:b="y"/>`),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, captured, "First", "first WithSOAPHeader must be overwritten")
|
||||
assert.Contains(t, captured, "Second")
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ import (
|
||||
|
||||
// createPullPointRespAlt mirrors the first fixture but returns a
|
||||
// different SubscriptionReference Address so a test can prove that
|
||||
// subsequent pulls hit the recreated endpoint.
|
||||
// subsequent pulls hit the recreated endpoint. Like createPullPointResp,
|
||||
// it intentionally omits <TerminationTime> so renew scheduling stays
|
||||
// driven by opts.
|
||||
const createPullPointRespAlt = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope"
|
||||
xmlns:wsa="http://www.w3.org/2005/08/addressing"
|
||||
|
||||
@@ -11,23 +11,29 @@ import (
|
||||
)
|
||||
|
||||
// renewLoop sleeps until the next deadline (camera-granted termination
|
||||
// minus RenewMargin), renews, and repeats. A permanently failing
|
||||
// renew lets the subscription die at the camera; the pull loop's
|
||||
// reconnect path then recreates it — recreate is the only reliable
|
||||
// recovery once a subscription is GC'd.
|
||||
// minus RenewMargin), renews, and repeats. On failure it backs off via
|
||||
// nextRenewIntervalAfterError so the loop doesn't busy-loop against
|
||||
// the 1s floor when the previous grant has just expired. A permanently
|
||||
// failing renew lets the subscription die at the camera; the pull
|
||||
// loop's reconnect path then recreates it — recreate is the only
|
||||
// reliable recovery once a subscription is GC'd.
|
||||
func (s *Stream) renewLoop(ctx context.Context) {
|
||||
for {
|
||||
ref := s.getPullPoint()
|
||||
if !sleepCtx(ctx, nextRenewInterval(ref.GrantedTermination, s.opts, time.Now())) {
|
||||
gen := s.pullPointGen()
|
||||
if !sleepCtx(ctx, nextRenewInterval(ref.GrantedTermination, s.opts, s.now())) {
|
||||
return
|
||||
}
|
||||
granted, err := renewPullPoint(s.caller, s.getPullPoint(), s.opts)
|
||||
if err != nil {
|
||||
s.surfaceError(ErrRenewFailed{Err: err})
|
||||
if !sleepCtx(ctx, nextRenewIntervalAfterError(ref.GrantedTermination, s.opts, s.now())) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !granted.IsZero() {
|
||||
s.updateGrantedTermination(granted)
|
||||
s.updateGrantedTerminationIfGen(gen, granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,6 +58,18 @@ func nextRenewInterval(granted time.Time, opts Options, now time.Time) time.Dura
|
||||
return d
|
||||
}
|
||||
|
||||
// nextRenewIntervalAfterError ignores GrantedTermination — by the time
|
||||
// renew has failed once, the grant is typically already in the past
|
||||
// and nextRenewInterval would floor to 1s, hammering the camera.
|
||||
// Recovery is the pull loop's reconnect path; we just need to not
|
||||
// accelerate retries past the configured RetryBackoff.
|
||||
func nextRenewIntervalAfterError(_ time.Time, opts Options, _ time.Time) time.Duration {
|
||||
if opts.RetryBackoff > 0 {
|
||||
return opts.RetryBackoff
|
||||
}
|
||||
return time.Second
|
||||
}
|
||||
|
||||
// renewPullPoint sends Renew with an absolute UTC TerminationTime.
|
||||
// WS-BaseNotification §6.1.1 also allows xsd:duration but older
|
||||
// Hikvision, some Dahua and some Bosch firmwares reject the
|
||||
|
||||
@@ -191,7 +191,7 @@ const renewFaultBody = `<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-
|
||||
func TestRenewPullPoint_EchoesRefParamsWithIsReferenceParameter(t *testing.T) {
|
||||
ref := subscriptionRef{
|
||||
Address: "http://192.168.1.10/onvif/services",
|
||||
RefParamsXML: `<dom0:SubscriptionId xmlns:dom0="http://www.axis.com/2009/event">297</dom0:SubscriptionId>`,
|
||||
RefParamsXML: `<wsa:ReferenceParameters xmlns:wsa="http://www.w3.org/2005/08/addressing"><dom0:SubscriptionId xmlns:dom0="http://www.axis.com/2009/event">297</dom0:SubscriptionId></wsa:ReferenceParameters>`,
|
||||
}
|
||||
fc := newFakeCaller()
|
||||
_, err := renewPullPoint(fc, ref, defaultOptions())
|
||||
@@ -202,3 +202,56 @@ func TestRenewPullPoint_EchoesRefParamsWithIsReferenceParameter(t *testing.T) {
|
||||
assert.Contains(t, hdr, "297")
|
||||
assert.Contains(t, hdr, `IsReferenceParameter="true"`)
|
||||
}
|
||||
|
||||
// Regression: when GrantedTermination has just passed (renew failed
|
||||
// at or after the deadline), nextRenewInterval floors to one second
|
||||
// and the loop hammers the camera at 1 Hz until reconnect. Original
|
||||
// ticker design retried at the configured cadence regardless. After
|
||||
// a failure the loop must use a backoff decoupled from the stale
|
||||
// grant.
|
||||
func TestNextRenewIntervalAfterError_IgnoresStaleGrantedTermination(t *testing.T) {
|
||||
now := time.Date(2026, 5, 27, 13, 0, 0, 0, time.UTC)
|
||||
stale := now.Add(-500 * time.Millisecond)
|
||||
opts := Options{InitialTermination: 60 * time.Second, RenewMargin: 10 * time.Second, RetryBackoff: time.Second}
|
||||
got := nextRenewIntervalAfterError(stale, opts, now)
|
||||
assert.GreaterOrEqual(t, got, opts.RetryBackoff,
|
||||
"failure path must back off at least RetryBackoff, not 1s floor on stale grant")
|
||||
}
|
||||
|
||||
func TestNextRenewIntervalAfterError_FallsBackToRetryBackoff(t *testing.T) {
|
||||
now := time.Date(2026, 5, 27, 13, 0, 0, 0, time.UTC)
|
||||
opts := Options{InitialTermination: 60 * time.Second, RenewMargin: 10 * time.Second, RetryBackoff: 5 * time.Second}
|
||||
got := nextRenewIntervalAfterError(time.Time{}, opts, now)
|
||||
assert.Equal(t, opts.RetryBackoff, got)
|
||||
}
|
||||
|
||||
// Lost-update race: renew snapshots the ref, the SOAP call returns,
|
||||
// and meanwhile attemptRecreate replaced pullPoint with a fresh
|
||||
// subscription. If renew blindly writes the OLD subscription's
|
||||
// granted time onto the NEW subscription, the new schedule is wrong.
|
||||
// Update must be conditioned on "same subscription as when I read."
|
||||
func TestUpdateGrantedTermination_DropsWriteWhenSubscriptionRotated(t *testing.T) {
|
||||
s := &Stream{}
|
||||
original := subscriptionRef{Address: "http://camera/sub-A"}
|
||||
s.setPullPoint(original)
|
||||
gen := s.pullPointGen()
|
||||
|
||||
// Simulate recreate happening between snapshot and write.
|
||||
s.setPullPoint(subscriptionRef{Address: "http://camera/sub-B"})
|
||||
|
||||
// Old generation's renew result must NOT overwrite sub-B's grant.
|
||||
bogus := time.Date(1999, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
s.updateGrantedTerminationIfGen(gen, bogus)
|
||||
|
||||
assert.True(t, s.getPullPoint().GrantedTermination.IsZero(),
|
||||
"stale renew result must be discarded after a subscription rotation")
|
||||
}
|
||||
|
||||
func TestUpdateGrantedTermination_AppliesWhenGenMatches(t *testing.T) {
|
||||
s := &Stream{}
|
||||
s.setPullPoint(subscriptionRef{Address: "http://camera/sub"})
|
||||
gen := s.pullPointGen()
|
||||
t1 := time.Date(2026, 5, 27, 13, 0, 0, 0, time.UTC)
|
||||
s.updateGrantedTerminationIfGen(gen, t1)
|
||||
assert.Equal(t, t1, s.getPullPoint().GrantedTermination)
|
||||
}
|
||||
|
||||
@@ -78,6 +78,12 @@ func extractTerminationTime(body string) time.Time {
|
||||
return t
|
||||
}
|
||||
|
||||
// terminationTimeRE matches the first <*:TerminationTime> in the body.
|
||||
// Only safe on responses that contain exactly one — currently
|
||||
// CreatePullPointSubscriptionResponse and RenewResponse via
|
||||
// extractTerminationTime. PullMessagesResponse also has a
|
||||
// TerminationTime element; do not call extractTerminationTime on pull
|
||||
// bodies.
|
||||
var terminationTimeRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?TerminationTime\b[^>]*>(.*?)</(?:[^:>\s]+:)?TerminationTime>`)
|
||||
|
||||
// pullMessages returns an empty slice (no error) when the camera had
|
||||
@@ -188,8 +194,14 @@ var (
|
||||
soap12SubcodeRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?Subcode\b[^>]*>.*?<(?:[^:>\s]+:)?Value[^>]*>(.*?)</(?:[^:>\s]+:)?Value>`)
|
||||
|
||||
// WS-Security blocks may carry our Username/Password if the camera
|
||||
// echoes the request in a fault; scrub before logging.
|
||||
wsseSecurityRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?Security\b[^>]*>.*?</(?:[^:>\s]+:)?Security>`)
|
||||
// echoes the request in a fault; scrub before logging. The
|
||||
// alternation handles the truncated case where the read cap fell
|
||||
// between <Security> and </Security>: in that case nothing past
|
||||
// the opening tag is safe to retain. wssePasswordRE is the
|
||||
// belt-and-braces fallback for non-conformant cameras emitting
|
||||
// Password / UsernameToken outside a Security wrapper.
|
||||
wsseSecurityRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?Security\b[^>]*>(?:.*?</(?:[^:>\s]+:)?Security>|.*)`)
|
||||
wssePasswordRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?Password\b[^>]*>.*?</(?:[^:>\s]+:)?Password>`)
|
||||
)
|
||||
|
||||
// extractSOAPFault returns the reason text from a SOAP fault, falling
|
||||
@@ -220,39 +232,28 @@ var (
|
||||
refParamsRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?ReferenceParameters\b[^>]*>(.*?)</(?:[^:>\s]+:)?ReferenceParameters>`)
|
||||
)
|
||||
|
||||
// buildRefParamsHeader produces the SOAP <Header> inner XML for a set
|
||||
// of WS-Addressing ReferenceParameters: each ref-param element is
|
||||
// re-emitted with wsa:IsReferenceParameter="true" and any xmlns:*
|
||||
// it inherited from the parent <ReferenceParameters> element. Input
|
||||
// may be either the raw children or the full <*:ReferenceParameters>
|
||||
// wrapper — extractReferenceParameters returns the wrapper so parent-
|
||||
// scoped namespace declarations survive into the rebuild.
|
||||
func buildRefParamsHeader(rawXML string) (string, error) {
|
||||
if strings.TrimSpace(rawXML) == "" {
|
||||
// buildRefParamsHeader produces the SOAP <Header> inner XML from the
|
||||
// full <*:ReferenceParameters> element returned by
|
||||
// extractReferenceParameters. Each child element is re-emitted with
|
||||
// wsa:IsReferenceParameter="true" added and any xmlns:* declared on
|
||||
// the parent inherited onto it (so the standalone child stays valid).
|
||||
// Empty input yields empty output.
|
||||
func buildRefParamsHeader(refParamsXML string) (string, error) {
|
||||
if strings.TrimSpace(refParamsXML) == "" {
|
||||
return "", nil
|
||||
}
|
||||
doc := etree.NewDocument()
|
||||
if err := doc.ReadFromString("<wrap>" + rawXML + "</wrap>"); err != nil {
|
||||
if err := doc.ReadFromString(refParamsXML); err != nil {
|
||||
return "", fmt.Errorf("parse ref params: %w", err)
|
||||
}
|
||||
wrap := doc.SelectElement("wrap")
|
||||
if wrap == nil {
|
||||
return "", errors.New("parse ref params: missing wrap root")
|
||||
wrapper := doc.Root()
|
||||
if wrapper == nil || wrapper.Tag != "ReferenceParameters" {
|
||||
return "", fmt.Errorf("ref params root must be <*:ReferenceParameters>, got <%s>", rootTag(wrapper))
|
||||
}
|
||||
|
||||
children := wrap.ChildElements()
|
||||
var ambient *etree.Element
|
||||
if len(children) == 1 && strings.HasSuffix(children[0].Tag, "ReferenceParameters") {
|
||||
ambient = children[0]
|
||||
children = ambient.ChildElements()
|
||||
}
|
||||
|
||||
var out strings.Builder
|
||||
for _, child := range children {
|
||||
for _, child := range wrapper.ChildElements() {
|
||||
c := child.Copy()
|
||||
if ambient != nil {
|
||||
inheritXmlns(c, ambient)
|
||||
}
|
||||
inheritXmlns(c, wrapper)
|
||||
c.CreateAttr("wsa:IsReferenceParameter", "true")
|
||||
d := etree.NewDocument()
|
||||
d.SetRoot(c)
|
||||
@@ -265,6 +266,13 @@ func buildRefParamsHeader(rawXML string) (string, error) {
|
||||
return out.String(), nil
|
||||
}
|
||||
|
||||
func rootTag(e *etree.Element) string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.Tag
|
||||
}
|
||||
|
||||
// inheritXmlns copies xmlns / xmlns:* declarations from src onto dst
|
||||
// when dst doesn't already declare them, so a child whose namespace
|
||||
// prefix was declared on an ancestor stays valid in isolation.
|
||||
@@ -332,6 +340,7 @@ func enrichSOAPErr(resp *http.Response, err error) error {
|
||||
return err
|
||||
}
|
||||
body := wsseSecurityRE.ReplaceAllString(string(b), "<Security>[REDACTED]</Security>")
|
||||
body = wssePasswordRE.ReplaceAllString(body, "<Password>[REDACTED]</Password>")
|
||||
if reason := extractSOAPFault(body); reason != "" {
|
||||
return fmt.Errorf("SOAP fault: %s: %w", reason, err)
|
||||
}
|
||||
|
||||
@@ -315,7 +315,7 @@ func TestCreatePullPoint_VendorWithoutRefParams_RefParamsEmpty(t *testing.T) {
|
||||
func TestPullMessages_EchoesRefParamsWithIsReferenceParameterAttribute(t *testing.T) {
|
||||
ref := subscriptionRef{
|
||||
Address: "http://192.168.1.10/onvif/services",
|
||||
RefParamsXML: `<dom0:SubscriptionId xmlns:dom0="http://www.axis.com/2009/event">297</dom0:SubscriptionId>`,
|
||||
RefParamsXML: `<wsa:ReferenceParameters xmlns:wsa="http://www.w3.org/2005/08/addressing"><dom0:SubscriptionId xmlns:dom0="http://www.axis.com/2009/event">297</dom0:SubscriptionId></wsa:ReferenceParameters>`,
|
||||
}
|
||||
fc := newFakeCaller()
|
||||
_, err := pullMessages(fc, ref, defaultOptions())
|
||||
@@ -349,7 +349,9 @@ func TestPullMessages_PostsToAddressFromRef(t *testing.T) {
|
||||
// --- Building the header XML from raw ref params ----------------------
|
||||
|
||||
func TestBuildRefParamsHeader_AddsIsReferenceParameter(t *testing.T) {
|
||||
raw := `<dom0:SubscriptionId xmlns:dom0="http://www.axis.com/2009/event">297</dom0:SubscriptionId>`
|
||||
raw := `<wsa:ReferenceParameters xmlns:wsa="http://www.w3.org/2005/08/addressing">` +
|
||||
`<dom0:SubscriptionId xmlns:dom0="http://www.axis.com/2009/event">297</dom0:SubscriptionId>` +
|
||||
`</wsa:ReferenceParameters>`
|
||||
got, err := buildRefParamsHeader(raw)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, got, "SubscriptionId")
|
||||
@@ -360,7 +362,9 @@ func TestBuildRefParamsHeader_AddsIsReferenceParameter(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildRefParamsHeader_MultipleTopLevelChildren(t *testing.T) {
|
||||
raw := `<a:Foo xmlns:a="ns/a">1</a:Foo><b:Bar xmlns:b="ns/b">2</b:Bar>`
|
||||
raw := `<wsa:ReferenceParameters xmlns:wsa="http://www.w3.org/2005/08/addressing">` +
|
||||
`<a:Foo xmlns:a="ns/a">1</a:Foo><b:Bar xmlns:b="ns/b">2</b:Bar>` +
|
||||
`</wsa:ReferenceParameters>`
|
||||
got, err := buildRefParamsHeader(raw)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, strings.Count(got, `IsReferenceParameter="true"`),
|
||||
@@ -378,7 +382,7 @@ func TestBuildRefParamsHeader_EmptyInputReturnsEmpty(t *testing.T) {
|
||||
func TestUnsubscribePullPoint_EchoesRefParamsWithIsReferenceParameter(t *testing.T) {
|
||||
ref := subscriptionRef{
|
||||
Address: "http://192.168.1.10/onvif/services",
|
||||
RefParamsXML: `<dom0:SubscriptionId xmlns:dom0="http://www.axis.com/2009/event">297</dom0:SubscriptionId>`,
|
||||
RefParamsXML: `<wsa:ReferenceParameters xmlns:wsa="http://www.w3.org/2005/08/addressing"><dom0:SubscriptionId xmlns:dom0="http://www.axis.com/2009/event">297</dom0:SubscriptionId></wsa:ReferenceParameters>`,
|
||||
}
|
||||
fc := newFakeCaller()
|
||||
require.NoError(t, unsubscribePullPoint(fc, ref))
|
||||
@@ -401,8 +405,10 @@ func TestUnsubscribePullPoint_EmptyAddressIsNoOp(t *testing.T) {
|
||||
func TestPullMessages_TwoRefParamsEachLandsOnTheWire(t *testing.T) {
|
||||
ref := subscriptionRef{
|
||||
Address: "http://camera/sub",
|
||||
RefParamsXML: `<a:Foo xmlns:a="ns/a">1</a:Foo>` +
|
||||
`<b:Bar xmlns:b="ns/b">2</b:Bar>`,
|
||||
RefParamsXML: `<wsa:ReferenceParameters xmlns:wsa="http://www.w3.org/2005/08/addressing">` +
|
||||
`<a:Foo xmlns:a="ns/a">1</a:Foo>` +
|
||||
`<b:Bar xmlns:b="ns/b">2</b:Bar>` +
|
||||
`</wsa:ReferenceParameters>`,
|
||||
}
|
||||
fc := newFakeCaller()
|
||||
_, err := pullMessages(fc, ref, defaultOptions())
|
||||
@@ -601,3 +607,76 @@ func TestBuildRefParamsHeader_WhitespaceOnlyReturnsEmpty(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got)
|
||||
}
|
||||
|
||||
// Worst case: the response body's <Security> block starts within the
|
||||
// 64 KiB error cap but its </Security> is past it. The non-greedy
|
||||
// regex needs a close tag — without one the redaction misses and
|
||||
// raw Username/Password reaches the excerpt. Verify the helper
|
||||
// strips from <Security to EOF when no close tag is present.
|
||||
func TestEnrichSOAPErr_RedactsSecurityBlockMissingCloseTag(t *testing.T) {
|
||||
body := `<env:Envelope xmlns:env="x"><env:Header>` +
|
||||
`<wsse:Security xmlns:wsse="y">` +
|
||||
`<wsse:Username>admin</wsse:Username>` +
|
||||
`<wsse:Password>hunter2</wsse:Password>` +
|
||||
// no </wsse:Security> — simulates a Security block truncated
|
||||
// at the 64 KiB read cap.
|
||||
strings.Repeat("padding ", 1000)
|
||||
got := enrichSOAPErr(fakeResponse(body), errors.New("500"))
|
||||
require.Error(t, got)
|
||||
assert.NotContains(t, got.Error(), "hunter2",
|
||||
"truncated Security block must not leak Password to the excerpt")
|
||||
assert.NotContains(t, got.Error(), "admin",
|
||||
"truncated Security block must not leak Username to the excerpt")
|
||||
}
|
||||
|
||||
// The wrapper-only contract means an element whose local name merely
|
||||
// ends in "ReferenceParameters" cannot be mistaken for the wrapper —
|
||||
// the root must be exactly <*:ReferenceParameters>. Anything else is
|
||||
// a contract violation by the caller and surfaces as an error.
|
||||
func TestBuildRefParamsHeader_RejectsNonReferenceParametersRoot(t *testing.T) {
|
||||
raw := `<my:MyReferenceParameters xmlns:my="urn:vendor:my">X</my:MyReferenceParameters>`
|
||||
_, err := buildRefParamsHeader(raw)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "ReferenceParameters")
|
||||
}
|
||||
|
||||
// A non-conformant camera echoing UsernameToken/Password outside a
|
||||
// <Security> wrapper would still leak credentials through the body
|
||||
// excerpt. Belt-and-braces: redact Password elements directly too.
|
||||
func TestEnrichSOAPErr_RedactsBarePasswordElement(t *testing.T) {
|
||||
body := `<env:Envelope xmlns:env="x"><env:Body>` +
|
||||
`<wsse:UsernameToken xmlns:wsse="y">` +
|
||||
`<wsse:Username>admin</wsse:Username>` +
|
||||
`<wsse:Password>hunter2</wsse:Password>` +
|
||||
`</wsse:UsernameToken>` +
|
||||
`</env:Body></env:Envelope>`
|
||||
got := enrichSOAPErr(fakeResponse(body), errors.New("400"))
|
||||
require.Error(t, got)
|
||||
assert.NotContains(t, got.Error(), "hunter2",
|
||||
"Password must be redacted regardless of whether it's wrapped in Security")
|
||||
}
|
||||
|
||||
// --- Edge-case coverage flagged in review -----------------------------
|
||||
|
||||
func TestExtractTerminationTime_MalformedDateYieldsZero(t *testing.T) {
|
||||
body := `<env:Body><wsnt:TerminationTime>not-a-date</wsnt:TerminationTime></env:Body>`
|
||||
assert.True(t, extractTerminationTime(body).IsZero(),
|
||||
"unparseable datetime must not panic and must not return a garbage time — fall back to opts")
|
||||
}
|
||||
|
||||
func TestNextRenewInterval_MarginEqualsBaseFallsToHalf(t *testing.T) {
|
||||
now := time.Date(2026, 5, 27, 13, 0, 0, 0, time.UTC)
|
||||
opts := Options{InitialTermination: 30 * time.Second, RenewMargin: 30 * time.Second}
|
||||
got := nextRenewInterval(time.Time{}, opts, now)
|
||||
assert.Equal(t, 15*time.Second, got,
|
||||
"when margin == base, the helper must fall through to base/2 rather than the 1s floor")
|
||||
}
|
||||
|
||||
func TestExtractReferenceParameters_EmptySubscriptionReferenceReturnsEmpty(t *testing.T) {
|
||||
body := `<env:Envelope xmlns:env="x" xmlns:tev="y">
|
||||
<env:Body><tev:CreatePullPointSubscriptionResponse>
|
||||
<tev:SubscriptionReference/>
|
||||
</tev:CreatePullPointSubscriptionResponse></env:Body>
|
||||
</env:Envelope>`
|
||||
assert.Empty(t, extractReferenceParameters(body))
|
||||
}
|
||||
|
||||
@@ -165,8 +165,9 @@ type Stream struct {
|
||||
caller caller
|
||||
opts Options
|
||||
|
||||
pullPointMu sync.Mutex
|
||||
pullPoint subscriptionRef
|
||||
pullPointMu sync.Mutex
|
||||
pullPoint subscriptionRef
|
||||
gen uint64 // bumped on every setPullPoint so renews can detect a recreate
|
||||
|
||||
events chan Event
|
||||
errors chan error
|
||||
@@ -191,11 +192,29 @@ func (s *Stream) setPullPoint(ref subscriptionRef) {
|
||||
s.pullPointMu.Lock()
|
||||
defer s.pullPointMu.Unlock()
|
||||
s.pullPoint = ref
|
||||
s.gen++
|
||||
}
|
||||
|
||||
func (s *Stream) updateGrantedTermination(t time.Time) {
|
||||
// pullPointGen returns the current generation. Pair with
|
||||
// updateGrantedTerminationIfGen so a renew result issued against a
|
||||
// subscription that was rotated mid-flight (recreate path) is
|
||||
// discarded instead of overwriting the new subscription's grant.
|
||||
func (s *Stream) pullPointGen() uint64 {
|
||||
s.pullPointMu.Lock()
|
||||
defer s.pullPointMu.Unlock()
|
||||
return s.gen
|
||||
}
|
||||
|
||||
// updateGrantedTerminationIfGen writes the granted time only when the
|
||||
// caller's snapshot is still current. Use setPullPoint to replace the
|
||||
// full ref; this updates GrantedTermination in place after a renew
|
||||
// response.
|
||||
func (s *Stream) updateGrantedTerminationIfGen(gen uint64, t time.Time) {
|
||||
s.pullPointMu.Lock()
|
||||
defer s.pullPointMu.Unlock()
|
||||
if s.gen != gen {
|
||||
return
|
||||
}
|
||||
s.pullPoint.GrantedTermination = t
|
||||
}
|
||||
|
||||
|
||||
@@ -125,6 +125,10 @@ func (f *fakeCaller) sendSoapCallCount() int {
|
||||
|
||||
// createPullPointResp is the minimal SOAP envelope the lib's existing
|
||||
// xml.Decoder + getXMLNode path can extract a pull-point address from.
|
||||
// Intentionally omits <TerminationTime> so renewLoop falls back to
|
||||
// opts.InitialTermination — tests that drive renew timing depend on
|
||||
// that path. Tests that need the camera-granted termination capture
|
||||
// path use a dedicated fixture instead.
|
||||
const createPullPointResp = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope"
|
||||
xmlns:wsa="http://www.w3.org/2005/08/addressing"
|
||||
|
||||
@@ -82,7 +82,7 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
res, err := dev.SendSoap(endPoint, string(requestBody))
|
||||
res, err := dev.SendSoapWithOptions(endPoint, string(requestBody))
|
||||
if err != nil {
|
||||
log.Fatalln("fail to CallMethod:", err)
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
res, err := dev.SendSoap(endPoint, string(requestBody))
|
||||
res, err := dev.SendSoapWithOptions(endPoint, string(requestBody))
|
||||
if err != nil {
|
||||
log.Fatalln("fail to CallMethod:", err)
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
res, err := dev.SendSoap(endPoint, string(requestBody))
|
||||
res, err := dev.SendSoapWithOptions(endPoint, string(requestBody))
|
||||
if err != nil {
|
||||
log.Fatalln("fail to CallMethod:", err)
|
||||
}
|
||||
|
||||
2
go.mod
2
go.mod
@@ -11,6 +11,7 @@ require (
|
||||
github.com/icholy/digest v0.1.23
|
||||
github.com/juju/errors v1.0.0
|
||||
github.com/stretchr/testify v1.8.4
|
||||
go.uber.org/goleak v1.3.0
|
||||
golang.org/x/net v0.19.0
|
||||
)
|
||||
|
||||
@@ -34,7 +35,6 @@ require (
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
go.uber.org/goleak v1.3.0 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/crypto v0.16.0 // indirect
|
||||
golang.org/x/sys v0.15.0 // indirect
|
||||
|
||||
@@ -2,6 +2,7 @@ package gosoap
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"log"
|
||||
|
||||
"github.com/beevik/etree"
|
||||
@@ -128,7 +129,13 @@ func (msg *SoapMessage) AddBodyContents(elements []*etree.Element) {
|
||||
*msg = SoapMessage(res)
|
||||
}
|
||||
|
||||
//AddStringHeaderContent for Envelope body
|
||||
// AddStringHeaderContent appends a single root element to the SOAP
|
||||
// Header. Use AddStringHeaderContents (plural) when the content
|
||||
// contains multiple sibling elements — for example WS-Addressing
|
||||
// reference parameters, which the spec requires as separate header
|
||||
// blocks. The two coexist for backwards compatibility: external
|
||||
// consumers of this library may rely on AddStringHeaderContent's
|
||||
// single-root constraint and the matching error on multi-root input.
|
||||
func (msg *SoapMessage) AddStringHeaderContent(data string) error {
|
||||
doc := etree.NewDocument()
|
||||
|
||||
@@ -156,6 +163,40 @@ func (msg *SoapMessage) AddStringHeaderContent(data string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddStringHeaderContents is the multi-root variant of
|
||||
// AddStringHeaderContent: it accepts any number of top-level sibling
|
||||
// elements (zero is an error) and appends each as its own SOAP Header
|
||||
// child. Needed because WS-Addressing 1.0 §3.1 requires each
|
||||
// reference parameter to be a separate Header block, but a Go XML
|
||||
// document only has one root. Comments and text outside elements are
|
||||
// silently dropped.
|
||||
func (msg *SoapMessage) AddStringHeaderContents(data string) error {
|
||||
in := etree.NewDocument()
|
||||
if err := in.ReadFromString("<wrap>" + data + "</wrap>"); err != nil {
|
||||
return err
|
||||
}
|
||||
wrap := in.SelectElement("wrap")
|
||||
if wrap == nil {
|
||||
return errors.New("AddStringHeaderContents: missing wrap root")
|
||||
}
|
||||
children := wrap.ChildElements()
|
||||
if len(children) == 0 {
|
||||
return errors.New("AddStringHeaderContents: no element children in content")
|
||||
}
|
||||
|
||||
doc := etree.NewDocument()
|
||||
if err := doc.ReadFromString(msg.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
header := doc.Root().SelectElement("Header")
|
||||
for _, child := range children {
|
||||
header.AddChild(child.Copy())
|
||||
}
|
||||
res, _ := doc.WriteToString()
|
||||
*msg = SoapMessage(res)
|
||||
return nil
|
||||
}
|
||||
|
||||
//AddHeaderContent for Envelope body
|
||||
func (msg *SoapMessage) AddHeaderContent(element *etree.Element) {
|
||||
doc := etree.NewDocument()
|
||||
|
||||
Reference in New Issue
Block a user