Skip to content

fix(webhooks): a queue worker signed lifecycle webhooks with a stale secret - #245

Open
roncodes wants to merge 1 commit into
mainfrom
fix/issue-244-lifecycle-webhook-session-bleed
Open

fix(webhooks): a queue worker signed lifecycle webhooks with a stale secret#245
roncodes wants to merge 1 commit into
mainfrom
fix/issue-244-lifecycle-webhook-session-bleed

Conversation

@roncodes

Copy link
Copy Markdown
Member

Fixes #244.

The bug

SendResourceLifecycleWebhook::setSessionFromEvent() only wrote a session key when it was absent, and handle() then preferred the session value over the event's:

if (!session()->has('api_secret')) {
    session()->put('api_secret', $event->apiSecret);
}
// ...
$apiSecret = session()->get('api_secret', $event->apiSecret ?? 'internal');

A queue:work process is long-running and its session store is a container singleton, so it survives between jobs. Once the worker handled one lifecycle event, every later event reused that first event's api_secret — signing outbound webhooks with the wrong HMAC key — plus its api_credential, api_key, api_environment, is_sandbox, company and user. Restarting the worker only masked it.

The reporter verified this against Fleetbase API v0.7.53 / core-api v1.6.55, and the same logic was still present on v1.6.59.

The fix

The context serialized onto the event is now authoritative for the job that carries it:

  • resolveEventContext() resolves the event's context once, applying the defaults (console / internal / live) explicitly instead of relying on session()->get()'s fallback argument, which never fired for a key that was present-but-stale.
  • The webhook body, signature, ApiEvent row and WebhookRequestLog are all built from that resolved context — no session reads in the send path.
  • The context is still written to the session unconditionally, because downstream code (model scopes, observers, resources) reads it from there — but only for the duration of the job. A restorer returned by setSessionFromEvent() runs in a finally, putting back whatever the session held before and removing keys that were not set, so the next job starts clean and a synchronous (non-queued) run no longer leaves api_secret/api_credential behind in a real user's session.

setSessionFromEvent($event) stays public and keeps its signature; it now overwrites rather than backfills, and returns the restore callable.

Tests

vendor/bin/pest — 1415 passed. The 6 failures in Tests\Unit\Http\RequestContractsTest and RequestValidationBehaviorTest are pre-existing and reproduce identically on main; they are unrelated to this change. php-cs-fixer and phpstan are clean on the touched files.

Four tests cover the report, and all four fail against the unpatched listener:

  • signs each queued event with its own secret — two events handled back to back by the same listener instance in one session; each dispatched job's X-Fleetbase-Signature is asserted to equal the HMAC of its own payload under its own secret, and to not equal the HMAC under the other event's secret.
  • does not leak environment sandbox or company context between queued events — a sandbox event followed by a live one; each is asserted to reach the endpoint for its own mode.
  • restores the previous session context after handling an event — a spy resource records the session as downstream code sees it mid-job, covering api_credential, api_key, api_secret, api_environment, is_sandbox, company and user; the session is then asserted to be handed back exactly as it was found.
  • prefers event credential attribution over a stale worker session — stale credential/key/secret in the session are asserted not to reach the ApiEvent row, the job meta, or the signature.

Three existing tests asserted the old precedence (session wins) and were re-pointed at the event-authoritative behavior; their coverage of failure logging and credential attribution is unchanged, with the context moved from the session onto the event.

Note, not addressed here

ResourceLifecycleEvent::broadcastOn() reads session('company') and session('api_credential') at broadcast time, which has the same worker-session exposure. It is left alone deliberately: the event's apiCredential defaults to 'console' rather than being absent, so switching it over would start adding an api.console channel to every console-originated broadcast. That is a separate change with its own blast radius on the realtime layer, and worth its own issue.

…secret

SendResourceLifecycleWebhook only populated the session context when a key was
absent, then preferred that session value over the event's own. A long running
queue worker keeps its session between jobs, so once it had handled an event
from one API context every later event was signed and attributed with the first
one's credentials, and its company/user context leaked across jobs too.

The context serialized on the event is now authoritative: it is resolved once,
written to the session unconditionally for downstream code to read, and the
previous session state is restored in a finally block so the next job starts
clean.

Fixes #244
@codecov

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (6317e91) to head (94d5131).

Additional details and impacted files
@@             Coverage Diff             @@
##                main      #245   +/-   ##
===========================================
  Coverage     100.00%   100.00%           
  Complexity      6730      6730           
===========================================
  Files            397       397           
  Lines          22448     22461   +13     
===========================================
+ Hits           22448     22461   +13     
Flag Coverage Δ
backend 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Queue worker can reuse stale session secret for lifecycle webhook signatures

1 participant