Back to Blog
high SEVERITY9 min read

CWE-778: Tribute WebHookHandler Logs No Subscription Events

The Tribute payment webhook handler verified request signatures and then mutated subscription state — granting new subscriptions and cancelling existing ones — while logging nothing on success. Only error paths and a no-op test hook produced log lines, so a forged or replayed `NewSubscription` event that passed signature verification left no record of which `TelegramUserID` was affected. The fix adds structured `slog.Info` records at signature verification and at both state-changing branches.

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published October 13, 2026•Reviewed October 13, 2026

Answer Summary

The affected API is the `WebHookHandler()` http.Handler in the first-party `tribute` package (Go); there is no published package version range. Before the fix, an attacker who could produce a request that passed signature verification — via a leaked signing secret or a replayed delivery — could grant themselves a paid subscription or cancel another user's, and the service would record nothing: no event name, no `TelegramUserID`, no amount, no timestamp, leaving responders with only end-state database rows and no provenance. The fix adds three structured `slog.Info` calls — one after signature verification and one in each of the `NewSubscription` and `CancelledSubscription` branches — carrying the event name, `TelegramUserID`, `Period`, and `Amount`. This is CWE-778 (Insufficient Logging); it is defence-in-depth hardening rather than a directly exploitable flaw, and no CVE or GHSA has been assigned.

Vulnerability at a Glance

cweCWE-778
fixAdded structured `slog.Info` records after signature verification and in both state-changing switch branches, including event name, `TelegramUserID`, `Period`, and `Amount`
riskSubscription grants and cancellations were unattributable after the fact; forged or replayed webhook deliveries left no forensic trace
languageGo
root cause`WebHookHandler()` logged only error returns and the `TestHook` no-op, never the successful `NewSubscription` / `CancelledSubscription` state changes
vulnerabilityInsufficient logging of security-relevant payment webhook events

Summary

The Tribute payment webhook handler verified request signatures and then mutated subscription state — granting new subscriptions and cancelling existing ones — while logging nothing on success. Only error paths and a no-op test hook produced log lines, so a forged or replayed NewSubscription event that passed signature verification left no record of which TelegramUserID was affected. The fix adds structured slog.Info records at signature verification and at both state-changing branches.

Introduction

A payment webhook is one of the few HTTP endpoints in a typical service where an unauthenticated third party can cause a privilege change. In the tribute package, WebHookHandler() returns an http.Handler that reads a signed webhook body, verifies it, and then switches on wh.Name:

  • NewSubscription → c.newSubscriptionHandler(ctx, wh) — the user gains paid access
  • CancelledSubscription → c.cancelSubscriptionHandler(ctx, wh) — the user loses it
  • TestHook → logs "Tribute webhook working" and does nothing else

Read that list again in terms of observability. The branch that does nothing wrote a log line. The two branches that changed a user's entitlement wrote nothing at all unless they failed. Every successful grant and every successful revocation was invisible.

That asymmetry is CWE-778, Insufficient Logging, and it is the kind of finding that costs nothing until the day it costs everything: the first time someone asks "when did this account get a subscription, and which webhook delivery did it come from?", the answer is a row in a table with no provenance attached.

Affected Versions

Affected not applicable (first-party code) — the WebHookHandler() http.Handler in the tribute package
Fixed in not applicable (first-party code) — fixed by the hardening change described below
Ecosystem go
CVE / GHSA not assigned
CWE CWE-778 (Insufficient Logging)

No package version range applies: this is first-party application code, hardened in place rather than shipped as a dependency upgrade.

The Vulnerability Explained

The silent success path

Here is the shape of the handler before the change, reduced to the relevant control flow:

switch wh.Name {
case NewSubscription:
    err := c.newSubscriptionHandler(ctx, wh)
    if err != nil {
        http.Error(w, "internal server error", http.StatusInternalServerError)
        return
    }
case CancelledSubscription:
    err := c.cancelSubscriptionHandler(ctx, wh)
    // ... ErrCustomerNotFound and generic error handling ...
case TestHook:
    slog.Info("Tribute webhook working")
}

The problematic pattern is the absence in the first two cases: once err == nil, control falls out of the switch and the handler returns 200. There is no record that a webhook named NewSubscription arrived, no record of wh.Payload.TelegramUserID, no record of wh.Payload.Amount or wh.Payload.Period, and no timestamped marker that signature verification passed for this particular delivery.

Everything upstream of the switch — signature verification, body decoding — also logged only on failure. So the full log record of a successful subscription grant was: nothing.

Why that matters on this specific endpoint

Signature verification is the only thing standing between the public internet and newSubscriptionHandler. That control can degrade in several realistic ways:

  • Secret leakage. The Tribute signing secret ends up in a leaked .env, a CI log, or a container image layer. An attacker can now sign arbitrary payloads.
  • Replay. A valid delivery is captured — from an upstream proxy log, a misconfigured debug sink, an SSRF-reachable internal endpoint — and resent. If the handler is idempotent only by accident, a replayed NewSubscription can extend or re-grant access.
  • Payload tampering within a valid signature scheme. If verification covers less of the request than you think (a subset of fields, a canonicalisation quirk), an attacker can swap wh.Payload.TelegramUserID to a target account.

In each case the attack is the same two-step: produce one request that passes verification, then walk away. Pre-fix, the consequences were asymmetric in the attacker's favour:

  1. Grant. Send a signed NewSubscription with TelegramUserID set to the attacker's own Telegram account. The user now has paid access with no billing record and no log line linking the entitlement to an inbound request. Reconciliation against the payment provider would show a subscription that the provider never issued, but nothing in the application's own telemetry would say when or how it appeared.
  2. Revoke. Send a signed CancelledSubscription with a victim's TelegramUserID. The victim loses access. Support sees a paying customer whose subscription row is gone, with no event trail explaining it — which looks exactly like a database bug, sending the investigation in the wrong direction.

The real-world impact is not confidentiality or integrity in the classic sense; it is non-repudiation and incident response. A service running this handler could not answer, from its own logs:

  • How many subscription grants happened in a given window?
  • Which TelegramUserID values were granted or revoked, and in what order?
  • Did the anomalous grant arrive as a verified webhook, or was it written by some other code path?
  • Was there a burst of verified deliveries consistent with secret compromise?

Error-only logging has a specific failure mode here: it is loudest when the attacker is unsuccessful. A probing attacker who sends malformed payloads generates log noise. An attacker who gets it right generates silence. That is the opposite of what you want from a security log.

The Fix

The change adds three structured slog.Info records — one observability checkpoint for each security-relevant decision the handler makes.

Before (state change, no record):

case NewSubscription:
    err := c.newSubscriptionHandler(ctx, wh)
    if err != nil {
        http.Error(w, "internal server error", http.StatusInternalServerError)
        return
    }

After:

slog.Info("webhook: signature verified", "event", wh.Name, "telegram_id", wh.Payload.TelegramUserID)

switch wh.Name {
case NewSubscription:
    err := c.newSubscriptionHandler(ctx, wh)
    if err != nil {
        http.Error(w, "internal server error", http.StatusInternalServerError)
        return
    }
    slog.Info("webhook: new subscription processed", "telegram_id", wh.Payload.TelegramUserID, "period", wh.Payload.Period, "amount", wh.Payload.Amount)

And in the cancellation branch:

slog.Info("webhook: subscription cancelled", "telegram_id", wh.Payload.TelegramUserID)

Each of the three lines is there for a distinct reason:

1. "webhook: signature verified", placed immediately after verification and before the switch. This is the authentication event. It records that a cryptographically valid delivery was accepted, along with wh.Name and the target telegram_id. Crucially it fires for every accepted event, including TestHook and any event name not handled by the switch — so an unknown or unexpected wh.Name reaching the handler is now visible, where previously it fell through the switch in total silence. This is also the line that makes secret-compromise detection possible: an unusual rate or pattern of verified deliveries is now a countable signal.

2. "webhook: new subscription processed", after newSubscriptionHandler returns nil. This is the authorisation outcome — the moment access was actually granted. It carries telegram_id, period, and amount, which is exactly the tuple needed to reconcile an entitlement against the payment provider's record. Placing it after the error check is deliberate: the line asserts that the state change succeeded, not merely that it was attempted, so the log cannot claim a grant that was rolled back.

3. "webhook: subscription cancelled", after the cancellation handler's error checks. Revocation is as security-relevant as grant, and arguably harder to diagnose without logs — a missing subscription looks like data loss. Note this line sits after the payment.ErrCustomerNotFound branch, so a verified cancellation for an unknown customer is still visible via the "signature verified" line plus the existing error handling, and is distinguishable from a cancellation that actually took effect.

Together the three lines turn one indistinguishable 200 OK into a correlatable sequence: verified → processed. The gap between those two states is now where a bug or an abort shows up, instead of being invisible.

The fields were chosen to be useful without being dangerous. telegram_id, period, and amount are subscription terms suitable for retention and reconciliation. The handler logs no signature material, no raw request body, and no secret. Using slog's key/value form rather than string interpolation means these records are queryable — you can filter on telegram_id across a time window instead of grepping formatted prose.

One honest caveat, as noted in the hardening PR: this is defence-in-depth. No exploitable path through signature verification was demonstrated. The change does not make the endpoint harder to attack; it makes the attack detectable and reconstructable, and bounds the failure mode of the controls that are already there.

Key Takeaways

  • Log the success path on any endpoint that mutates entitlements. newSubscriptionHandler and cancelSubscriptionHandler changed who could pay-gate access; error-only logging meant the handler was loudest when attacks failed and silent when they worked.
  • If your no-op branch logs and your state-changing branches don't, the logging is backwards. The TestHook case called slog.Info("Tribute webhook working") while NewSubscription wrote nothing — a clear signal that logging was added for debugging, not for audit.
  • Log the authentication decision separately from the business outcome. "webhook: signature verified" fires before the switch and so also captures event names the switch doesn't handle; "webhook: new subscription processed" fires only after a nil error, so it asserts a completed state change rather than an attempt.
  • Include the correlation keys, not just the verdict. Carrying wh.Payload.TelegramUserID, Period, and Amount is what makes a log line reconcilable against the payment provider; a bare "subscription created" message answers none of the questions an investigation asks.
  • Use slog's structured key/value arguments for audit events. "telegram_id", wh.Payload.TelegramUserID is filterable at query time; the same information interpolated into a message string is not.

How Orbis AppSec Detected This

  • Source: the signed webhook request body delivered to the http.Handler returned by WebHookHandler(), decoded into the webhook struct's Name and Payload fields (TelegramUserID, Period, Amount).
  • Sink: the state-changing calls newSubscriptionHandler(ctx, wh) and cancelSubscriptionHandler(ctx, wh), which grant and revoke paid subscription access.
  • Missing control: no audit record on the success path — neither the accepted-signature event nor the completed subscription grant or cancellation emitted a log line, so a verified delivery that modified entitlements produced no telemetry at all. Only err != nil paths and the TestHook no-op logged.
  • CWE: CWE-778 — Insufficient Logging.
  • Fix: three structured slog.Info records were added — one after signature verification carrying wh.Name and telegram_id, and one in each of the NewSubscription and CancelledSubscription branches after their error checks, carrying telegram_id plus period and amount for the grant.

Orbis AppSec detected this vulnerability automatically. Try Orbis AppSec on your repositories to find and fix issues like this.

Conclusion

The tribute webhook handler was doing the hard part correctly — verifying signatures before touching subscription state — and then throwing away the evidence. Every successful grant via newSubscriptionHandler and every successful revocation via cancelSubscriptionHandler returned 200 OK with no record of the event name, the affected TelegramUserID, or the subscription terms, while a connectivity test that changed nothing logged happily.

Three slog.Info lines close that gap: one proving a signature was accepted, one proving access was granted with its period and amount, one proving access was revoked. None of them stops an attacker who holds the signing secret. All of them mean that when someone eventually asks which Telegram account got a subscription it never paid for, and when, the service can answer.

If you maintain a payment or entitlement webhook, run the same check: comment out your error handlers mentally and ask what the logs would show for a request that succeeded. If the answer is nothing, that's CWE-778, and it's cheap to fix before you need it.

Prevention and further reading

Frequently Asked Questions

Why did the `TestHook` branch already log while `NewSubscription` did not?

The `TestHook` case existed purely to confirm connectivity, so it called `slog.Info("Tribute webhook working")`. The inversion is the point: the branch that changes nothing was observable, while the branches that grant and revoke paid access were silent.

Does logging `wh.Payload.Amount` and `wh.Payload.Period` put payment data in the logs?

`Amount` and `Period` are subscription terms, not card data or the webhook signing secret, so they are safe to retain and are exactly what you need to reconcile an entitlement against a billing record. The handler still logs no signature material and no raw request body.

If `cancelSubscriptionHandler` returns `payment.ErrCustomerNotFound`, is anything logged now?

That path short-circuits before the new `slog.Info("webhook: subscription cancelled", ...)` call, but it is still covered by the pre-existing error handling plus the new "signature verified" line, so you can see that a verified cancellation arrived for a `TelegramUserID` that had no customer record.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #3

Related Articles

medium

path-to-regexp 0.1.12 DoS: Catastrophic Backtracking on Malformed URL

path-to-regexp version 0.1.12 contains a regular expression engine vulnerability where maliciously crafted URL parameters trigger exponential backtracking, causing CPU exhaustion and service unavailability. Upgrading to version 0.1.13 eliminates the vulnerable pattern from the parsing logic.

critical

CVE-2026-59873: node-tar 7.5.11 DoS via Crafted Gzip Bomb

node-tar versions 7.5.11 through 7.5.18 are vulnerable to a denial-of-service attack through maliciously crafted gzip archives that decompress to disproportionately large sizes. An attacker can exploit this to exhaust memory and CPU resources by submitting a small, highly compressed archive that expands beyond configured limits during extraction.

high

MapManager.get() Race Condition Duplicates API Requests

The MapManager's `get(mapUid, cache)` method used a check-then-act pattern that permitted multiple concurrent requests to pass the cache miss check simultaneously, triggering redundant API calls and risking cache corruption. The fix introduces a `_pending` promise map to deduplicate in-flight fetches for identical map UIDs.

critical

Lampa Desktop Auto-Update Heuristic Bypass: Execution of Unverified

Lampa Desktop's auto-update mechanism downloaded JavaScript and CSS from `raw.githubusercontent.com` using only heuristic validation—file size thresholds and string pattern matching—that attackers could trivially satisfy. The fix introduces cryptographic integrity verification by cross-referencing Git blob hashes from the GitHub Contents API, ensuring downloaded code matches the repository's authoritative state before execution.

high

adm-zip 0.6.0 Preserves SUID Bits From ZIPs: CVE-2026-102282

The `adm-zip` dependency resolved to 0.6.0 in this project's dependency tree, a version affected by CVE-2026-102282: during extraction it applies the Unix permission bits stored in each ZIP entry's external file attributes verbatim, including the setuid (`04000`), setgid (`02000`), and sticky bits. An attacker who controls an archive passed to `extractAllTo()` or `extractEntryTo()` can therefore have the extractor create a setuid binary owned by whatever user the extraction process runs as. The

high

file_md5() MD5 Digest Lets Agent Docs Collide (CWE-328)

A first-party maintenance script used `hashlib.md5()` inside a `file_md5(path)` helper to decide whether the per-vendor agent instruction documents in a repository were byte-equivalent. Because MD5 is collision-broken, two meaningfully different documents could be crafted to produce the same digest and pass the "these files are in sync" gate. The fix replaces the MD5 call with `hashlib.sha256()` over the same `path.read_bytes()` input.