fix: stop trusting pg_notify payloads — notifications are hints, not transport - #107
Conversation
2b7856f to
a959481
Compare
bodymindarts
left a comment
There was a problem hiding this comment.
Review: stop trusting pg_notify payloads — ✅ Approve
Strong, correctly-scoped trust-boundary fix. The core thesis — NOTIFY is a wake-up hint, never transport; always re-read from the table with the consumer's own credentials — is applied consistently on both the ephemeral (payload) and persistent (head) paths, the regression tests are meaningful (the ephemeral one genuinely fails pre-fix), and CI is green. Shippable as-is. The items below are defense-in-depth / operational notes, not blockers — one is worth a follow-up because it's the same class of bug this PR fixes, left unclamped on a sibling path.
🔒 Security — what this gets right
- Ephemeral forgery + eavesdropping closed.
migrations/20260803120000_obix_ephemeral_notify_hint.sqlslims the trigger to{event_type, recorded_at}, andhandle_ephemeral_notification(src/out/ephemeral/cache.rs:142-172) never reads the body — it alwaysfetch_event_by_typefrom the table, usingrecorded_atonly to skip a redundant fetch. Injection and payload-harvest are both gone. - Persistent phantom-head neutralized.
src/out/persistent/cache.rs:505-538clamps the claimed head to the sequence's authoritativelast_valuebefore advancinghighest_known_sequence. A forgedi64::MAXcan no longer pin the gap-fill loop against a phantom head. - Derive-attribute identifier injection closed.
obix-macros/src/tables.rs:33-55—tbl_prefixrestricted to[A-Za-z0-9_], 1–25 chars, no leading digit. The≤25bound is exactly right: longest derived name{prefix}_persistent_outbox_events_sequence_seq= prefix + 38 ≤ 63 (PG's identifier limit). - Panic removal on DB-derived data.
InboxError::InvalidStatus(verifiedFromStr::Err = String, so no info lost) andrecord_ephemeral_event_type_undecodablereplace.expect()/.unwrap()on foreign/drifted rows.
🟠 Concern (non-blocking) — the forged-range fetch is still unclamped
src/out/persistent/cache.rs:480-503 + handle_notification (:319-348) + fetch_notified_range (:303-314)
The claimed head is clamped, but the range fetch the same forged {min,max} drives is not. A forged {min: 1, max: i64::MAX} still:
- forces
handle_notificationto computemissingby filteringmin..=maxagainst the cache — O(cache_size) synchronous hashmap lookups on the single-threaded cache loop (bounded by the cache high-water mark, but per-forgery and on the critical path); and - spawns
fetch_notified_range(after, i64::MAX)→load_events_in_range=WHERE sequence > after AND sequence <= i64::MAX→ an index range scan to the end of the table, streaming every tail row throughcache_fill(Arc allocs + broadcast) on every forged NOTIFY.
No data leak (the consumer already has table-read creds), but it's a cheap-for-attacker / expensive-for-victim amplification — and it's the same "don't let a forged claim drive unbounded work" bug this PR fixes on the head path, left in place on the fetch path. The new forged_persistent_notification_does_not_stall_listener test runs against an empty table, so this cost is unexercised.
Suggestion: do the authoritative highest_known_persistent_sequence read before spawning the fetch, and clamp the fetch up_to (and the missing-range span) to that confirmed head. Free for legit notifications (up_to == real head), protective for forged. The head read already happens for the advance path — this just reorders it.
❓ Nuance worth stating — clamp target is last_value (allocated), not committed head
src/out/persistent/cache.rs:523-534 — highest_known_persistent_sequence reads the sequence's last_value, which advances at nextval (pre-commit) and includes sequences held by in-flight/rolled-back txns. So a forged claim inside the window (committed_head, last_value] passes the min(claimed, last_value) clamp and advances the head prematurely, triggering the grace-period gap-fill against in-flight sequences. It's bounded by last_value and self-heals via the ON CONFLICT speculative-insert block (so it never stalls — the test holds), but it's worth being explicit in the comment that the clamp bounds forged-fill work rather than eliminating it.
🟡 Operational — mixed-version rollout window (ephemeral only)
Once the migration replaces the trigger with the hint-only variant, any still-running old-code instance parses {event_type, recorded_at} with payload_omitted defaulting to false, falls into the else branch, tries to deserialize a full EphemeralOutboxEvent (which now has no payload) → None → and never falls back to a DB fetch. So not-yet-upgraded instances silently stop delivering ephemeral events until they roll. (New-code-against-old-trigger is fine: the old row_to_json still contains event_type + recorded_at; extra fields are ignored.) Because sqlx migrates on boot, the first upgraded instance flips the trigger for everyone. Impact is limited to ephemeral (transient, last-write-wins, heals on upgrade/resync) and the persistent path is unaffected — but the upgrade note should call out this transient ephemeral-delivery gap during a rolling deploy.
🔵 Nits
- README wording (
README.md:15): "a forged or snoopedpg_notifycan neither inject nor leak events" slightly overstates it — payloads are protected, but the hint still exposes event metadata to any connectable role: ephemeralevent_typenames +recorded_attiming, and persistent{min,max}sequence numbers (i.e. event volume/rate). Fine for the threat model; consider "neither inject events nor leak payloads." - Duplicate ephemeral broadcasts under burst (
src/out/ephemeral/cache.rs:101-118): the LWW guard uses strict>, and a burst of N notifications for the same new type is drained against one cache snapshot (fetches are async, cache unchanged during the drain), so all N spawn identical fetches → N broadcasts of the same latest event (equalrecorded_atnot deduped). LWW consumers should be idempotent, so low impact and not a regression — could dedupe by not re-broadcasting an equalrecorded_at.
Test coverage
Good: forged ephemeral (not delivered + listener stays live), external-write hint→fetch path, forged persistent (no phantom head, no stall). The ephemeral forgery test is a real regression (fails pre-fix).
Gaps worth a follow-up: (1) no burst/coalescing test (many NOTIFYs → bounded fetches / single head read); (2) the forged-persistent test uses an empty table, so the range-fetch amplification above is untested on a populated table; (3) no test that the last_value-window forged claim self-heals rather than stalls.
Nice work — the reasoning in the code comments is unusually clear and made this easy to audit.
… transport
PostgreSQL performs no authorization on LISTEN/NOTIFY channels: any role
able to connect to the database can signal (and listen to) any channel.
obix nonetheless treated notification bodies as trusted transport.
BREAKING CHANGE: the setup migration (20251204130225_obix_setup.sql) is
modified in place — the ephemeral trigger now sends only
{event_type, recorded_at} instead of row_to_json(NEW). Existing
deployments must recreate the schema (the migration checksum changes);
there is no in-place upgrade migration.
Ephemeral (forgery + eavesdropping):
- The trigger sent the full payload over pg_notify, and listeners
deserialized and broadcast it without touching the table. Any DB role
(or SQLi in any app sharing the DB) could forge arbitrary ephemeral
events into every consumer, and could LISTEN to harvest all ephemeral
payloads without any table grant.
- Now: listeners treat the notification as a hint and always fetch the
event from the table with their own credentials, skipping the fetch
only when the in-process cache already holds an event at least as
recent. A burst of N notifications for the same type collapses into a
single fetch (deduped in the drain loop).
Persistent (phantom head + unbounded range fetch):
- A forged {min_sequence, max_sequence} with a huge max was applied
directly to highest_known_sequence, pinning the gap-fill loop against
a phantom head (a fill query every second, forever) — AND drove an
unbounded fetch_notified_range scanning the entire table tail on every
forgery.
- Now: both the head advance and the fetch up_to are clamped to the
sequence authoritative last_value before being applied (one cheap
last_value read per notification batch). last_value advances at
nextval (pre-commit), so a forged claim inside (committed_head,
last_value] can still trigger a bounded grace-period gap-fill that
self-heals via ON CONFLICT — it never stalls.
Minor hardening:
- obix-macros: validate tbl_prefix (1-25 chars, [A-Za-z0-9_], no leading
digit) — it is interpolated into generated SQL identifiers and the
pg_notify channel string literal.
- Remove panics on database-derived data: an unparseable inbox event
status surfaces as InboxError::InvalidStatus; an undeserializable
ephemeral event_type drops the row with an error span.
Regression tests verify: forged ephemeral notification not delivered
(fails pre-fix), externally written ephemeral events arrive via
hint+fetch, and a forged persistent notification on a populated table
neither synthesizes phantom events nor stalls delivery.
a959481 to
652c601
Compare
|
Thanks for the thorough review — all three substantive points addressed in the latest push ( 🟠 Unclamped range fetch — done. The authoritative ❓ 🔵 Burst dedup nit — done. 🔵 README nit — reworded to "neither inject events nor leak payloads." Migration — folded into the existing The mixed-version ephemeral-delivery gap during a rolling deploy is a real operational note — left as-is since it's inherent to changing the trigger's payload format and limited to the transient ephemeral stream (heals on upgrade/resync). |
…ss notifier) (#114) * feat(outbox)!: move pg_notify off the commit path (debounced per-process notifier) Every notify-bearing commit serializes on a cluster-wide lock in PreCommit_Notify held across the commit's WAL flush, capping the whole PostgreSQL instance at a few hundred notify-bearing commits/s. obix made every persistent-outbox-writing transaction notify-bearing via the 'notified' CTE in the generated persist query. Since #107 notifications are hints, not transport (listeners clamp claims and always fetch from the table), so notify emission can be made rarer and out-of-band without touching listener semantics: - persist_events drops the pg_notify CTE — app transactions no longer take the global NOTIFY lock - a per-process debounced notifier task (fed by PersistEvents::post_commit with each committed batch's (min, max)) coalesces reports and emits at most one pg_notify per debounce interval (default 25ms), with synchronous_commit=off scoped to the emit statement - bare-sqlx::Transaction publishes (no commit hooks -> post_commit never runs) keep the in-tx notify via a persist_events_notifying query variant - an idle head-poll in the persistent cache loop (default 10s of notification silence) backstops lost wake-ups: writer crash between commit and notify, dead notifier in a remote process, external writers Listener-side code (#107 clamps, #100 grace gap-fill, fetch_notified_range) is unchanged; mixed-version fleets are safe in both directions. Handoff: drua-library spaces/obix-dev/handoff-debounced-notifier.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style(outbox): let-chain the confirmed-head resync guard (clippy collapsible_if) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(outbox): gate idle-resync timer on confirmed head reads; trim comments Bugbot (PR #114 r3739940465): the idle timer reset on every inbound NotifyMessage before parsing, so junk channel traffic counted as pipeline activity and could suppress the crash-window backstop indefinitely. The timer now resets only where the head is actually confirmed (successful last_value read in the notification arm or the idle arm itself), which also defeats parseable-but-non-advancing claim spam. Regression test: junk_notifications_do_not_suppress_idle_resync. Also addresses review comments: in-body narration removed, added doc comments halved. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(outbox): gate idle-resync timer on authoritative progress, not head reads alone Bugbot (PR #114 r3739979153): head-read-only gating regressed the busy single-process case — local publishes advance the head via post_commit cache fills without head reads, so the idle poll fired every interval under steady write load, advancing the head over in-flight nextvals and risking grace-period gap fills against uncommitted rows. The timer now also resets when a cache fill actually advances highest_known_sequence (a newly-seen committed row). Forged payloads can fake neither signal — fetches return only real committed rows at or below the head, which advance nothing — so the r3739940465 suppression fix holds, while a busy local publisher keeps the poll dormant as the design intended. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Mirrors the fuzz setup in es-entity: a standalone cargo-fuzz crate, a shared ci/fuzz.sh (single source of truth for `make fuzz`, `nix run .#fuzz`, and the CI job), and a nightly Concourse job with a GCS-persisted evolving corpus. The single target fuzzes obix::decode_persistent_event — the chokepoint that decodes every persistent outbox row read back from Postgres, and the most untrusted data obix reads back. Its contract is that a poison row must never panic (a single poison row once wedged the pipeline in a hot panic/retry loop) and an undecodable row becomes an honest Err(UndecodableEventError). The harness asserts: never panics; on Err the carried failure.raw is the exact input value (honest copy); the None-placeholder path is a total function (always Ok, no payload); and row metadata round-trips on both arms. This is the obix analog of es-entity's fuzz_event_hydration. Verified on main (0.7.2-dev): cargo fuzz build --sanitizer=none compiles; ytt -f ci renders; a 1h run did ~311M executions with zero crashes. The CI job reuses es-entity's staging-gcp-creds bucket (namespaced under obix-artifacts/fuzz-corpus/) and zenduty webhook; swap if unavailable to this team. A second commit on an earlier draft added NOTIFY-payload fuzz targets, but main's #107 ("stop trusting pg_notify payloads — notifications are hints, not transport") already removed the .expect() that motivated them, leaving both NOTIFY parses as plain 2-field serde_json calls — serde_json is already exhaustively fuzzed, so those targets carried near-zero marginal value and are dropped. decode_persistent_event remains the one genuinely valuable target.
* chore: add coverage-guided fuzzing for decode_persistent_event Mirrors the fuzz setup in es-entity: a standalone cargo-fuzz crate, a shared ci/fuzz.sh (single source of truth for `make fuzz`, `nix run .#fuzz`, and the CI job), and a nightly Concourse job with a GCS-persisted evolving corpus. The single target fuzzes obix::decode_persistent_event — the chokepoint that decodes every persistent outbox row read back from Postgres, and the most untrusted data obix reads back. Its contract is that a poison row must never panic (a single poison row once wedged the pipeline in a hot panic/retry loop) and an undecodable row becomes an honest Err(UndecodableEventError). The harness asserts: never panics; on Err the carried failure.raw is the exact input value (honest copy); the None-placeholder path is a total function (always Ok, no payload); and row metadata round-trips on both arms. This is the obix analog of es-entity's fuzz_event_hydration. Verified on main (0.7.2-dev): cargo fuzz build --sanitizer=none compiles; ytt -f ci renders; a 1h run did ~311M executions with zero crashes. The CI job reuses es-entity's staging-gcp-creds bucket (namespaced under obix-artifacts/fuzz-corpus/) and zenduty webhook; swap if unavailable to this team. A second commit on an earlier draft added NOTIFY-payload fuzz targets, but main's #107 ("stop trusting pg_notify payloads — notifications are hints, not transport") already removed the .expect() that motivated them, leaving both NOTIFY parses as plain 2-field serde_json calls — serde_json is already exhaustively fuzzed, so those targets carried near-zero marginal value and are dropped. decode_persistent_event remains the one genuinely valuable target. * refactor(ci): consume shared fuzz_job from galoy-concourse-shared Mirrors es-entity#192: migrate obix's hand-rolled inline fuzz job to the shared fuzz_job() / fuzz_time_resource() / zenduty_* helpers now in galoy-concourse-shared (PR #20, vendir ref 8653c10 -> 9184832). - ci/vendir.yml + vendir.lock.yml: bump the shared ref. - ci/pipeline.yml: the ~110-line inline fuzz job/resources/types -> a single `#@ fuzz_job()` call + shared resource/type helpers. Drops the no-longer- needed gcr_resource_type / public_docker_registry imports (the shared job uses the google/cloud-sdk image + gsutil directly, not a Concourse gcs-resource type). - ci/fuzz.sh DELETED -> use the vendored ci/vendor/tasks/fuzz.sh, which auto-discovers targets via `cargo fuzz list` (no hardcoded names). Makefile + flake.nix (`nix run .#fuzz`) point at it. Behavior change from the inline job: cadence is now the shared default — weekly Saturday 06:00 UTC for 24h (was daily / 3600s) — and the corpus is self-bootstrapping (first run fuzzes from scratch, no manual seed). GCS access moved off the (broken) Concourse gcs-resource type onto gsutil in a google/cloud-sdk step, same as es-entity. The vendir refresh for obix touches only pipeline-fragments.lib.yml (fuzz helpers) + tasks/fuzz.sh, plus the unrelated helpers.sh -> rust-helpers.sh rename — which affects only test-integration/test-bats/check-code.sh, none of which obix's pipeline invokes, so it's harmless here. Verified: `ytt -f ci` renders (shared 3-step job: restore-corpus -> fuzz -> store-corpus, GCS prefix obix-artifacts/fuzz-corpus); `nix eval .#fuzz` resolves; `bash ci/vendor/tasks/fuzz.sh` auto-discovers 1 target and runs it. Credentials still reuse es-entity's shared ((staging-gcp-creds.*)) and ((zenduty.webhook_url)); confirm the obix Concourse team can read those. * chore(ci): bump vendir to galoy-concourse-shared 4c9071e Pulls in PR #21 (fix/fuzz-script-path): the shared fuzz_job ran `bash pipeline-tasks/ci/vendor/tasks/fuzz.sh`, but the fuzz task does `cd repo` with no `pipeline-tasks` input, so it failed at runtime on Concourse with "No such file or directory". The fix makes the path repo-relative (`ci/vendor/tasks/fuzz.sh`), which the repo input already contains. Invisible to local `ytt` render tests — only surfaces on the CI runner. Refresh touches only ci/vendor/pipeline-fragments.lib.yml (the one-line path fix); no obix-side changes needed. ytt -f ci renders; rendered fuzz task now reads `bash ci/vendor/tasks/fuzz.sh`. * chore(flake): add Concourse fly CLI to the dev shell Mirrors lana-bank's concourseFly derivation: fetches the fly binary from ci.galoy.io (per-platform SRI hashes) so `fly` / `repipe` work in `nix develop` without a manual install. Needed to repipe the new fuzz job from this repo's dev shell.
Summary
PostgreSQL performs no authorization on LISTEN/NOTIFY channels — any role able to connect to the database can signal (and listen to) any channel. obix treated notification bodies as trusted transport, which this PR fixes. Notifications are now strictly hints; event data is always read from the tables with the consumer's own credentials.
🔴 Ephemeral event forgery & eavesdropping
pg_notify, and listeners deserialized and broadcast it without touching the table. Any DB role (or SQL injection in any application sharing the database) could inject forged ephemeral events into all consumers, and couldLISTENto harvest every ephemeral payload with no table grant.{event_type, recorded_at}. Listeners always fetch the payload from the table;recorded_atlets them skip the fetch when the in-process cache is already current. A burst of N notifications for the same type collapses into a single fetch (deduped in the drain loop).🟠 Forged persistent notifications — phantom head + unbounded range fetch
{min_sequence, max_sequence}with a hugemax_sequencewas applied directly tohighest_known_sequence(pinning the gap-fill loop against a phantom head — a fill query every second, forever) and drove an unboundedfetch_notified_rangescanning the entire table tail on every forgery.up_toare clamped to the sequence's authoritativelast_valuebefore being applied.last_valueadvances atnextval(pre-commit), so a forged claim inside(committed_head, last_value]can still trigger a bounded grace-period gap-fill that self-heals viaON CONFLICT— it never stalls.🟡 Minor hardening
tbl_prefixvalidation — restricted to 1–25 chars of[A-Za-z0-9_](no leading digit) at derive time; it is interpolated into generated SQL identifiers and thepg_notifychannel literal.InboxError::InvalidStatus; undeserializable ephemeralevent_type→ row dropped with an error span.Test plan
forged_ephemeral_notification_is_not_delivered— fails on pre-fix code (forged event was broadcast verbatim), passes with the fixephemeral_event_written_externally_is_fetched_from_db— cross-instance delivery via hint + DB fetchforged_persistent_notification_does_not_stall_listener— now runs against a populated table (5 events), so the range-fetch amplification is exercised; forgedi64::MAXhead neither synthesizes phantom events beyond the real head nor stalls deliverycargo test --all-features(63 tests),cargo clippy --all-targetsclean,cargo fmt --checkclean