diff --git a/Device.go b/Device.go index 628d242..9c89b14 100644 --- a/Device.go +++ b/Device.go @@ -349,9 +349,11 @@ func (dev Device) SendSoap(endpoint string, xmlRequestBody string) (*http.Respon // 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 -// or in the header forwards verbatim and -// may override envelope defaults under our credentials. +// API assumes the caller is authoritative for the envelope. Header +// content forwards verbatim — among others, overrides +// auth, overrides intent, // +// redirect responses, enables replay- +// token forgery, and bypasses freshness checks. func (dev Device) SendSoapWithHeader(endpoint, xmlRequestBody, xmlHeaderContent string) (*http.Response, error) { return dev.SendSoapWithOptions(endpoint, xmlRequestBody, WithSOAPHeader(xmlHeaderContent)) } diff --git a/event/stream/renew.go b/event/stream/renew.go index c7c6969..3e29edf 100644 --- a/event/stream/renew.go +++ b/event/stream/renew.go @@ -19,15 +19,14 @@ import ( // reliable recovery once a subscription is GC'd. func (s *Stream) renewLoop(ctx context.Context) { for { - ref := s.getPullPoint() - gen := s.pullPointGen() + ref, gen := s.snapshotPullPoint() 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())) { + if !sleepCtx(ctx, nextRenewIntervalAfterError(s.opts)) { return } continue @@ -58,12 +57,12 @@ 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. +// nextRenewIntervalAfterError returns the post-failure sleep. The +// grant is typically already in the past by the time renew has failed +// once, so nextRenewInterval would floor to 1s and hammer 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 { +func nextRenewIntervalAfterError(opts Options) time.Duration { if opts.RetryBackoff > 0 { return opts.RetryBackoff } diff --git a/event/stream/renew_test.go b/event/stream/renew_test.go index 0cb86ab..c9a29e6 100644 --- a/event/stream/renew_test.go +++ b/event/stream/renew_test.go @@ -209,20 +209,17 @@ func TestRenewPullPoint_EchoesRefParamsWithIsReferenceParameter(t *testing.T) { // 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) +func TestNextRenewIntervalAfterError_BacksOffAtLeastRetryBackoff(t *testing.T) { + opts := Options{RetryBackoff: time.Second} + got := nextRenewIntervalAfterError(opts) 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) +func TestNextRenewIntervalAfterError_DefaultsToOneSecondWhenRetryBackoffZero(t *testing.T) { + got := nextRenewIntervalAfterError(Options{}) + assert.Equal(t, time.Second, got, + "zero RetryBackoff must yield the safety floor, not a tight 0-duration sleep") } // Lost-update race: renew snapshots the ref, the SOAP call returns, @@ -231,6 +228,8 @@ func TestNextRenewIntervalAfterError_FallsBackToRetryBackoff(t *testing.T) { // 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.now is intentionally nil — this test does not exercise any + // time-dependent path; only the gen-counter accessors. s := &Stream{} original := subscriptionRef{Address: "http://camera/sub-A"} s.setPullPoint(original) @@ -248,6 +247,7 @@ func TestUpdateGrantedTermination_DropsWriteWhenSubscriptionRotated(t *testing.T } func TestUpdateGrantedTermination_AppliesWhenGenMatches(t *testing.T) { + // s.now is intentionally nil — gen-counter path only. s := &Stream{} s.setPullPoint(subscriptionRef{Address: "http://camera/sub"}) gen := s.pullPointGen() @@ -255,3 +255,23 @@ func TestUpdateGrantedTermination_AppliesWhenGenMatches(t *testing.T) { s.updateGrantedTerminationIfGen(gen, t1) assert.Equal(t, t1, s.getPullPoint().GrantedTermination) } + +// renewLoop must capture (ref, gen) atomically. Two separate +// getPullPoint() + pullPointGen() reads leave a window in which a +// concurrent setPullPoint advances gen between the two reads — the +// renew then runs against ref-N but believes its captured gen is N+1, +// and updateGrantedTerminationIfGen("succeeds") writing the old +// subscription's grant onto the new one. Single snapshot closes it. +func TestSnapshotPullPoint_AtomicReadOfRefAndGen(t *testing.T) { + // s.now is intentionally nil — gen-counter path only. + s := &Stream{} + s.setPullPoint(subscriptionRef{Address: "A"}) // gen=1 + ref, gen := s.snapshotPullPoint() + assert.Equal(t, "A", ref.Address) + assert.Equal(t, uint64(1), gen) + + s.setPullPoint(subscriptionRef{Address: "B"}) // gen=2 + ref, gen = s.snapshotPullPoint() + assert.Equal(t, "B", ref.Address) + assert.Equal(t, uint64(2), gen) +} diff --git a/event/stream/soap.go b/event/stream/soap.go index 1cc7555..d8a1e7f 100644 --- a/event/stream/soap.go +++ b/event/stream/soap.go @@ -197,11 +197,14 @@ var ( // echoes the request in a fault; scrub before logging. The // alternation handles the truncated case where the read cap fell // between and : 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. + // the opening tag is safe to retain — the replacement re-emits a + // synthetic close tag, dropping the remainder of the body excerpt + // (max-redact preferred to max-context for log lines). + // wssePasswordRE is the belt-and-braces fallback for + // non-conformant cameras emitting Password / UsernameToken outside + // a Security wrapper; same truncation handling. wsseSecurityRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?Security\b[^>]*>(?:.*?\s]+:)?Security>|.*)`) - wssePasswordRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?Password\b[^>]*>.*?\s]+:)?Password>`) + wssePasswordRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?Password\b[^>]*>(?:.*?\s]+:)?Password>|.*)`) ) // extractSOAPFault returns the reason text from a SOAP fault, falling @@ -247,8 +250,11 @@ func buildRefParamsHeader(refParamsXML string) (string, error) { return "", fmt.Errorf("parse ref params: %w", err) } wrapper := doc.Root() - if wrapper == nil || wrapper.Tag != "ReferenceParameters" { - return "", fmt.Errorf("ref params root must be <*:ReferenceParameters>, got <%s>", rootTag(wrapper)) + if wrapper == nil { + return "", errors.New("ref params has no root element") + } + if wrapper.Tag != "ReferenceParameters" { + return "", fmt.Errorf("ref params root must be <*:ReferenceParameters>, got <%s>", wrapper.Tag) } var out strings.Builder for _, child := range wrapper.ChildElements() { @@ -266,13 +272,6 @@ func buildRefParamsHeader(refParamsXML 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. diff --git a/event/stream/soap_test.go b/event/stream/soap_test.go index 8289c05..153e2c1 100644 --- a/event/stream/soap_test.go +++ b/event/stream/soap_test.go @@ -680,3 +680,42 @@ func TestExtractReferenceParameters_EmptySubscriptionReferenceReturnsEmpty(t *te ` assert.Empty(t, extractReferenceParameters(body)) } + +func TestEnrichSOAPErr_RedactsTruncatedPasswordOutsideSecurity(t *testing.T) { + body := `` + + `` + + `admin` + + `hunter2` // truncated — no , no , no + got := enrichSOAPErr(fakeResponse(body), errors.New("500")) + require.Error(t, got) + assert.NotContains(t, got.Error(), "hunter2", + "Password without a closing tag (truncated at cap) must still be redacted") +} + +func TestEnrichSOAPErr_RedactsMultipleSecurityBlocks(t *testing.T) { + body := `` + + `secret1` + + `secret2` + + `` + got := enrichSOAPErr(fakeResponse(body), errors.New("500")) + require.Error(t, got) + assert.NotContains(t, got.Error(), "secret1") + assert.NotContains(t, got.Error(), "secret2", + "ReplaceAllString must catch every Security block, not just the first") +} + +func TestBuildRefParamsHeader_RejectsNonReferenceParametersRoot_Table(t *testing.T) { + cases := []struct { + name, raw string + }{ + {"vendor suffix", `X`}, + {"multi-root", ``}, + {"unrelated element", `content`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := buildRefParamsHeader(c.raw) + require.Error(t, err) + }) + } +} diff --git a/event/stream/stream.go b/event/stream/stream.go index 49d3aeb..9dac55b 100644 --- a/event/stream/stream.go +++ b/event/stream/stream.go @@ -165,9 +165,9 @@ type Stream struct { caller caller opts Options - pullPointMu sync.Mutex - pullPoint subscriptionRef - gen uint64 // bumped on every setPullPoint so renews can detect a recreate + pullPointMu sync.Mutex // guards pullPoint and gen + pullPoint subscriptionRef + gen uint64 // bumped on every setPullPoint so renews detect mid-flight recreate events chan Event errors chan error @@ -205,10 +205,20 @@ func (s *Stream) pullPointGen() uint64 { return s.gen } +// snapshotPullPoint reads ref + gen under one lock so a concurrent +// setPullPoint can't slip in between two separate accessor calls and +// leave the caller with mismatched halves. +func (s *Stream) snapshotPullPoint() (subscriptionRef, uint64) { + s.pullPointMu.Lock() + defer s.pullPointMu.Unlock() + return s.pullPoint, 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. +// response. Intentionally does not bump gen — that would defeat the +// rotation-detection it implements. func (s *Stream) updateGrantedTerminationIfGen(gen uint64, t time.Time) { s.pullPointMu.Lock() defer s.pullPointMu.Unlock() diff --git a/gosoap/soap-builder.go b/gosoap/soap-builder.go index 4d053f6..d86fc7f 100644 --- a/gosoap/soap-builder.go +++ b/gosoap/soap-builder.go @@ -170,6 +170,10 @@ func (msg *SoapMessage) AddStringHeaderContent(data string) error { // 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. +// +// SECURITY: data forwards verbatim into the outbound envelope. Do not +// pass content sourced from untrusted clients — see the same caveat +// on onvif.Device.SendSoapWithOptions / SendSoapWithHeader. func (msg *SoapMessage) AddStringHeaderContents(data string) error { in := etree.NewDocument() if err := in.ReadFromString("" + data + ""); err != nil {