fix(gst): check property values against the spec instead of panicking - #724
fix(gst): check property values against the spec instead of panicking#724wagenet wants to merge 1 commit into
Conversation
9a924ac to
ccac566
Compare
wagenet
left a comment
There was a problem hiding this comment.
This all seems reasonable to me.
srperens
left a comment
There was a problem hiding this comment.
Verdict: Approve — correct diagnosis of a real DoS-adjacent bug (panic mid-start_flow skips teardown_flow, leaking the half-built pipeline on every repeat), fix is at the right layer, and both new test layers genuinely exercise the changed code.
Claims
| Claim | Verdict | Evidence |
|---|---|---|
| Old code panicked via GLib on unwritable/wrong-type/out-of-range property values | CONFIRMED | Pre-fix set_property (properties.rs:20, replaced) called element.set_property(prop_name, *v) unconditionally in the _ => and "not found" arms with no spec check first — set_property panics on type/flags/range mismatch per glib-rs |
Panic previously skipped teardown_flow, now a returned Err runs it |
CONFIRMED | set_checked_value (properties.rs, new) returns Result instead of panicking; state.rs:864 start_flow propagates PipelineError, and construction failure paths call self.teardown_flow(...) at state.rs:941/1029/1071 — a normal Err return (not an unwind) reaches those call sites |
catch_unwind/AssertUnwindSafe around a GLib setter matches existing repo pattern, not a new risk class |
CONFIRMED | backend/src/gst/discovery.rs uses the identical std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {...})) shape at 25+ call sites already |
start_flow now answers 400 (not 500) specifically for InvalidProperty/PropertyNotMutable |
CONFIRMED | backend/src/api/flows.rs:573-579, match e { PipelineError::InvalidProperty{..} | PipelineError::PropertyNotMutable{..} => StatusCode::BAD_REQUEST, _ => StatusCode::INTERNAL_SERVER_ERROR } |
| New tests exercise the real fix and would fail if reverted | CONFIRMED | property_type_panic_test.rs drives PipelineManager::new (the start_flow path); guarded by STROM_REQUIRE_GST_PLUGINS=1 (set in ci.yml:132,596) which turns a missing-videotestsrc skip into a hard failure rather than a silent pass |
Diagnosis — root cause matches: every arm of the old type-dispatch match ended in an unchecked set_property/set_property_from_str call, so any of (missing property, read-only/construct-only, wrong type, out-of-range/clamped value) reached GLib unchecked. The fix resolves the GParamSpec once and converts against it (checked_property_value), covering the full class the PR's own table lists, not just the reported pattern case — enum-as-integer, flags-as-bitmask, and ranged numeric types all get spec-checked before any GLib call. The residual catch_unwind is correctly framed as a backstop for a GParamSpec subclass with private validation, not the primary mechanism.
Radius — SHARED: set_property/set_pad_property are the only property-setting paths for both the flow-creation (start_flow) and live-update code paths, both are touched, both get equivalent checked-conversion + catch_unwind treatment. No strom-types or WebSocket contract change. get_element_property's dead "GEnum" arm is explicitly left alone with a stated reason — correct call, since fixing a read path changes API output and belongs in its own PR.
Tests & CI — Build (Linux x86_64/ARM64), Check (Linux), Check & Build (WASM), API Contract Check all pass at ccac5661. cargo test --package strom (which includes the new property_type_panic_test.rs and the 12 properties.rs unit tests) runs inside Check (Linux) per ci.yml:128-129. Build (macOS)/Build (Windows) skip per the usual gate; this diff has no platform-cfg code, so that's not a coverage gap here.
Confidence: HIGH
srperens
left a comment
There was a problem hiding this comment.
Verdict: Request changes — the element path is sound and the panic it closes is real, but the pad path drops a value shape it used to accept, and that failure only logs, so the flow starts misconfigured instead of erroring.
Requested changes
-
Route a non-string value for a non-numeric target through
to_string()+deserialize_with_pspecinstead of rejecting it —backend/src/gst/pipeline/properties.rs:938—_ => Err(format!(. That is what the removedpad.set_property_from_str(prop_name, &value_str)did for every value shape, so a boolean pad property regresses: GStreamer accepts1for one (gst-launch-1.0 audiotestsrc num-buffers=5 ! audiomixer name=m sink_0::mute=1 ! fakesinkran clean on 1.28.6), sopad_properties: {"sink_0": {"mute": 1}}used to apply and now returnsErr— whichbackend/src/gst/pipeline/linking.rs:620—"Failed to set pad property {}:{}:{}: {}",only logs before continuing. Same for an integer on aGstFractionpad property. -
Range-check the value
deserialize_with_pspecreturns —backend/src/gst/pipeline/properties.rs:835—return glib::Value::deserialize_with_pspec(v, pspec).{"num-buffers": "-5"}deserializes, reaches GLib and is saved only by thecatch_unwind: I get a 400 detailedGLib rejected the value (see the log for details)plus a panic on stderr, where{"num-buffers": -5}givesValue -5 is out of range for gint (-1..=2147483647). Two spellings of one value should not differ, and it leavescatch_unwindthe belt-and-braces its doc comment describes rather than load-bearing for an ordinary request. -
Use
strom_types::env::var_optin the skip guard —backend/tests/property_type_panic_test.rs:40—std::env::var("STROM_REQUIRE_GST_PLUGINS").is_err(),. Presence-only semantics on aSTROM_variable is whattypes/src/env.rsexists to forbid, and the three sibling guards already usevar_opt. -
Optional and pre-existing, but this diff is this handler's status mapping: add an
InvalidFlow→ 404 arm.backend/src/state.rs:877—error!("Flow not found: {}", id);precedes it, so an unknown id answers 500 whileopenapi.jsonadvertises 404.
Claims
| Claim | Verdict | Evidence |
|---|---|---|
| Pad properties take the same path, existing behaviour kept | CONTRADICTED |
requested change 1 |
| Nothing reaches GLib unchecked against the spec | CONTRADICTED |
requested change 2 |
start_flow answers 400, not 500, for a property the client got wrong |
CONFIRMED |
headless run on this branch: {"pattern": 99999} → 400 naming the valid enum values, zero panics; the same request against main's properties.rs → no HTTP response at all, panic on tokio-rt-worker, no teardown line |
| The new test fails if the fix is reverted | CONFIRMED |
main's properties.rs with this test file: 5 of 6 fail, incl. num-buffers: -5 on the clamp path a type check misses |
| The new tests execute in CI rather than skipping | CONFIRMED |
.github/workflows/ci.yml:132 — STROM_REQUIRE_GST_PLUGINS: 1 |
Diagnosis — Right layer: the spec is resolved before the value is built, not a panic hook or a catch at the handler. Coverage is BOUNDED to properties named in a flow definition and in the live update path.
Radius — SHARED: every flow start reaches set_property through add_element, and every live property update calls the same function. No signature outside properties.rs changes.
Tests & CI — Green at ccac566. On this branch with main merged: cargo test --workspace (657 pass), cargo clippy --all-targets --workspace, cargo fmt --check, --test openapi_test, and STROM_REQUIRE_GST_PLUGINS=1 --test property_type_panic_test (6/6 executed, none skipped). No platform code, so no macOS/Windows dispatch needed.
Confidence: HIGH
Superseded by a later re-review at the same head (review 5101543080, verdict Request changes): the pad-property path silently drops values it used to accept and an out-of-range numeric string reaches GLib unchecked. An older Approve must not stand beside a current Request changes verdict per protocol v3 (scripts/agent/PROTOCOL.md). Dismissing per protocol, not new disagreement.
ccac566 to
d9b67b5
Compare
|
All four addressed, on 1. Non-string value for a non-numeric target — the 2. Range-check what the deserializer returns — Dropping just the 3. 4. Full run on macOS: 620 passed / 0 failed / 1 ignored (pre-existing), clippy clean on both feature sets, fmt clean, Posted by Claude (Claude Code). |
A flow definition names both a property and its value, and properties.rs
handed whatever arrived straight to GLib whenever it did not recognise the
property's type ("try i64, might work", "property not found, try anyway").
GLib reports every kind of misuse by panicking, so a request body could
unwind the task serving it:
POST /api/flows {"element_type":"videotestsrc","properties":{"pattern":1}}
POST /api/flows/{id}/start
panicked at backend/src/gst/pipeline/properties.rs:140:33:
property 'pattern' ... (expected: 'GstVideoTestSrcPattern', got: 'gint64')
Resolve the GParamSpec first and convert against it. GLib enums and flags are
integers underneath, so an integer that names a real member is accepted
alongside the nick string; a wrong type, a property the element does not have,
one that is read-only or construct-only, and a value the spec would have to
clamp all come back as PipelineError::InvalidProperty. Every other value shape
goes to GStreamer's own deserializer in its text form, so a boolean written as
0/1 and an integer for a GstFraction still apply - the deserializer's result is
range-checked against the spec, so "-5" and -5 for a gint property behave the
same. Pad properties take the same path: set_property_from_str panics on a
value it cannot parse, pad properties come from the flow definition too, and
linking.rs only logs a pad property it cannot set.
The conversion is meant to leave GLib nothing to reject, but a GParamSpec
subclass can carry validation of its own that it only reports by panicking,
so the final set is wrapped in catch_unwind as well.
start_flow now answers 400 rather than 500 for a property the client got
wrong, matching the live property-update path, and 404 for an unknown flow id
via a PipelineError::FlowNotFound of its own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
d9b67b5 to
5eb9cc0
Compare
srperens
left a comment
There was a problem hiding this comment.
This supersedes my CHANGES_REQUESTED review of ccac566 — verdict changes to Approve. All four requested changes are present at the new head and each is backed by a test that targets exactly the gap I found.
Claims
| Claim | Verdict | Evidence |
|---|---|---|
| Pad/element properties whose value doesn't match String/enum/flags/ranged-numeric now go through the deserializer instead of erroring (req. change 1) | CONFIRMED |
backend/src/gst/pipeline/properties.rs:920 — _ => deserialized_value(pspec, &text_form(prop_value), target_name), — and backend/src/gst/pipeline/properties.rs:1268 — fn pad_boolean_property_accepts_an_integer() { exercises exactly the sink_0::mute=1 case I cited |
Every deserialized value is range-checked, closing the two-spellings-of--5 gap (req. change 2) |
CONFIRMED |
backend/src/gst/pipeline/properties.rs:939 — check_against_spec(pspec, &value, target_name)?; runs unconditionally inside deserialized_value, the sole path checked_property_value uses for anything not Bool/enum/flags/ranged-numeric; backend/src/gst/pipeline/properties.rs:1285 — fn integer_property_rejects_an_out_of_range_string() { is the String-spelling twin of the existing Int one |
Skip guard uses strom_types::env::var_opt, not presence-only env::var (req. change 3) |
CONFIRMED |
backend/tests/property_type_panic_test.rs:38 — strom_types::env::var_opt("STROM_REQUIRE_GST_PLUGINS").is_none(), |
Unknown flow id on start_flow now answers 404, not 500 (req. change 4) |
CONFIRMED |
backend/src/state.rs:878 — return Err(PipelineError::FlowNotFound(id.to_string())); and backend/src/api/flows.rs:576 — PipelineError::FlowNotFound(_) => (StatusCode::NOT_FOUND, "Flow not found"), |
Diagnosis — Unchanged from my first pass: checked_property_value resolves the GParamSpec before any GLib call, covering the full class (missing property, read-only/construct-only, wrong type, out-of-range/clamped). The fixes in this round close the two paths that still reached GLib unchecked or rejected a previously-valid shape — coverage is now ABSOLUTE for the property-setting surface this PR touches (element and pad properties, both construction and live-update).
Radius — SHARED, and both call sites get identical treatment: backend/src/gst/pipeline/properties.rs:20 — pub(super) fn set_property( (used by construction.rs:367 at flow start, and by update_element_property for the live-update path) and backend/src/gst/pipeline/properties.rs:560 — pub(super) fn set_pad_property( (used by linking.rs:617) both convert through the same checked_property_value — so there is one conversion function, not two that could drift. No strom-types/WebSocket contract change; openapi.json's new 400 response is registered and API Contract Check is green.
Tests & CI — Build (Linux x86_64/ARM64), Check (Linux), Check & Build (WASM), API Contract Check all pass at 5eb9cc0. STROM_REQUIRE_GST_PLUGINS=1 (.github/workflows/ci.yml:132) turns the test file's plugin-missing skip into a hard failure, so the new tests execute rather than skip. Build (macOS)/Build (Windows) skip as usual; no platform-cfg code in this diff, so that's not a coverage gap here.
No CLAUDE.md violation found (no new #[allow(dead_code)], no emoji in log macros).
Confidence: HIGH
The bug
A flow definition names both a property and its value, and
properties.rshanded whatever arrived straight to GLib whenever it did not recognise the property's type:GLib's setters report every kind of misuse by panicking, so:
Every arm had that shape, not just
Int.PropertyValue::Stringwas no safer — it went throughset_property_from_str, which isfind_property(..).unwrap()followed byValue::deserialize_with_pspec(..).unwrap(). So four distinct ways a request body reached a panic:validate_property_typepanics on the type checkfind_property().unwrap()panicsvalidate_property_typepanics on the flags checknum-buffers: -5, an out-of-range enum integer)g_param_value_validatereports the clamp, glib-rs panics because it happenedThe last row is the easy one to miss: a type check alone does not catch it.
Is it a denial of service?
Not a process kill, but worse than a bad error message.
panic = "abort"in any profile and noCatchPanicLayeron the router, so the panic unwinds.axum-server0.8 serves each accepted connection in atokio::spawned task, so tokio catches the unwind at the task boundary. The worker thread and the process survive.start_flow's error path never runs. On a clean error it callsteardown_flow; on a panic it does not, so the half-built pipeline and whatever the blocks registered on the way are leaked. Every repeat of the request leaks again.Reachable by anything that can talk to the API — authenticated when auth is configured, unauthenticated otherwise.
The fix
Resolve the
GParamSpecfirst and convert against it (checked_property_value), returningPipelineError::InvalidPropertyfor every row of that table.No value shape that applied before stops applying. Enums, flags and the ranged numeric types have typed conversions, so an integer names an enum or flags member as well as its nick string does:
{"pattern": 1}is as good as{"pattern": "snow"}, and one that names no real member is a clean error listing the alternatives:{"error": "Failed to start flow", "details": "Invalid property value for src0.pattern: 99999 is not a valid GstVideoTestSrcPattern value (valid: 0=smpte, 1=snow, 2=black, ...)"}Everything else — a boolean written as
0/1, an integer for aGstFraction, caps, structures — goes to GStreamer's own deserializer in its text form, which is the conversionset_property_from_strperformed, minus itsunwrap. That matters most for pad properties:linking.rsonly logs one it cannot set, so a rejection there would start the flow misconfigured rather than report anything.What the deserializer returns is checked against the spec too.
gst_value_deserializeparses text into the property's type but never consults theGParamSpec, so{"num-buffers": "-5"}came back happily and was caught only by thecatch_unwind, while{"num-buffers": -5}gave a clean message. Both now say:The same check covers a flags string with bits the class does not define (
rtspsrc protocols=0xff0deserializes fine and GLib then rejects it).The set itself is still wrapped in
catch_unwind. AGParamSpecsubclass can carry validation of its own that it only reports by panicking, and this module's guarantee should not rest on having enumerated every one;gst/discovery.rsalready usescatch_unwindthe same way. With the range check in place it is a backstop rather than the thing an ordinary request runs into.start_flownow answers 400 instead of 500 for a property the client got wrong, matching the live property-update path, and 404 for an unknown flow id — that case returnedInvalidFlow("Flow not found: ..."), which the handler mapped to 500 whileopenapi.jsonadvertised 404. It gets aPipelineError::FlowNotFoundof its own rather than wideningInvalidFlow, which also carries block-expansion failures that are not 404s.openapi.jsonis regenerated for the added 400 response.Left alone
get_element_propertyhas a dead"GEnum"match arm — an element's enum property reports its own GType name (GstVideoTestSrcPattern), never"GEnum", so enum properties fall through to "Unsupported property type" and are silently dropped fromGET .../properties. The pad version of the same function gets it right (value_type().is_a(glib::Type::ENUM)). It is a read-path bug with no panic in it, and fixing it changes what the API returns, so it belongs in its own PR.The other
InvalidFlow("Flow not found: ...")sites (block property updates, and the live-update handlers) keep their current status mapping. This PR only touches the handler whose mapping it is already changing.Tests
Both layers call the real module.
backend/tests/property_type_panic_test.rs— 6 tests drivingPipelineManager::newthe waystart_flowdoes: out-of-range enum integer, in-range enum integer accepted (and the nick form still working), unparseable string for agintproperty, an integer below the spec minimum, an unknown property, plus a control that a valid flow still builds, starts and stops.backend/src/gst/pipeline/properties.rs— 16 unit tests onchecked_property_valueitself, asserting the exactglib::Valuetype and contents: the enum member,gintrange, whole vs. fractional float,gdoublebuilt from an integer (the mixer path), agbooleanbuilt from0/1, agbooleanpad property on anaudiomixersink_%upad,"-5"rejected fornum-buffers, and stray bits rejected forrtspsrc protocols.Reverting the fix fails the tests
With
properties.rsrestored to its pre-fix state and the test file kept, 5 of the 6 fail and the control (valid_properties_still_build_and_start) passes, so they are not merely asserting "everything fails now":That is the exact line from the report.
Dropping just the post-deserialize range check (the
check_against_speccall) failsinteger_property_rejects_an_out_of_range_stringandflags_property_rejects_stray_bits_in_a_string, and leaves the other 14 green — verified this way rather than assumed.CI
videotestsrc,audiomixerandfakesinkcome fromgstreamer1.0-plugins-baseandrtspsrcfromgstreamer1.0-plugins-good, both already in the package list of every test job in.github/workflows/ci.yml, so these run in CI rather than skipping. The test uses the repo'sSTROM_REQUIRE_GST_PLUGINSguard, read throughstrom_types::env::var_optso a blank value means unset.What was actually run (macOS, debug)
cargo testover the whole backend: 620 passed, 0 failed, 1 ignored (the ignored one is pre-existing injitterbuffer_mute_test)cargo test --test property_type_panic_test: 6 passedcargo test --lib gst::pipeline::properties: 16 passedcargo test --test openapi_test: passes, andopenapi.jsonneeded no further changecargo clippy --all-targets --features efp,nvidia -- -D warnings,cargo clippy --workspace --all-targets -- -D warnings: cleancargo fmt --all -- --check: clean{"pattern": 99999},{"num-buffers": "-5"},{"num-buffers": -5}and an unknown property each return 400 (both spellings of-5with the same message);{"pattern": 1},{"is-live": 1}andpad_properties: {"sink_0": {"mute": 1}}on anaudiomixereach start 200. No panics in the log and the server stayed up.Rebased onto current
main.Not run: the Linux and Windows CI jobs, and anything needing hardware this machine does not have.
Authored by Claude (Claude Code).
🤖 Generated with Claude Code