From 231dc85344bdf4d6450a75db20c5fa956408d90d Mon Sep 17 00:00:00 2001 From: John Nunemaker Date: Wed, 29 Jul 2026 17:11:15 -0400 Subject: [PATCH 1/8] Add empty group detection and pruning to Expression, guard enables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An expression containing an empty All group evaluates true for every actor, so persisting one silently enables the feature for everyone. - Expression#empty_groups? detects empty Any/All groups anywhere in the tree, #prune_empty_groups removes them wherever removal cannot broaden the expression, and #always_true? reports expressions that are guaranteed to match every actor. - Feature#enable_expression and #add_expression raise ArgumentError when the expression contains empty groups. flipper-api already rescues ArgumentError into the expression_invalid error response, so API clients get a 422 instead of persisting the footgun. - Feature#remove_expression of the last condition now disables the expression instead of persisting an empty group (previously it left {"Any":[]} — or worse, {"All":[]}, enabled-for-everyone — in the adapter). - Includes a seeded property spec verifying against the evaluator that pruning never broadens an expression. Co-Authored-By: Claude Fable 5 --- lib/flipper/expression.rb | 68 +++++++++++++++ lib/flipper/feature.rb | 42 +++++++-- spec/flipper/dsl_spec.rb | 2 +- .../expression_pruning_property_spec.rb | 85 +++++++++++++++++++ spec/flipper/expression_spec.rb | 78 +++++++++++++++++ spec/flipper/feature_spec.rb | 45 ++++++++-- 6 files changed, 306 insertions(+), 14 deletions(-) create mode 100644 spec/flipper/expression_pruning_property_spec.rb diff --git a/lib/flipper/expression.rb b/lib/flipper/expression.rb index 8d08988c0..10032f9c5 100644 --- a/lib/flipper/expression.rb +++ b/lib/flipper/expression.rb @@ -50,12 +50,80 @@ def eql?(other) end alias_method :==, :eql? + # Public: Returns true if this expression contains an Any/All group with + # no arguments anywhere in its tree. An empty All evaluates to true for + # every actor and an empty Any to false for every actor, so persisting + # one is almost always a mistake. + def empty_groups? + return true if group? && args.empty? + + args.any? { |arg| arg.is_a?(Expression) && arg.empty_groups? } + end + + # Public: Returns a copy of this expression with empty Any/All groups + # removed wherever removal cannot broaden the expression, or nil when no + # conditions remain. An empty All can be removed from an All parent (it + # contributes a constant true) and an empty Any from an Any parent (a + # constant false), but an empty Any inside an All is kept because + # removing it would broaden the expression from never-true. + def prune_empty_groups + prune_empty_groups_with_identity.first + end + + # Public: Returns true when this expression is guaranteed to match every + # actor, e.g. an empty All group reaches an Any group or the root. + def always_true? + constant_group_value == true + end + def value { name => args.map(&:value) } end + protected + + def prune_empty_groups_with_identity + return [self, nil] unless group? + return [nil, all?] if args.empty? + + pruned_args = args.map do |arg| + arg.is_a?(Expression) ? arg.prune_empty_groups_with_identity : [arg, nil] + end + kept = pruned_args.map(&:first).compact + + # No condition remains. Preserve this group's constant value for an + # enclosing group, which lets an empty All disappear from All without + # treating it as the unsafe empty-Any-in-All case. + return [nil, constant_group_value] if kept.empty? + + if all? && pruned_args.any? { |value, identity| value.nil? && identity == false } + return [self, nil] + end + + [build(name => kept), nil] + end + + def constant_group_value + return nil unless group? + return all? if args.empty? + + values = args.map do |arg| + arg.is_a?(Expression) ? arg.constant_group_value : nil + end + + if all? + return false if values.include?(false) + return true if values.all?(true) + else + return true if values.include?(true) + return false if values.all?(false) + end + + nil + end + private def call_with_context? diff --git a/lib/flipper/feature.rb b/lib/flipper/feature.rb index 2b44cf655..8b9468388 100644 --- a/lib/flipper/feature.rb +++ b/lib/flipper/feature.rb @@ -124,8 +124,10 @@ def enabled?(*actors) # expression - an Expression or Hash that can be converted to an expression. # # Returns result of enable. + # Raises ArgumentError if the expression contains empty Any/All groups, + # because an empty All matches every actor. def enable_expression(expression) - enable Expression.build(expression) + enable validate_expression!(Expression.build(expression)) end # Public: Add an expression for a feature. @@ -133,12 +135,18 @@ def enable_expression(expression) # expression_to_add - an expression or Hash that can be converted to an expression. # # Returns result of enable. + # Raises ArgumentError if the resulting expression contains empty Any/All + # groups, because an empty All matches every actor. def add_expression(expression_to_add) - if (current_expression = expression) - enable current_expression.add(expression_to_add) + expression_to_add = Expression.build(expression_to_add) + + new_expression = if (current_expression = expression) + current_expression.add(expression_to_add) else - enable expression_to_add + expression_to_add end + + enable validate_expression!(new_expression) end # Public: Enables a feature for an actor. @@ -191,14 +199,21 @@ def disable_expression end # Public: Remove an expression from a feature. Does nothing if no expression is - # currently enabled. + # currently enabled. Removing the last condition disables the expression + # rather than persisting an empty group. # # expression - an Expression or Hash that can be converted to an expression. # - # Returns result of enable or nil (if no expression enabled). + # Returns result of enable or disable or nil (if no expression enabled). def remove_expression(expression_to_remove) if (current_expression = expression) - enable current_expression.remove(expression_to_remove) + remaining = current_expression.remove(expression_to_remove) + + if remaining.group? && remaining.args.empty? + disable_expression + else + enable remaining + end end end @@ -427,6 +442,19 @@ def gate_for(actor) private + # Private: Raises if an expression about to be enabled contains empty + # Any/All groups. An empty All evaluates to true for every actor, so + # persisting one silently enables the feature for everyone. + # + # Returns the expression. + def validate_expression!(expression) + if expression.is_a?(Expression) && expression.empty_groups? + raise ArgumentError, "#{expression.value.inspect} contains empty Any/All groups and cannot be enabled (an empty All matches every actor). Remove the empty groups or add conditions to them." + end + + expression + end + # Private: Wrap the actors passed to enabled? in a single pass to avoid # intermediate array allocations on the hot path. Arrays are flattened one # level; the explicit is_a?(Array) check (instead of flatten) avoids the diff --git a/spec/flipper/dsl_spec.rb b/spec/flipper/dsl_spec.rb index 6b93f2a20..f978365e4 100644 --- a/spec/flipper/dsl_spec.rb +++ b/spec/flipper/dsl_spec.rb @@ -226,7 +226,7 @@ expect(subject[:stats].expression).to eq(any_expression) subject.remove_expression(:stats, expression) - expect(subject[:stats].expression).to eq(Flipper.any) + expect(subject[:stats].expression).to be(nil) end end diff --git a/spec/flipper/expression_pruning_property_spec.rb b/spec/flipper/expression_pruning_property_spec.rb new file mode 100644 index 000000000..12a2d4364 --- /dev/null +++ b/spec/flipper/expression_pruning_property_spec.rb @@ -0,0 +1,85 @@ +require 'flipper/expression' + +# Property-based check that empty group detection and pruning honor their +# contracts, verified against the evaluator itself. For seeded random trees +# (biased toward empty Any/All groups, including multi-key hashes where only +# the first key counts): +# +# - expressions without empty groups prune to themselves +# - pruning never broadens: no context excluded by the original may be +# included by the pruned expression +# - pruning to nil only happens when the original is a constant +# - always_true? implies the expression evaluates true in every context +# +# The seed is fixed for determinism; override with FUZZ_SEED/FUZZ_TREES for +# exploratory runs. +RSpec.describe Flipper::Expression do + SEED = (ENV["FUZZ_SEED"] || 20260728).to_i + TREES = (ENV["FUZZ_TREES"] || 1500).to_i + + PLANS = %w[basic pro enterprise].freeze + + CONTEXTS = PLANS.flat_map do |plan| + (0..50).step(10).map { |age| {properties: {"plan" => plan, "age" => age}} } + end.freeze + + def random_leaf(rng) + case rng.rand(4) + when 0 then {"Equal" => [{"Property" => ["plan"]}, PLANS[rng.rand(3)]]} + when 1 then {"NotEqual" => [{"Property" => ["plan"]}, PLANS[rng.rand(3)]]} + when 2 then {"GreaterThan" => [{"Property" => ["age"]}, rng.rand(50)]} + else {"LessThan" => [{"Property" => ["age"]}, rng.rand(50)]} + end + end + + def random_tree(rng, depth) + return random_leaf(rng) if depth <= 0 || rng.rand < 0.25 + + operator = rng.rand < 0.5 ? "Any" : "All" + children = Array.new(rng.rand(4)) { random_tree(rng, depth - 1) } + tree = {operator => children} + + # Multi-key hashes must behave as if only the first key exists, matching + # Flipper::Expression.build. + if rng.rand < 0.05 + other = operator == "Any" ? "All" : "Any" + tree[other] = Array.new(rng.rand(3)) { random_tree(rng, depth - 1) } + end + + tree + end + + def evaluate_all(expression) + CONTEXTS.map { |context| !!expression.evaluate(context) } + end + + it "prunes without ever broadening the expression" do + rng = Random.new(SEED) + + TREES.times do + tree = random_tree(rng, 4) + expression = described_class.build(tree) + failure = "seed=#{SEED} tree=#{tree.inspect}" + + values = evaluate_all(expression) + pruned = expression.prune_empty_groups + + if expression.always_true? + expect(values).to all(be(true)), "#{failure} always_true? but not always true" + end + + unless expression.empty_groups? + expect(pruned).to eq(expression), "#{failure} pruning changed a clean expression" + next + end + + if pruned.nil? + expect(values.uniq.size).to eq(1), "#{failure} pruned to nil but original is not constant" + else + evaluate_all(pruned).zip(values).each do |after, before| + expect(after && !before).to be(false), "#{failure} pruning broadened the expression" + end + end + end + end +end diff --git a/spec/flipper/expression_spec.rb b/spec/flipper/expression_spec.rb index 54f9f72ee..f4c86f784 100644 --- a/spec/flipper/expression_spec.rb +++ b/spec/flipper/expression_spec.rb @@ -185,4 +185,82 @@ expect(expression == other).to be(false) end end + + describe "#empty_groups?" do + it "returns true for an empty group" do + expect(described_class.build({"Any" => []}).empty_groups?).to be(true) + expect(described_class.build({"All" => []}).empty_groups?).to be(true) + end + + it "returns true for a nested empty group" do + expression = described_class.build({ + "Any" => [{"Equal" => [{"Property" => ["plan"]}, "basic"]}, {"All" => []}], + }) + expect(expression.empty_groups?).to be(true) + end + + it "returns false for groups with conditions" do + expression = described_class.build({ + "Any" => [{"All" => [{"Equal" => [{"Property" => ["plan"]}, "basic"]}]}], + }) + expect(expression.empty_groups?).to be(false) + end + + it "returns false for a condition" do + expression = described_class.build({"Equal" => [{"Property" => ["plan"]}, "basic"]}) + expect(expression.empty_groups?).to be(false) + end + end + + describe "#prune_empty_groups" do + let(:condition) { {"Equal" => [{"Property" => ["plan"]}, "basic"]} } + + it "returns nil when no conditions remain" do + expect(described_class.build({"Any" => []}).prune_empty_groups).to be(nil) + expect(described_class.build({"All" => []}).prune_empty_groups).to be(nil) + expect(described_class.build({"Any" => [{"All" => []}]}).prune_empty_groups).to be(nil) + end + + it "removes an empty All from an All parent" do + expression = described_class.build({"All" => [condition, {"All" => []}]}) + expect(expression.prune_empty_groups).to eq(described_class.build({"All" => [condition]})) + end + + it "removes an empty Any from an Any parent" do + expression = described_class.build({"Any" => [condition, {"Any" => []}]}) + expect(expression.prune_empty_groups).to eq(described_class.build({"Any" => [condition]})) + end + + it "keeps an empty Any inside an All because removal would broaden" do + expression = described_class.build({"All" => [condition, {"Any" => []}]}) + expect(expression.prune_empty_groups).to eq(expression) + end + + it "returns a condition untouched" do + expression = described_class.build(condition) + expect(expression.prune_empty_groups).to eq(expression) + end + end + + describe "#always_true?" do + it "returns true for an empty All" do + expect(described_class.build({"All" => []}).always_true?).to be(true) + end + + it "returns true for an empty All inside an Any" do + expression = described_class.build({ + "Any" => [{"Equal" => [{"Property" => ["plan"]}, "basic"]}, {"All" => []}], + }) + expect(expression.always_true?).to be(true) + end + + it "returns false for an empty Any" do + expect(described_class.build({"Any" => []}).always_true?).to be(false) + end + + it "returns false for conditional expressions" do + expression = described_class.build({"Equal" => [{"Property" => ["plan"]}, "basic"]}) + expect(expression.always_true?).to be(false) + end + end end diff --git a/spec/flipper/feature_spec.rb b/spec/flipper/feature_spec.rb index 3a9b26ff2..3cea74cf6 100644 --- a/spec/flipper/feature_spec.rb +++ b/spec/flipper/feature_spec.rb @@ -911,6 +911,39 @@ def actor.nil? end end + describe '#enable_expression with empty groups' do + it "raises for an empty root group" do + expect { + subject.enable_expression(Flipper.any) + }.to raise_error(ArgumentError, /empty Any\/All groups/) + expect(subject.expression).to be(nil) + end + + it "raises for a nested empty group" do + expect { + subject.enable_expression({"Any" => [{"Equal" => [{"Property" => ["plan"]}, "basic"]}, {"All" => []}]}) + }.to raise_error(ArgumentError, /empty Any\/All groups/) + expect(subject.expression).to be(nil) + end + end + + describe '#add_expression with empty groups' do + it "raises when the added expression contains an empty group" do + expect { + subject.add_expression(Flipper.all) + }.to raise_error(ArgumentError, /empty Any\/All groups/) + expect(subject.expression).to be(nil) + end + + it "raises when adding to an expression would keep an empty group" do + subject.enable_expression Flipper.property(:plan).eq("basic") + + expect { + subject.add_expression(Flipper.any(Flipper.all)) + }.to raise_error(ArgumentError, /empty Any\/All groups/) + end + end + describe '#remove_expression' do context "when nothing enabled" do context "with Expression instance" do @@ -946,10 +979,10 @@ def actor.nil? end context "with Expression instance" do - it "changes expression to Any and removes Expression if it matches" do + it "disables the expression when removing the last condition" do new_expression = Flipper.property(:plan).eq("basic") subject.remove_expression new_expression - expect(subject.expression).to eq(Flipper.any) + expect(subject.expression).to be(nil) end it "changes expression to Any if Expression doesn't match" do @@ -985,9 +1018,9 @@ def actor.nil? end context "with Expression instance" do - it "removes Expression if it matches" do + it "disables the expression when removing the last condition" do subject.remove_expression condition - expect(subject.expression).to eq(Flipper.any) + expect(subject.expression).to be(nil) end it "does nothing if Expression does not match" do @@ -1038,9 +1071,9 @@ def actor.nil? end context "with Expression instance" do - it "removes Expression if it matches" do + it "disables the expression when removing the last condition" do subject.remove_expression condition - expect(subject.expression).to eq(Flipper.all) + expect(subject.expression).to be(nil) end it "does nothing if Expression does not match" do From d940a732f8956e49fa4c731226beecebf9de0fc0 Mon Sep 17 00:00:00 2001 From: John Nunemaker Date: Wed, 29 Jul 2026 17:41:14 -0400 Subject: [PATCH 2/8] fix(review): sync mirrors legacy empty groups, prune completeness, Hash removal - FeatureSynchronizer mirrors remote expressions via the generic enable path so legacy empty-group data can't abort sync or raise on the Poll read path - prune_empty_groups now prunes removable nested groups even when an All sibling is unremovable, instead of returning the group unpruned - remove_expression builds its argument so Hash args match (previously a silent no-op) - validate_expression! error message no longer claims an empty Any matches every actor - API specs cover the empty-group 422 response end to end Co-Authored-By: Claude Fable 5 --- .../adapters/sync/feature_synchronizer.rb | 6 ++++- lib/flipper/expression.rb | 9 +++++-- lib/flipper/feature.rb | 4 +-- .../sync/feature_synchronizer_spec.rb | 11 ++++++++ .../api/v1/actions/expression_gate_spec.rb | 26 +++++++++++++++++++ spec/flipper/expression_spec.rb | 15 +++++++++++ spec/flipper/feature_spec.rb | 12 +++++++++ 7 files changed, 78 insertions(+), 5 deletions(-) diff --git a/lib/flipper/adapters/sync/feature_synchronizer.rb b/lib/flipper/adapters/sync/feature_synchronizer.rb index 3176dbe6a..d0980e060 100644 --- a/lib/flipper/adapters/sync/feature_synchronizer.rb +++ b/lib/flipper/adapters/sync/feature_synchronizer.rb @@ -58,7 +58,11 @@ def sync_expression if remote_expression.nil? @feature.disable_expression else - @feature.enable_expression remote_expression + # Mirror remote state faithfully via the generic enable path. + # enable_expression validates against empty Any/All groups, but + # remote adapters can hold legacy empty-group expressions and a + # raise here would abort the sync for every remaining feature. + @feature.enable remote_expression end end diff --git a/lib/flipper/expression.rb b/lib/flipper/expression.rb index 10032f9c5..80cd9fc24 100644 --- a/lib/flipper/expression.rb +++ b/lib/flipper/expression.rb @@ -98,8 +98,13 @@ def prune_empty_groups_with_identity # treating it as the unsafe empty-Any-in-All case. return [nil, constant_group_value] if kept.empty? - if all? && pruned_args.any? { |value, identity| value.nil? && identity == false } - return [self, nil] + # In an All parent a constant-false child (an empty Any) cannot be + # removed without broadening, so keep the original child while still + # using the pruned versions of its siblings. + if all? + kept = pruned_args.zip(args).map do |(value, identity), arg| + value.nil? && identity == false ? arg : value + end.compact end [build(name => kept), nil] diff --git a/lib/flipper/feature.rb b/lib/flipper/feature.rb index 8b9468388..4e79bb771 100644 --- a/lib/flipper/feature.rb +++ b/lib/flipper/feature.rb @@ -207,7 +207,7 @@ def disable_expression # Returns result of enable or disable or nil (if no expression enabled). def remove_expression(expression_to_remove) if (current_expression = expression) - remaining = current_expression.remove(expression_to_remove) + remaining = current_expression.remove(Expression.build(expression_to_remove)) if remaining.group? && remaining.args.empty? disable_expression @@ -449,7 +449,7 @@ def gate_for(actor) # Returns the expression. def validate_expression!(expression) if expression.is_a?(Expression) && expression.empty_groups? - raise ArgumentError, "#{expression.value.inspect} contains empty Any/All groups and cannot be enabled (an empty All matches every actor). Remove the empty groups or add conditions to them." + raise ArgumentError, "#{expression.value.inspect} contains empty Any/All groups and cannot be enabled (an empty All matches every actor, an empty Any matches none). Remove the empty groups or add conditions to them." end expression diff --git a/spec/flipper/adapters/sync/feature_synchronizer_spec.rb b/spec/flipper/adapters/sync/feature_synchronizer_spec.rb index ac34461f8..7c3a97091 100644 --- a/spec/flipper/adapters/sync/feature_synchronizer_spec.rb +++ b/spec/flipper/adapters/sync/feature_synchronizer_spec.rb @@ -94,6 +94,17 @@ expect_only_enable end + it "mirrors a remote expression containing empty groups without raising" do + remote = Flipper::GateValues.new(expression: {"Any" => []}) + feature.enable_expression(plan_expression) + adapter.reset + + described_class.new(feature, feature.gate_values, remote).call + + expect(feature.expression_value).to eq({"Any" => []}) + expect_only_enable + end + it "does nothing to expression if in sync" do remote = Flipper::GateValues.new(expression: plan_expression.value) feature.enable_expression(plan_expression) diff --git a/spec/flipper/api/v1/actions/expression_gate_spec.rb b/spec/flipper/api/v1/actions/expression_gate_spec.rb index dca7b34a0..7e5f40a1c 100644 --- a/spec/flipper/api/v1/actions/expression_gate_spec.rb +++ b/spec/flipper/api/v1/actions/expression_gate_spec.rb @@ -121,6 +121,32 @@ end end + describe 'enable with empty group' do + before do + data = {"Any" => []} + post '/features/my_feature/expression', JSON.dump(data), + "CONTENT_TYPE" => "application/json" + end + + it 'returns correct error response' do + expect(last_response.status).to eq(422) + expect(json_response).to eq(api_expression_invalid_response) + end + end + + describe 'enable with nested empty group' do + before do + data = {"All" => [{"All" => []}]} + post '/features/my_feature/expression', JSON.dump(data), + "CONTENT_TYPE" => "application/json" + end + + it 'returns correct error response' do + expect(last_response.status).to eq(422) + expect(json_response).to eq(api_expression_invalid_response) + end + end + describe 'enable missing feature' do before do post '/features/my_feature/expression', JSON.dump(expression.value), "CONTENT_TYPE" => "application/json" diff --git a/spec/flipper/expression_spec.rb b/spec/flipper/expression_spec.rb index f4c86f784..71c01ff3e 100644 --- a/spec/flipper/expression_spec.rb +++ b/spec/flipper/expression_spec.rb @@ -236,6 +236,21 @@ expect(expression.prune_empty_groups).to eq(expression) end + it "removes an empty All from an Any parent" do + expression = described_class.build({"Any" => [condition, {"All" => []}]}) + expect(expression.prune_empty_groups).to eq(described_class.build({"Any" => [condition]})) + end + + it "prunes removable groups even when a sibling cannot be removed" do + expression = described_class.build({ + "All" => [{"All" => [condition, {"All" => []}]}, {"Any" => []}], + }) + expected = described_class.build({ + "All" => [{"All" => [condition]}, {"Any" => []}], + }) + expect(expression.prune_empty_groups).to eq(expected) + end + it "returns a condition untouched" do expression = described_class.build(condition) expect(expression.prune_empty_groups).to eq(expression) diff --git a/spec/flipper/feature_spec.rb b/spec/flipper/feature_spec.rb index 3cea74cf6..022863fde 100644 --- a/spec/flipper/feature_spec.rb +++ b/spec/flipper/feature_spec.rb @@ -1029,6 +1029,18 @@ def actor.nil? end end + context "with Hash" do + it "disables the expression when removing the last condition" do + subject.remove_expression condition.value + expect(subject.expression).to be(nil) + end + + it "does nothing if Hash does not match" do + subject.remove_expression({"Equal" => [{"Property" => ["plan"]}, "premium"]}) + expect(subject.expression).to eq(expression) + end + end + context "with Any instance" do it "removes Any if it matches" do new_expression = Flipper.any(Flipper.property(:plan).eq("premium")) From 07aa9e3cbcd48ecdefe077c02ff2daf420c8201b Mon Sep 17 00:00:00 2001 From: John Nunemaker Date: Sun, 2 Aug 2026 17:10:41 -0400 Subject: [PATCH 3/8] fix(review): close empty-group safety gaps Keep the legacy sync bypass private and carry constant identities through nested pruning so removable empty groups do not survive. --- .../adapters/sync/feature_synchronizer.rb | 8 ++- lib/flipper/expression.rb | 21 ++++---- lib/flipper/feature.rb | 50 +++++++++++++------ spec/flipper/dsl_spec.rb | 9 ++++ .../expression_pruning_property_spec.rb | 12 +++++ spec/flipper/expression_spec.rb | 10 ++++ spec/flipper/feature_spec.rb | 46 +++++++++++++++++ 7 files changed, 125 insertions(+), 31 deletions(-) diff --git a/lib/flipper/adapters/sync/feature_synchronizer.rb b/lib/flipper/adapters/sync/feature_synchronizer.rb index d0980e060..38a636425 100644 --- a/lib/flipper/adapters/sync/feature_synchronizer.rb +++ b/lib/flipper/adapters/sync/feature_synchronizer.rb @@ -58,11 +58,9 @@ def sync_expression if remote_expression.nil? @feature.disable_expression else - # Mirror remote state faithfully via the generic enable path. - # enable_expression validates against empty Any/All groups, but - # remote adapters can hold legacy empty-group expressions and a - # raise here would abort the sync for every remaining feature. - @feature.enable remote_expression + # Remote adapters can hold legacy empty-group expressions, which + # must be mirrored faithfully without weakening public enables. + @feature.__send__ :enable_expression_from_sync, remote_expression end end diff --git a/lib/flipper/expression.rb b/lib/flipper/expression.rb index 80cd9fc24..a52db8192 100644 --- a/lib/flipper/expression.rb +++ b/lib/flipper/expression.rb @@ -91,23 +91,22 @@ def prune_empty_groups_with_identity pruned_args = args.map do |arg| arg.is_a?(Expression) ? arg.prune_empty_groups_with_identity : [arg, nil] end - kept = pruned_args.map(&:first).compact + + kept = pruned_args.map do |value, constant| + if constant == false + value || build("Any" => []) if all? + elsif constant.nil? + value + end + end.compact # No condition remains. Preserve this group's constant value for an # enclosing group, which lets an empty All disappear from All without # treating it as the unsafe empty-Any-in-All case. return [nil, constant_group_value] if kept.empty? - # In an All parent a constant-false child (an empty Any) cannot be - # removed without broadening, so keep the original child while still - # using the pruned versions of its siblings. - if all? - kept = pruned_args.zip(args).map do |(value, identity), arg| - value.nil? && identity == false ? arg : value - end.compact - end - - [build(name => kept), nil] + pruned = build(name => kept) + [pruned, pruned.constant_group_value] end def constant_group_value diff --git a/lib/flipper/feature.rb b/lib/flipper/feature.rb index 4e79bb771..2f16a802e 100644 --- a/lib/flipper/feature.rb +++ b/lib/flipper/feature.rb @@ -40,16 +40,7 @@ def initialize(name, adapter, options = {}) # # Returns the result of Adapter#enable. def enable(thing = true) - instrument(:enable) do |payload| - adapter.add self - - gate = gate_for(thing) - wrapped_thing = gate.wrap(thing) - payload[:gate_name] = gate.name - payload[:thing] = wrapped_thing - - adapter.enable self, gate, wrapped_thing - end + enable_thing(thing, validate_expression: true) end # Public: Disable this feature for something. @@ -127,7 +118,7 @@ def enabled?(*actors) # Raises ArgumentError if the expression contains empty Any/All groups, # because an empty All matches every actor. def enable_expression(expression) - enable validate_expression!(Expression.build(expression)) + enable Expression.build(expression) end # Public: Add an expression for a feature. @@ -146,7 +137,7 @@ def add_expression(expression_to_add) expression_to_add end - enable validate_expression!(new_expression) + enable new_expression end # Public: Enables a feature for an actor. @@ -207,12 +198,18 @@ def disable_expression # Returns result of enable or disable or nil (if no expression enabled). def remove_expression(expression_to_remove) if (current_expression = expression) - remaining = current_expression.remove(Expression.build(expression_to_remove)) + remaining = current_expression + .remove(Expression.build(expression_to_remove)) + .prune_empty_groups - if remaining.group? && remaining.args.empty? + if remaining.nil? disable_expression else - enable remaining + # remove and prune only delete nodes, so any empty group left in + # remaining was already stored (e.g. mirrored by sync). Persist it + # unvalidated: rejecting it here would block removing unrelated + # conditions from legacy data. + enable_thing(remaining, validate_expression: false) end end end @@ -442,6 +439,29 @@ def gate_for(actor) private + # Internal: Mirrors a trusted remote expression during adapter sync, + # including legacy expressions that predate empty-group validation. + # + # Returns the result of Adapter#enable. + def enable_expression_from_sync(expression) + enable_thing(Expression.build(expression), validate_expression: false) + end + + def enable_thing(thing, validate_expression:) + instrument(:enable) do |payload| + gate = gate_for(thing) + wrapped_thing = gate.wrap(thing) + validate_expression!(wrapped_thing) if validate_expression + + adapter.add self + + payload[:gate_name] = gate.name + payload[:thing] = wrapped_thing + + adapter.enable self, gate, wrapped_thing + end + end + # Private: Raises if an expression about to be enabled contains empty # Any/All groups. An empty All evaluates to true for every actor, so # persisting one silently enables the feature for everyone. diff --git a/spec/flipper/dsl_spec.rb b/spec/flipper/dsl_spec.rb index f978365e4..16dea0ed4 100644 --- a/spec/flipper/dsl_spec.rb +++ b/spec/flipper/dsl_spec.rb @@ -216,6 +216,15 @@ end end + describe '#enable with empty groups' do + it "raises instead of enabling an empty All for everyone" do + expect { + subject.enable(:stats, Flipper.all) + }.to raise_error(ArgumentError, /empty Any\/All groups/) + expect(subject[:stats].expression).to be(nil) + end + end + describe '#add_expression/remove_expression' do it 'enables and disables the feature for the expression' do expression = Flipper.property(:plan).eq("basic") diff --git a/spec/flipper/expression_pruning_property_spec.rb b/spec/flipper/expression_pruning_property_spec.rb index 12a2d4364..b98adc63a 100644 --- a/spec/flipper/expression_pruning_property_spec.rb +++ b/spec/flipper/expression_pruning_property_spec.rb @@ -53,6 +53,13 @@ def evaluate_all(expression) CONTEXTS.map { |context| !!expression.evaluate(context) } end + def surviving_empty_groups(expression, parent = nil) + return [] unless expression.is_a?(described_class) + + found = expression.group? && expression.args.empty? ? [[expression, parent]] : [] + found + expression.args.flat_map { |arg| surviving_empty_groups(arg, expression) } + end + it "prunes without ever broadening the expression" do rng = Random.new(SEED) @@ -79,6 +86,11 @@ def evaluate_all(expression) evaluate_all(pruned).zip(values).each do |after, before| expect(after && !before).to be(false), "#{failure} pruning broadened the expression" end + + surviving_empty_groups(pruned).each do |group, parent| + expect(group.any? && parent&.all?).to be(true), + "#{failure} retained a removable empty group" + end end end end diff --git a/spec/flipper/expression_spec.rb b/spec/flipper/expression_spec.rb index 71c01ff3e..d70599874 100644 --- a/spec/flipper/expression_spec.rb +++ b/spec/flipper/expression_spec.rb @@ -251,6 +251,16 @@ expect(expression.prune_empty_groups).to eq(expected) end + it "prunes a removable group nested inside a constant-false group" do + expression = described_class.build({ + "All" => [condition, {"All" => [{"Any" => []}, {"All" => []}]}], + }) + expected = described_class.build({ + "All" => [condition, {"All" => [{"Any" => []}]}], + }) + expect(expression.prune_empty_groups).to eq(expected) + end + it "returns a condition untouched" do expression = described_class.build(condition) expect(expression.prune_empty_groups).to eq(expression) diff --git a/spec/flipper/feature_spec.rb b/spec/flipper/feature_spec.rb index 022863fde..189270a69 100644 --- a/spec/flipper/feature_spec.rb +++ b/spec/flipper/feature_spec.rb @@ -927,6 +927,20 @@ def actor.nil? end end + describe '#enable with empty groups' do + it "raises instead of enabling an empty All for everyone" do + expect { + subject.enable(Flipper.all) + }.to raise_error(ArgumentError, /empty Any\/All groups/) + expect(subject.expression).to be(nil) + expect(subject.exist?).to be(false) + end + + it "does not expose the trusted sync hook as public API" do + expect(described_class.public_method_defined?(:enable_expression_from_sync)).to be(false) + end + end + describe '#add_expression with empty groups' do it "raises when the added expression contains an empty group" do expect { @@ -945,6 +959,38 @@ def actor.nil? end describe '#remove_expression' do + context "when a synced legacy expression contains an empty group" do + it "prunes the empty group and disables the expression when removing the last condition" do + condition = Flipper.property(:plan).eq("basic") + subject.__send__ :enable_expression_from_sync, Flipper.any(condition, Flipper.all) + + expect { + subject.remove_expression condition + }.not_to raise_error + expect(subject.expression).to be(nil) + end + + it "prunes the empty group when other conditions remain" do + condition = Flipper.property(:plan).eq("basic") + other = Flipper.property(:age).gte(21) + subject.__send__ :enable_expression_from_sync, Flipper.any(condition, other, Flipper.all) + + subject.remove_expression condition + expect(subject.expression).to eq(Flipper.any(other)) + end + + it "keeps an unprunable empty Any when removing an unrelated condition" do + condition = Flipper.property(:plan).eq("basic") + other = Flipper.property(:age).gte(21) + subject.__send__ :enable_expression_from_sync, Flipper.all(condition, other, Flipper.any) + + expect { + subject.remove_expression condition + }.not_to raise_error + expect(subject.expression).to eq(Flipper.all(other, Flipper.any)) + end + end + context "when nothing enabled" do context "with Expression instance" do it "does nothing" do From 0bb45507318ae31d0547f3d307e5bbc7cbb82e18 Mon Sep 17 00:00:00 2001 From: John Nunemaker Date: Sun, 2 Aug 2026 18:46:55 -0400 Subject: [PATCH 4/8] Reset Rails inflections between engine specs --- ROADMAP_IDEAS.md | 95 +++++++++++ SECURITY_AUDIT.md | 315 ++++++++++++++++++++++++++++++++++++ spec/flipper/engine_spec.rb | 27 ++++ 3 files changed, 437 insertions(+) create mode 100644 ROADMAP_IDEAS.md create mode 100644 SECURITY_AUDIT.md diff --git a/ROADMAP_IDEAS.md b/ROADMAP_IDEAS.md new file mode 100644 index 000000000..10a4b17f1 --- /dev/null +++ b/ROADMAP_IDEAS.md @@ -0,0 +1,95 @@ +# Flipper: Product Opportunities + +_Research-backed ideas for what to build next, grounded in the 2025–2026 competitive landscape and the resiliency tooling gaps around Ruby/Rails. Compiled 2026-07._ + +## Context / TLDR + +The feature-flag category converged hard in 2025–2026: + +- **Typed/JSON flag values** and **MCP servers** are now universal — Flipper is the only notable tool without either. +- **Metric-guarded rollouts with auto-rollback** became the commercial differentiator (LaunchDarkly Release Guardian, Datadog Feature Flags). +- **Three of seven commercial vendors were acquired by AI/observability companies** in a 9-month window: Statsig → OpenAI ($1.1B), Eppo → Datadog, DevCycle → Dynatrace. The market decided flags + observability is one product. + +On the resiliency side, the strongest signal is that teams already hand-build ops tooling **on top of Flipper** (GitLab runbooks, PlanetScale's Sidekiq kill-switch middleware), while the Ruby OSS in that space is largely abandonware. + +Because the owner is already building an APM, several items below rank higher than they otherwise would — the APM is the health signal that makes guarded rollouts and auto-tripping kill switches possible natively. + +--- + +## List 1 — Gaps vs other flag platforms (ranked) + +### 1. Typed / JSON flag values (multivariate) +The keystone gap. Every competitor — all 7 commercial, all 5 OSS — supports string/number/JSON values; GrowthBook's comparison content explicitly calls Flipper out as the only tool without it. Dynamic config, AI/prompt config, and experimentation all sit downstream of "a flag can return a value, not just a boolean." Biggest lift on the list, but everything else worth wanting in 2027 depends on it. Splits naturally across tiers: typed values in OSS, editing/targeting UI in Pro/Cloud. + +### 2. Guarded rollouts with auto-rollback (APM synergy) +**The** frontier feature: LaunchDarkly Release Guardian, Datadog's Feature Flags GA headline (auto-rollback on APM/RUM/SLO signals), Harness Release Monitoring. No Rails-native version exists at any price. Uniquely well-positioned here because the hard part is the health signal — the APM being built produces exactly that. Percentage rollout ramps automatically, the APM watches error rate/latency for the new cohort, and the flag halts or reverts itself on regression. This is what makes flags + APM one product, and it's why Datadog and Dynatrace bought their way into flags. + +### 3. Official MCP server +All 12 researched competitors shipped one in ~6 months — novel to table stakes inside 2025. Cheap here: the Cloud API already exists; wrap it with role-scoped tools (list/check/enable-for-actor, guarded by permissions). A local OSS variant that talks to the app's own adapter is the dev-loop version. Low effort, high "Flipper is current" signal, increasingly a checklist item in tool selection. + +### 4. OpenFeature provider (+ optional OFREP on Cloud) +OpenFeature is now the category's interop layer — providers exist for essentially everyone but Flipper; Datadog built its product on it; Cloudflare's new flag service is OpenFeature-native. A Ruby provider is a thin gem. Bigger strategic move: an **OFREP-compliant evaluation endpoint** on Cloud makes any OpenFeature SDK in any language a Flipper client — the cheapest answer to "Flipper is Ruby-only" without maintaining 15 SDKs. + +### 5. Release orchestration: approvals, change requests, scheduled changes in the UI +LaunchDarkly, Statsig, Harness, and Datadog all have approval workflows; Flipper has none, and it's a hard requirement for regulated buyers. Scheduled changes partially exist via 1.4.0 time-based expressions, but there's no UI affordance ("enable at 9am Tuesday, ramp to 100% by Friday"). Natural Gold/enterprise material. + +### 6. SSO/SAML and 2FA on Cloud +Unsexy, but the classic enterprise deal-blocker — docs show neither. Pure sales unblocking for the tier where the money is. + +### 7. AI-generated flag-cleanup PRs +Pro already has call sites; Cloud has stale detection — two-thirds of the way to what became the new bar in 2025 (LaunchDarkly Vega, Datadog Bits AI, Statsig). Closing the loop (stale flag + known call sites → behavior-preserving removal PR) is a smaller step here than it was for them. + +### 8. Experimentation (deliberately last) +The stats arms race (CUPED, sequential testing, warehouse-native) is a different company, and GrowthBook gives a full stats engine away free — no margin in chasing it. Honest play: lightweight "impact" views on telemetry already collected, plus a documented GrowthBook/PostHog integration path for teams that outgrow that. + +--- + +## List 2 — One step to the side: resiliency tooling (ranked) + +Ranked partly by how much each compounds with flags + APM. + +### 1. The APM (already being built) +Independently validated as #1 by the flag research: observability became the moat (LaunchDarkly acquired Highlight, Datadog acquired Eppo, Dynatrace acquired DevCycle). Flags wired to health signals is where the category is going. + +### 2. Dynamic runtime config +The most validated adjacency — every major flag vendor's second act (Statsig Dynamic Config, LaunchDarkly JSON flags, ConfigCat, Firebase). Ruby incumbent is `rails-settings-cached`: 1,000+ dependents (incl. Mastodon), but no UI, no audit, no targeting, no environments, barely moving. Devs hand-roll Redis singletons for the exact resiliency knobs you'd turn mid-incident because deploying an ENV change takes ~15 minutes. This is List 1 #1 in different clothes: `Flipper.setting(:page_size).value`, per-actor/per-tenant overrides (unserved multi-tenant pain), audit + rollback included. One investment, two product stories. + +### 3. Background job control plane +Strongest evidence of unmet demand in the whole study: GitLab runbooks and PlanetScale's published middleware both already implement kill/defer/throttle for Sidekiq jobs **using Flipper**. Queue pausing is paywalled in Sidekiq Pro (~$229/mo), limiters in Enterprise (~$749/mo); OSS gap-fillers (sidekiq-limit_fetch et al.) are unmaintained; Solid Queue explicitly declined rate limiting; no dashboard spans Sidekiq + Solid Queue + GoodJob. Productize what GitLab hand-rolled — per-job-class/per-tenant kill, defer, throttle, gradual re-enable via percentage gates, audit — through ActiveJob/Sidekiq middleware. Wedge: the huge OSS-Sidekiq base that won't pay $749/mo. + +### 4. Operational toggles / kill switches as a first-class type +Kill switches are already the top non-release use of flags; the productizable delta is what plain flags lack: auto-trip on error thresholds (APM again), TTL/auto-expiring switches, fail-safe direction + runbook links, and percentage gates as brownout dials ("serve cached homepage to 40% of anonymous traffic"). Unleash is coining "FeatureOps" to claim this; nothing Ruby-native does it. Cheap relative to its story value; reframes Flipper from "release tool" to "production control panel." + +### 5. Deploy safety / Kamal integration +Kamal has no canary workflow — kamal-proxy has percentage-rollout code implemented-but-hidden for ~2 years — and 37signals' stated position is that canarying belongs in feature flags (effectively an invitation). Deploy markers + a post-deploy flag guard ("after `kamal deploy`, watch these metrics, auto-halt the rollout flag") largely falls out of List 1 #2 with a Kamal on-ramp. + +### 6. Runtime-adjustable rate limiting +Algorithm layer is commoditized (Rails 8 `rate_limit`, rack-attack, Cloudflare), but nobody owns the control plane: per-plan/per-tenant limits adjustable from a dashboard mid-incident, with audit and observability. "Flipper for limits" — and the in-process-gem-plus-sync architecture answers the latency objection that kills hosted rate-limit APIs on HN. Real but narrower than 2–5. + +### 7. Circuit breaker visibility (integrate, don't rebuild) +Ruby's breaker gems are mediocre-to-dead (circuitbox stalled since 2023; Semian is powerful but config-driven with no dashboard; Evil Martians wrote in 2025 the category "lacks monitoring and real-time management," with fresh demand from flaky LLM APIs). Nobody offers a hosted breaker control plane. But total market attention is modest — do it as an integration (surface Semian/Stoplight state, alert on open circuits, "when breaker opens, flip flag X"), not a product. + +### 8. Maintenance / read-only mode +Incumbent gem (turnout) died in 2018; Kamal now handles the static-page tier. App-aware maintenance — read-only mode, admins-through, scheduled windows — is still hand-rolled, and every DIY version is literally a flag in middleware. Ship `Flipper::Maintenance` as a packaged feature for retention/marketing; a prior hosted-maintenance-mode startup died, so don't make it a product. + +### Avoid +- **Chaos engineering for Ruby** — two company-backed gems already died; commercial players deliberately stay at the infra layer. +- **On-call/paging** — crowded, commoditizing, SMS reliability is a different company's problem. (Worth stealing only the small "which flags changed during this incident" view.) + +--- + +## The through-line + +List 2 items 2–6 plus guarded rollouts from List 1 converge on one story no Ruby incumbent owns: **the production control panel for Rails** — flags, config, jobs, limits, and deploys, all watched by the APM and all able to react to it. The market decided in 2025 that flags + observability is one product; Flipper is one of very few positioned to build that natively for Rails instead of bolting it on via acquisition. + +--- + +## Appendix: what Flipper Cloud/Pro already ship (so as not to duplicate) + +**Pro (self-hosted, early access):** self-hosted dashboard, expressions UI, feature owners, call sites (code scanning w/ editor deep links), audit log (+ Slack), multi-database support, dynamic/large actor sets. + +**Cloud (hosted):** environments (production + personal + custom, prod mirroring), 3-level RBAC + trusted domains, longevity/owners/tags, audit history + one-click rollback, telemetry (evaluation metrics, stale detection via telemetry summary), webhooks (HMAC-signed, instant sync), Slack integration, super search (⌘K), REST API, local-sync model. + +**Recently shipped (2025–2026):** time-based expressions (1.4.0), smarter ⌘K, Slack integration, extended telemetry timeframes, expressions in Cloud UI, tag picker. + +**Confirmed gaps in all tiers:** no SAML/SSO or 2FA, no A/B testing, no UI scheduled/guarded rollouts, no approval workflows, no non-Ruby server SDKs (JS adapter only), no OpenFeature provider, no MCP server, no typed/JSON flag values. diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md new file mode 100644 index 000000000..d68f9bac6 --- /dev/null +++ b/SECURITY_AUDIT.md @@ -0,0 +1,315 @@ +# Flipper Security, Correctness & Concurrency Audit + +**Date:** 2026-07-06 +**Scope:** `lib/` — core library, storage adapters, UI, API, middleware, and Flipper Cloud. +**Method:** Static source review across five focus areas (UI/API security, threading/concurrency, core correctness, storage adapters, cloud/CLI). Every finding below was re-verified against the source by reading the cited lines. + +> **Context on Flipper's threat model.** The UI and API middleware intentionally ship **without** authentication — the host application is expected to protect the mount point. Several findings are only reachable when that mounting is misconfigured (public mount, weak auth). They are still worth fixing because misconfiguration is common and cheap to defend against. Similarly, Flipper's primary API is per-thread (`Flipper.enabled?` uses a per-thread instance), so several concurrency bugs only surface when a single `Flipper`/adapter instance is deliberately shared across threads (a documented, common pattern like `$flipper = Flipper.new(adapter)`) or in fork-based servers. + +--- + +## Severity Summary + +| # | Severity | Category | Issue | Location | +|---|----------|----------|-------|----------| +| 1 | **High** | Security (XSS) | Stored HTML/XSS via unescaped actor identifiers on the dashboard | `ui/decorators/feature.rb:44,48` + `ui/views/features.erb:52` | +| 2 | **High** | Threading | Fork-time `Mutex#unlock` of a foreign mutex raises `ThreadError` in the fork-recovery path | `adapters/memory.rb:125-141`, `poller.rb:133-137` | +| 3 | **Medium** | Security (CSRF) | Authenticity-token check silently dropped when any `rack_protection` option is passed | `ui.rb:37-41` | +| 4 | **Medium** | Correctness | `race_condition_ttl` cache option is a silent no-op | `adapters/active_support_cache_store.rb:83` | +| 5 | **Medium** | Correctness | Comparison expressions raise `ArgumentError` on type-mismatched operands | `expressions/comparable.rb:8` | +| 6 | **Medium** | Security | Webhook replay: signature timestamp tolerance never enforced | `cloud/middleware.rb:36`, `cloud/message_verifier.rb:46` | +| 7 | **Medium** | Reliability | Failover/Failsafe swallow the entire `StandardError` hierarchy by default | `adapters/failsafe.rb:14`, `adapters/failover.rb:23` | +| 8 | **Medium** | Security | Cloud auth token leaked to STDOUT/logs when debug output is enabled | `cloud/configuration.rb:191-202`, `adapters/http/client.rb:94` | +| 9 | **Medium** | Threading | Non-atomic sync gates cause thundering-herd concurrent syncs (`Poll` + `IntervalSynchronizer`) | `adapters/poll.rb:41-49`, `adapters/sync/interval_synchronizer.rb:27-41` | +| 10 | **Medium** | Threading | Shared DSL `@memoized_features` / Memoizable `@cache` mutated concurrently | `dsl.rb:221`, `adapters/memoizable.rb` | +| 11 | **Medium** | Threading | Cloud telemetry reassigns `@metric_storage`/`@pool`/`@timer` without synchronization | `cloud/telemetry.rb:63-118` | +| 12 | **Low** | Correctness | AR migration warning can never fire (inverted memoization guard) | `adapters/active_record.rb:300-303` | +| 13 | **Low** | Correctness | JSON exporter mutates adapter-owned hash in place | `exporters/json/v1.rb:14-20` | +| 14 | **Low** | Data integrity | Moneta adapter non-atomic read-modify-write → lost updates | `adapters/moneta.rb` (`enable`/`disable`/`add`/`remove`) | +| 15 | **Low** | Data integrity | Failover dual-write is non-atomic with no reconciliation | `adapters/failover.rb:54-82` | +| 16 | **Low** | Security | HTTP adapter interpolates feature/gate keys into URLs without escaping | `adapters/http.rb`, `adapters/http/client.rb` | +| 17 | **Low** | Security | `RedisCache` uses `Marshal.load` on cached blobs (RCE gadget sink) | `adapters/redis_cache.rb:23,35,42` | +| 18 | **Low** | Correctness | CRC32 modulo bias in percentage-of-actors bucketing | `gates/percentage_of_actors.rb:33` | +| 19 | **Low** | Reliability | `Poller#stop` uses `Thread#kill` (abrupt, self-kill from sync path) | `poller.rb:55-60` | +| 20 | **Low** | Threading | `Flipper.configuration` / `groups_registry` lazy init is racy at boot | `flipper.rb:29,182` | +| 21 | **Low** | Reliability | CLI auto-opens a server-controlled URL without confirmation | `cli.rb:102-106` | +| 22 | **Low** | Security | Cleartext token transmission if Cloud URL configured as `http://` | `adapters/http/client.rb:96-99` | +| 23 | **Low** | Robustness | Import endpoints: unbounded input + unguarded param access | `ui/actions/import.rb:12`, `api/v1/actions/import.rb:16` | +| 24 | **Info** | Correctness | Multi-actor percentage-of-actors hashes the concatenation of all actors | `gates/percentage_of_actors.rb:33` | +| 25 | **Info** | Various | Minor items: `Typecast.to_set` on scalars, `FeatureEnabled` cleanup bookkeeping, `uri_for_path` leading `&`, gzip has no size cap, webhook error reflected in headers, `at_exit` accumulation, no built-in UI/API auth | see details | + +--- + +## High Severity + +### 1. Stored HTML / XSS via unescaped actor identifiers on the dashboard +**Files:** `lib/flipper/ui/decorators/feature.rb:44,48`; rendered raw at `lib/flipper/ui/views/features.erb:52` + +`gates_in_words` hand-builds an HTML string and interpolates actor values straight into a `title` attribute with no escaping: + +```ruby +statuses << %Q() + ... +``` + +and the view emits it **raw** (`<%==`, unescaped): + +```erb +<%== feature.gates_in_words %> +``` + +Actor `flipper_id`s are free-form strings with no character restrictions and can be introduced by a lower-privilege user via the UI "Add Actor" form (`ui/actions/actors_gate.rb`), the API (`api/v1/actions/actors_gate.rb`), or an imported export file. An id like `x" onmouseover="alert(document.domain)` or `">` is stored and then written unescaped into the dashboard for every admin who loads `/features` (the default landing page). + +**Mitigating factor:** UI responses set a restrictive CSP (`ui/action.rb:39-48`, `script-src 'self'` with no `unsafe-inline`), which blocks injected inline scripts/handlers in modern browsers. This downgrades it from a clean JS-execution bug to HTML/CSS injection + content spoofing — but JS execution returns anywhere the CSP is stripped or weakened (reverse proxies, a host app that sets its own CSP, older browsers). The single-feature page renders the same data safely with `<%= %>` / `Sanitize.fragment`; only this list-page path is unescaped. + +**Fix:** HTML-escape the interpolated actor values inside `gates_in_words` (e.g. `Rack::Utils.escape_html`), or return structured data and escape in the view instead of using `<%==`. + +### 2. Fork-time `Mutex#unlock` of a foreign mutex raises `ThreadError` +**Files:** `lib/flipper/adapters/memory.rb:125-141`; `lib/flipper/poller.rb:133-137` + +The fork-recovery code unlocks a mutex it may not own: + +```ruby +def reset + @pid = Process.pid + @lock&.unlock if @lock&.locked? # unlocking a mutex owned by a now-dead thread +end + +def synchronize(&block) + if @lock + reset if forked? # runs OUTSIDE the lock + @lock.synchronize(&block) + ... +``` + +If a process forks (Puma/Unicorn/Resque preload-then-fork) while another thread holds the mutex, the child inherits a mutex flagged as locked by a thread that no longer exists. `locked?` returns `true`, and `unlock` from the surviving thread raises `ThreadError: Attempt to unlock a mutex which is locked by another thread` — a crash in the exact recovery path meant to prevent one. `memory.rb` has an additional TOCTOU: `reset` runs before the lock is acquired, so two threads in a fresh child can both call `reset` and the second `unlock` hits "not locked." + +**Fix:** After a fork, **replace** the mutex rather than unlock it: `@lock = Mutex.new` (and `@mutex = Mutex.new` in the poller). A fresh mutex is the only safe post-fork state. + +--- + +## Medium Severity + +### 3. Authenticity-token CSRF check silently dropped when a `rack_protection` option is passed +**File:** `lib/flipper/ui.rb:37-41` + +```ruby +if rack_protection_options.empty? + builder.use Rack::Protection::AuthenticityToken # form-token CSRF check +else + builder.use Rack::Protection, rack_protection_options +end +``` + +The UI's forms all embed a CSRF token (`ui/action.rb` `csrf_input_tag`) whose validation depends on `Rack::Protection::AuthenticityToken`. But in rack-protection 3.x/4.x the bundled `Rack::Protection` middleware has `AuthenticityToken` **off by default**. So passing *any* non-empty `rack_protection:` option (e.g. `{ allow_if: ... }`) drops the token check entirely. The code comment ("go whole hog and include all of Rack::Protection") is factually wrong. Residual protections (`HttpOrigin`, `RemoteToken`, `JsonCsrf`) still block many cross-origin POSTs but fail open for requests lacking `Origin`/`Referer`. + +**Fix:** Always include the token check, e.g. add `builder.use Rack::Protection::AuthenticityToken` unconditionally, or merge `use: [:authenticity_token, ...]` into the options path. Fix the comment. + +### 4. `race_condition_ttl` cache option is a silent no-op +**File:** `lib/flipper/adapters/active_support_cache_store.rb:83` + +```ruby +def write_options + write_options = {} + write_options[:expires_in] = @ttl if @ttl + write_options[:race_condition_ttl] if @race_condition_ttl # reads the key, never assigns + write_options +end +``` + +Line 83 evaluates `write_options[:race_condition_ttl]` (nil) as a bare expression and never assigns anything. Users who configure `race_condition_ttl:` to guard against cache-stampede get **zero** protection, with no error or warning — defeating the exact race the option exists to prevent. + +**Fix:** `write_options[:race_condition_ttl] = @race_condition_ttl if @race_condition_ttl` + +### 5. Comparison expressions raise `ArgumentError` on type-mismatched operands +**File:** `lib/flipper/expressions/comparable.rb:8` (used by `greater_than`, `less_than`, `greater_than_or_equal_to`, `less_than_or_equal_to`) + +```ruby +def self.call(left, right) + left.respond_to?(operator) && right.respond_to?(operator) && left.public_send(operator, right) +end +``` + +The `respond_to?` guards confirm both sides respond to `>`/`<` etc., but **not** that they're type-compatible. Every `String` and every `Integer` responds to `>`, yet `"25" > 21` raises `ArgumentError: comparison of String with 21 failed`. Property values commonly arrive as strings from JSON, so an actor with `flipper_properties = { age: "25" }` checked against `Flipper.property(:age).gte(21)` raises out of `Feature#enabled?` — the whole flag check blows up instead of returning `false`. (Missing/`nil` properties are safe because `nil.respond_to?(:>=)` is false.) + +**Fix:** Rescue `ArgumentError` and treat a failed comparison as `false`, or normalize operand types before comparing. + +### 6. Webhook replay: signature timestamp tolerance never enforced +**Files:** `lib/flipper/cloud/middleware.rb:36`; `lib/flipper/cloud/message_verifier.rb:46` + +The HMAC signature check itself is correct and timing-safe (SHA256 digest + constant-time compare in `secure_compare`), and the signed message includes the timestamp, so it can't be tampered. **But** the middleware never passes a `tolerance:`: + +```ruby +if message_verifier.verify(payload, signature) # tolerance defaults to nil → freshness check skipped +``` + +```ruby +def verify(payload, header, tolerance: nil) + ... + if tolerance && timestamp < Time.now - tolerance # skipped entirely when tolerance is nil +``` + +A single captured, validly-signed webhook can be replayed by an unauthenticated caller indefinitely. Impact is bounded — each replay forces `flipper.sync(cache_bust: true)` — so this is a replay/amplification-DoS, not an integrity break. + +**Fix:** Pass a tolerance from the middleware (`verify(payload, signature, tolerance: 60)`) and rescue the failure as a 400. Consider also rejecting timestamps too far in the future. + +### 7. Failover/Failsafe swallow the entire `StandardError` hierarchy by default +**Files:** `lib/flipper/adapters/failsafe.rb:14`; `lib/flipper/adapters/failover.rb:23` + +```ruby +@errors = options.fetch(:errors, [StandardError]) +... +rescue *@errors +``` + +The default catches **everything** — `NoMethodError`, `TypeError`, `JSON::ParserError`, serialization bugs — not just connectivity failures. A genuine code/data bug in the primary adapter is silently masked: Failsafe returns `{}`/`Set.new`/`false` (which reads as "all features disabled" in production), and Failover quietly serves possibly-stale secondary data. The operator sees no error. + +**Fix:** Default to a narrow connectivity-error list (timeouts, `Errno::ECONNREFUSED`, `Redis::BaseConnectionError`, etc.) and/or instrument every swallowed exception so failures stay observable. + +### 8. Cloud auth token leaked to STDOUT/logs when debug output is enabled +**Files:** `lib/flipper/cloud/configuration.rb:191-202`; `lib/flipper/adapters/http/client.rb:94` + +Enabling `FLIPPER_CLOUD_DEBUG_OUTPUT_STDOUT` (or `debug_output=`) hands the raw stream to `Net::HTTP#set_debug_output`, which dumps all request headers — including `flipper-cloud-token` (the environment's bearer credential) — in cleartext to logs. Requires operator opt-in, so it's a footgun rather than a default exposure, but an operator debugging a sync issue in production leaks the token to centralized logging. + +**Fix:** Redact the `flipper-cloud-token` / `authorization` headers before handing the stream to `set_debug_output`, or loudly document that debug output exposes the token. + +### 9. Non-atomic sync gates cause thundering-herd concurrent syncs +**Files:** `lib/flipper/adapters/poll.rb:41-49`; `lib/flipper/adapters/sync/interval_synchronizer.rb:27-41` + +Both use an unsynchronized check-then-act on a plain ivar shared across all request threads: + +```ruby +# interval_synchronizer.rb +def call + return unless time_to_sync? # reads @last_sync_at + @last_sync_at = now # plain ivar, no lock + @synchronizer.call +end +``` + +After the interval elapses, N threads all see `time_to_sync?` true before any updates the timestamp, so **all N** run a full `Synchronizer#call` — each a remote `get_all` round-trip plus overlapping local writes. Defeats the interval limiting. Same pattern in `Poll#synced_adapter` (`@last_synced_at`). + +**Fix:** Make the gate atomic — mutex around the read-check-set, or a `Concurrent::AtomicFixnum` with `compare_and_set` so exactly one thread wins per interval. + +### 10. Shared DSL `@memoized_features` / Memoizable `@cache` mutated concurrently +**Files:** `lib/flipper/dsl.rb:221`; `lib/flipper/adapters/memoizable.rb` + +```ruby +@memoized_features[name.to_sym] ||= Feature.new(name, @adapter, instrumenter: instrumenter) +``` + +Safe under the per-thread module API, but a shared `Flipper.new(adapter)` / `Flipper::Cloud.new` instance across threads (a supported, common pattern) mutates a plain `Hash` with a non-atomic `||=`. Concurrent insert during another thread's iteration raises `can't add a new key into hash during iteration` or loses writes. Memoizable's `@cache.fetch(k){ cache[k]=... }` has the same hazard when memoizing. + +**Fix:** Back both with `Concurrent::Map` (`compute_if_absent` is atomic), or document that a DSL instance isn't safe to share across threads. + +### 11. Cloud telemetry reassigns shared state without synchronization +**File:** `lib/flipper/cloud/telemetry.rb:63-118` + +`record` (arbitrary app threads), `post_to_pool` (timer thread), and `post_to_cloud` (pool thread) all read `@metric_storage`/`@pool`/`@timer`, while `restart` (on fork) and `stop` (on a `telemetry-shutdown` header) reassign/tear them down with no lock. Races drop metrics (increment into a swapped-out storage; `@pool.post` onto a shutting-down pool discarded silently) and can observe an inconsistent storage/pool/timer trio. + +**Fix:** Guard `start`/`stop`/`restart` and the reads with a mutex (or swap an `AtomicReference` atomically); at minimum snapshot `storage = @metric_storage` once per method. + +--- + +## Low Severity + +### 12. AR migration warning can never fire (inverted guard) +**File:** `lib/flipper/adapters/active_record.rb:300-303` + +```ruby +def warned_about_value_not_text? + return @warned_about_value_not_text if defined?(@warned_about_value_not_text) + @warned_about_value_not_text = true # returns true on the FIRST call +end +``` + +On the first call the ivar is undefined, so it falls through, sets `true`, and returns `true` — making `!warned_about_value_not_text?` false forever. The `VALUE_TO_TEXT_WARNING` telling users to run the JSON-column migration is permanently suppressed; they get no heads-up until a hard `raise` on a JSON write. **Fix:** return `false` the first time, flip to `true` after. + +### 13. JSON exporter mutates adapter-owned hash in place +**File:** `lib/flipper/exporters/json/v1.rb:14-20` + +```ruby +features = adapter.get_all +features.each do |feature_key, gates| + gates.each do |key, value| + features[feature_key][key] = value.to_a if value.is_a?(Set) # mutates live adapter data + end +end +``` + +The default `Adapter#get_all` returns references to internal storage for adapters whose `get` returns internal refs. Exporting then replaces stored `Set`s with `Array`s inside the adapter's live data, so a later actor-add does `array << value` and permits duplicates. The stock `Memory` adapter escapes this (it returns a fresh copy), but the documented default path is unsafe. **Fix:** build a new structure with `transform_values` instead of mutating. + +### 14. Moneta adapter non-atomic read-modify-write → lost updates +**File:** `lib/flipper/adapters/moneta.rb` (`enable`/`disable`/`add`/`remove`) + +Unlike Mongo (`$addToSet`/`$pull`) and Redis (`hset`/`hdel`), Moneta does full read → mutate-in-Ruby → write-back with no lock. Two concurrent `enable`s for actors A and B on the same feature both read the same set, each adds one, each writes back — the second clobbers the first and one actor is silently dropped. **Fix:** use Moneta atomic primitives / store-level lock where available, or document that Moneta is unsafe for concurrent writes. + +### 15. Failover dual-write is non-atomic with no reconciliation +**File:** `lib/flipper/adapters/failover.rb:54-82` + +With `dual_write: true`, primary and secondary writes are sequential and unguarded. If the secondary raises, the primary is already mutated and the exception propagates — the stores diverge permanently, and a flaky secondary breaks writes even while the primary is healthy. **Fix:** rescue/instrument the secondary write without failing the primary; provide a reconciliation path. + +### 16. HTTP adapter interpolates feature/gate keys into URLs without escaping +**Files:** `lib/flipper/adapters/http.rb`, `lib/flipper/adapters/http/client.rb` + +Keys are interpolated raw into paths/queries (`"/features/#{feature.key}/#{gate.key}"`, `"/features?keys=#{csv_keys}"`). A key containing `/`, `?`, `&`, `#`, a comma, or spaces produces a malformed request or injects/overrides query parameters. Keys are normally developer-controlled, hence Low. **Fix:** `URI.encode_www_form_component` each key. (Related: `uri_for_path` produces a spurious leading `&` when the base URL has no query string.) + +### 17. `RedisCache` uses `Marshal.load` on cached blobs +**File:** `lib/flipper/adapters/redis_cache.rb:23,35,42` + +Cache values are serialized with `Marshal.dump`/`Marshal.load`. Data is written by Flipper itself, so this is only exploitable if an attacker can write to the cache Redis — but caches are often treated as disposable/less-secured than the primary store, and `Marshal.load` is a classic RCE gadget sink. **Fix:** use a safe serializer (JSON / `Flipper::Typecast`) for cache blobs, or document that the cache store must be as trusted as the primary. + +### 18. CRC32 modulo bias in percentage-of-actors bucketing +**File:** `lib/flipper/gates/percentage_of_actors.rb:33` (and `expressions/percentage_of_actors.rb`) + +`Zlib.crc32(id) % (100 * SCALING_FACTOR)` is not uniform: `2**32` isn't a multiple of `100_000`, so residues `0..67_295` occur once more often than the rest. Since the "enabled" region is the low residues, buckets below 67,296 are over-represented by ≈0.0023%. Boundary conditions (0%/100%) and monotonicity are correct. Tiny fairness deviation. **Fix:** map crc32 to `[0,1)` via a full-range divisor, or use a modulus that divides the hash range. + +### 19. `Poller#stop` uses `Thread#kill` +**File:** `lib/flipper/poller.rb:55-60` + +`Thread#kill` asynchronously terminates the poller wherever it is (mid-`import`), can leave partially-imported state, and doesn't nil out `@thread`. The `poll-shutdown` header path runs on the poller thread and calls `stop` → self-kill mid-`sync`. **Fix:** cooperative shutdown via the existing `@shutdown_requested` AtomicBoolean checked in the run loop, then `join`; clear `@thread = nil`. + +### 20. `Flipper.configuration` / `groups_registry` lazy init is racy +**File:** `lib/flipper.rb:29,182` + +Module-level `@configuration ||= Configuration.new` is a non-atomic check-then-set. Two threads first touching Flipper concurrently (parallel boot/autoload) can each build a `Configuration`; the losing thread's `Flipper.configure` block results are discarded. Boot is usually single-threaded, hence Low. **Fix:** eagerly initialize at load, or memoize behind a mutex. + +### 21. CLI auto-opens a server-controlled URL without confirmation +**File:** `lib/flipper/cli.rb:102-106` + +`flipper cloud migrate` opens whatever `url` the `/migrate` API returns via `system("open", result.url)` with no prompt. The endpoint is overridable via `FLIPPER_CLOUD_URL`; a malicious/MITM'd endpoint yields a click-free open of an attacker-chosen URI in the developer's environment. (The array form of `system` means no shell injection.) **Fix:** validate the URL is `https://` on an allow-listed host, or just print it. + +### 22. Cleartext token transmission if Cloud URL is `http://` +**File:** `lib/flipper/adapters/http/client.rb:96-99` + +SSL is only enabled when `uri.scheme == "https"`. Since `url` is operator-configurable, an `http://` value sends the `flipper-cloud-token` header in cleartext with no warning. **Fix:** warn or refuse when a non-loopback Cloud URL is non-HTTPS while a token/secret is present. + +### 23. Import endpoints: unbounded input and unguarded param access +**Files:** `lib/flipper/ui/actions/import.rb:12`; `lib/flipper/api/v1/actions/import.rb:16` + +Both read the entire body/upload into memory before parsing (a low-grade DoS on an unauthenticated mount), and the UI path assumes `params['file']` is an upload hash — a missing/plain-string `file` raises `NoMethodError` → 500. Deserialization itself is safe (`Typecast.from_json` → plain `JSON.parse`, no object instantiation). **Fix:** guard `params['file']` presence and cap body/upload size before reading. + +--- + +## Informational + +- **24. Multi-actor percentage-of-actors semantics** (`gates/percentage_of_actors.rb:33`): with multiple actors, the gate hashes the sorted concatenation of *all* actor ids as one composite key rather than asking "is any actor in the bucket?". So `enabled?(a, b)` can be true while `enabled?(a)` and `enabled?(b)` are both false — unlike the actor/group gates which use `any?`. Long-standing behavior; worth documenting. +- **25. Minor robustness items:** + - `Typecast.to_set` (`typecast.rb:65-74`) raises `NoMethodError` on a non-nil scalar (`value.empty?` on an Integer) instead of a clear error — only reachable with malformed adapter data. + - `FeatureEnabled` cycle-cleanup (`expressions/feature_enabled.rb:20-30`) unconditionally deletes `feature_name` in `ensure` even on the cycle-break path where this frame didn't add it. Traced cyclic graphs still terminate correctly, but the asymmetric bookkeeping is fragile. + - Gzip response decompression (`serializers/gzip.rb`) has no decompressed-size cap (decompression-bomb pattern) — only reachable from a hostile/MITM'd Cloud endpoint since inbound webhook/import bodies aren't gzip-decompressed. + - Webhook failure reflects the raw exception class/message in `flipper-cloud-response-error-*` response headers (`cloud/middleware.rb:44-49`) — only after successful signature verification, so disclosure risk is minimal. + - Per-instance `at_exit { stop }` handlers (`poller.rb:44`, `cloud/telemetry.rb:59`) accumulate and are never removed; unbounded in fork-heavy servers and inherited handlers close over stale objects. + - **No built-in auth on UI/API** (`ui/middleware.rb`, `api/middleware.rb`): by design, but the root cause that makes findings 1, 3, and 23 reachable when the mount is misconfigured. + +--- + +## Verified as Correct (checked, not bugs) + +- **No SQL injection** in ActiveRecord/Sequel adapters — all queries use parameterized hash conditions and Arel with adapter-controlled table names. +- **Webhook HMAC comparison is timing-safe** — SHA256 digest + constant-time XOR compare in `MessageVerifier#secure_compare`; the signed message authenticates the timestamp. +- **No insecure deserialization** — cloud/import responses use plain `JSON.parse` with no object instantiation; the only `eval` is on trusted compiled template source. +- **HTTP client SSL is correct** for HTTPS URLs (`use_ssl` + `VERIFY_PEER`). +- **Mongo/Redis writes are atomic** (`$addToSet`/`$pull`, `hset`/`hdel`). +- **Path traversal** in `ui/actions/file.rb` is handled by `Rack::Files`; no open-redirect with user-controlled targets. +- **API responses are JSON-only** (no HTML rendering / reflected XSS); reflected `params["error"]` in UI views is HTML-escaped via `<%= %>`. +- Boundary conditions for both percentage gates (0% never, 100% always) and monotonicity are correct; `Registry` locking, `Actor#eql?/hash`, and per-thread cycle/sync-mode tracking are sound. diff --git a/spec/flipper/engine_spec.rb b/spec/flipper/engine_spec.rb index bbc93b884..32503231b 100644 --- a/spec/flipper/engine_spec.rb +++ b/spec/flipper/engine_spec.rb @@ -8,12 +8,22 @@ config.eager_load = false config.logger = ActiveSupport::Logger.new($stdout) config.active_support.remove_deprecated_time_with_zone_name = false + + # These specs boot a new application for each example. Rails main's + # inflection freezer registers a global after_initialize hook on every + # boot, so exclude that unrelated initializer from these test apps. + def initializers + Rails::Initializable::Collection.new( + super.reject { |initializer| initializer.name == "active_support.freeze_inflections" } + ) + end end.instance end before do stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}") Rails.application = nil + reset_frozen_inflections ActiveSupport::Dependencies.autoload_paths = ActiveSupport::Dependencies.autoload_paths.dup ActiveSupport::Dependencies.autoload_once_paths = ActiveSupport::Dependencies.autoload_once_paths.dup end @@ -371,4 +381,21 @@ def initializer(&block) block.call end end + + # Rails main freezes global inflection instances after an application boots. + # This spec boots a fresh application for each example, so give each one an + # unfrozen copy of the previous application's inflections. + def reset_frozen_inflections + inflections = ActiveSupport::Inflector::Inflections + return unless inflections.respond_to?(:all_instances) + return unless inflections.all_instances.compact.any?(&:frozen?) + + english = inflections.instance_variable_get(:@__en_instance__) + instances = inflections.instance_variable_get(:@__instance__) + copied_instances = instances.dup + instances.each_pair { |locale, instance| copied_instances[locale] = instance.dup } + + inflections.instance_variable_set(:@__en_instance__, english.dup) if english + inflections.instance_variable_set(:@__instance__, copied_instances) + end end From 96de243b74ca469b77fe6c6fafd5963cb927ca41 Mon Sep 17 00:00:00 2001 From: John Nunemaker Date: Sun, 2 Aug 2026 19:25:01 -0400 Subject: [PATCH 5/8] Remove unrelated planning documents --- ROADMAP_IDEAS.md | 95 -------------- SECURITY_AUDIT.md | 315 ---------------------------------------------- 2 files changed, 410 deletions(-) delete mode 100644 ROADMAP_IDEAS.md delete mode 100644 SECURITY_AUDIT.md diff --git a/ROADMAP_IDEAS.md b/ROADMAP_IDEAS.md deleted file mode 100644 index 10a4b17f1..000000000 --- a/ROADMAP_IDEAS.md +++ /dev/null @@ -1,95 +0,0 @@ -# Flipper: Product Opportunities - -_Research-backed ideas for what to build next, grounded in the 2025–2026 competitive landscape and the resiliency tooling gaps around Ruby/Rails. Compiled 2026-07._ - -## Context / TLDR - -The feature-flag category converged hard in 2025–2026: - -- **Typed/JSON flag values** and **MCP servers** are now universal — Flipper is the only notable tool without either. -- **Metric-guarded rollouts with auto-rollback** became the commercial differentiator (LaunchDarkly Release Guardian, Datadog Feature Flags). -- **Three of seven commercial vendors were acquired by AI/observability companies** in a 9-month window: Statsig → OpenAI ($1.1B), Eppo → Datadog, DevCycle → Dynatrace. The market decided flags + observability is one product. - -On the resiliency side, the strongest signal is that teams already hand-build ops tooling **on top of Flipper** (GitLab runbooks, PlanetScale's Sidekiq kill-switch middleware), while the Ruby OSS in that space is largely abandonware. - -Because the owner is already building an APM, several items below rank higher than they otherwise would — the APM is the health signal that makes guarded rollouts and auto-tripping kill switches possible natively. - ---- - -## List 1 — Gaps vs other flag platforms (ranked) - -### 1. Typed / JSON flag values (multivariate) -The keystone gap. Every competitor — all 7 commercial, all 5 OSS — supports string/number/JSON values; GrowthBook's comparison content explicitly calls Flipper out as the only tool without it. Dynamic config, AI/prompt config, and experimentation all sit downstream of "a flag can return a value, not just a boolean." Biggest lift on the list, but everything else worth wanting in 2027 depends on it. Splits naturally across tiers: typed values in OSS, editing/targeting UI in Pro/Cloud. - -### 2. Guarded rollouts with auto-rollback (APM synergy) -**The** frontier feature: LaunchDarkly Release Guardian, Datadog's Feature Flags GA headline (auto-rollback on APM/RUM/SLO signals), Harness Release Monitoring. No Rails-native version exists at any price. Uniquely well-positioned here because the hard part is the health signal — the APM being built produces exactly that. Percentage rollout ramps automatically, the APM watches error rate/latency for the new cohort, and the flag halts or reverts itself on regression. This is what makes flags + APM one product, and it's why Datadog and Dynatrace bought their way into flags. - -### 3. Official MCP server -All 12 researched competitors shipped one in ~6 months — novel to table stakes inside 2025. Cheap here: the Cloud API already exists; wrap it with role-scoped tools (list/check/enable-for-actor, guarded by permissions). A local OSS variant that talks to the app's own adapter is the dev-loop version. Low effort, high "Flipper is current" signal, increasingly a checklist item in tool selection. - -### 4. OpenFeature provider (+ optional OFREP on Cloud) -OpenFeature is now the category's interop layer — providers exist for essentially everyone but Flipper; Datadog built its product on it; Cloudflare's new flag service is OpenFeature-native. A Ruby provider is a thin gem. Bigger strategic move: an **OFREP-compliant evaluation endpoint** on Cloud makes any OpenFeature SDK in any language a Flipper client — the cheapest answer to "Flipper is Ruby-only" without maintaining 15 SDKs. - -### 5. Release orchestration: approvals, change requests, scheduled changes in the UI -LaunchDarkly, Statsig, Harness, and Datadog all have approval workflows; Flipper has none, and it's a hard requirement for regulated buyers. Scheduled changes partially exist via 1.4.0 time-based expressions, but there's no UI affordance ("enable at 9am Tuesday, ramp to 100% by Friday"). Natural Gold/enterprise material. - -### 6. SSO/SAML and 2FA on Cloud -Unsexy, but the classic enterprise deal-blocker — docs show neither. Pure sales unblocking for the tier where the money is. - -### 7. AI-generated flag-cleanup PRs -Pro already has call sites; Cloud has stale detection — two-thirds of the way to what became the new bar in 2025 (LaunchDarkly Vega, Datadog Bits AI, Statsig). Closing the loop (stale flag + known call sites → behavior-preserving removal PR) is a smaller step here than it was for them. - -### 8. Experimentation (deliberately last) -The stats arms race (CUPED, sequential testing, warehouse-native) is a different company, and GrowthBook gives a full stats engine away free — no margin in chasing it. Honest play: lightweight "impact" views on telemetry already collected, plus a documented GrowthBook/PostHog integration path for teams that outgrow that. - ---- - -## List 2 — One step to the side: resiliency tooling (ranked) - -Ranked partly by how much each compounds with flags + APM. - -### 1. The APM (already being built) -Independently validated as #1 by the flag research: observability became the moat (LaunchDarkly acquired Highlight, Datadog acquired Eppo, Dynatrace acquired DevCycle). Flags wired to health signals is where the category is going. - -### 2. Dynamic runtime config -The most validated adjacency — every major flag vendor's second act (Statsig Dynamic Config, LaunchDarkly JSON flags, ConfigCat, Firebase). Ruby incumbent is `rails-settings-cached`: 1,000+ dependents (incl. Mastodon), but no UI, no audit, no targeting, no environments, barely moving. Devs hand-roll Redis singletons for the exact resiliency knobs you'd turn mid-incident because deploying an ENV change takes ~15 minutes. This is List 1 #1 in different clothes: `Flipper.setting(:page_size).value`, per-actor/per-tenant overrides (unserved multi-tenant pain), audit + rollback included. One investment, two product stories. - -### 3. Background job control plane -Strongest evidence of unmet demand in the whole study: GitLab runbooks and PlanetScale's published middleware both already implement kill/defer/throttle for Sidekiq jobs **using Flipper**. Queue pausing is paywalled in Sidekiq Pro (~$229/mo), limiters in Enterprise (~$749/mo); OSS gap-fillers (sidekiq-limit_fetch et al.) are unmaintained; Solid Queue explicitly declined rate limiting; no dashboard spans Sidekiq + Solid Queue + GoodJob. Productize what GitLab hand-rolled — per-job-class/per-tenant kill, defer, throttle, gradual re-enable via percentage gates, audit — through ActiveJob/Sidekiq middleware. Wedge: the huge OSS-Sidekiq base that won't pay $749/mo. - -### 4. Operational toggles / kill switches as a first-class type -Kill switches are already the top non-release use of flags; the productizable delta is what plain flags lack: auto-trip on error thresholds (APM again), TTL/auto-expiring switches, fail-safe direction + runbook links, and percentage gates as brownout dials ("serve cached homepage to 40% of anonymous traffic"). Unleash is coining "FeatureOps" to claim this; nothing Ruby-native does it. Cheap relative to its story value; reframes Flipper from "release tool" to "production control panel." - -### 5. Deploy safety / Kamal integration -Kamal has no canary workflow — kamal-proxy has percentage-rollout code implemented-but-hidden for ~2 years — and 37signals' stated position is that canarying belongs in feature flags (effectively an invitation). Deploy markers + a post-deploy flag guard ("after `kamal deploy`, watch these metrics, auto-halt the rollout flag") largely falls out of List 1 #2 with a Kamal on-ramp. - -### 6. Runtime-adjustable rate limiting -Algorithm layer is commoditized (Rails 8 `rate_limit`, rack-attack, Cloudflare), but nobody owns the control plane: per-plan/per-tenant limits adjustable from a dashboard mid-incident, with audit and observability. "Flipper for limits" — and the in-process-gem-plus-sync architecture answers the latency objection that kills hosted rate-limit APIs on HN. Real but narrower than 2–5. - -### 7. Circuit breaker visibility (integrate, don't rebuild) -Ruby's breaker gems are mediocre-to-dead (circuitbox stalled since 2023; Semian is powerful but config-driven with no dashboard; Evil Martians wrote in 2025 the category "lacks monitoring and real-time management," with fresh demand from flaky LLM APIs). Nobody offers a hosted breaker control plane. But total market attention is modest — do it as an integration (surface Semian/Stoplight state, alert on open circuits, "when breaker opens, flip flag X"), not a product. - -### 8. Maintenance / read-only mode -Incumbent gem (turnout) died in 2018; Kamal now handles the static-page tier. App-aware maintenance — read-only mode, admins-through, scheduled windows — is still hand-rolled, and every DIY version is literally a flag in middleware. Ship `Flipper::Maintenance` as a packaged feature for retention/marketing; a prior hosted-maintenance-mode startup died, so don't make it a product. - -### Avoid -- **Chaos engineering for Ruby** — two company-backed gems already died; commercial players deliberately stay at the infra layer. -- **On-call/paging** — crowded, commoditizing, SMS reliability is a different company's problem. (Worth stealing only the small "which flags changed during this incident" view.) - ---- - -## The through-line - -List 2 items 2–6 plus guarded rollouts from List 1 converge on one story no Ruby incumbent owns: **the production control panel for Rails** — flags, config, jobs, limits, and deploys, all watched by the APM and all able to react to it. The market decided in 2025 that flags + observability is one product; Flipper is one of very few positioned to build that natively for Rails instead of bolting it on via acquisition. - ---- - -## Appendix: what Flipper Cloud/Pro already ship (so as not to duplicate) - -**Pro (self-hosted, early access):** self-hosted dashboard, expressions UI, feature owners, call sites (code scanning w/ editor deep links), audit log (+ Slack), multi-database support, dynamic/large actor sets. - -**Cloud (hosted):** environments (production + personal + custom, prod mirroring), 3-level RBAC + trusted domains, longevity/owners/tags, audit history + one-click rollback, telemetry (evaluation metrics, stale detection via telemetry summary), webhooks (HMAC-signed, instant sync), Slack integration, super search (⌘K), REST API, local-sync model. - -**Recently shipped (2025–2026):** time-based expressions (1.4.0), smarter ⌘K, Slack integration, extended telemetry timeframes, expressions in Cloud UI, tag picker. - -**Confirmed gaps in all tiers:** no SAML/SSO or 2FA, no A/B testing, no UI scheduled/guarded rollouts, no approval workflows, no non-Ruby server SDKs (JS adapter only), no OpenFeature provider, no MCP server, no typed/JSON flag values. diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md deleted file mode 100644 index d68f9bac6..000000000 --- a/SECURITY_AUDIT.md +++ /dev/null @@ -1,315 +0,0 @@ -# Flipper Security, Correctness & Concurrency Audit - -**Date:** 2026-07-06 -**Scope:** `lib/` — core library, storage adapters, UI, API, middleware, and Flipper Cloud. -**Method:** Static source review across five focus areas (UI/API security, threading/concurrency, core correctness, storage adapters, cloud/CLI). Every finding below was re-verified against the source by reading the cited lines. - -> **Context on Flipper's threat model.** The UI and API middleware intentionally ship **without** authentication — the host application is expected to protect the mount point. Several findings are only reachable when that mounting is misconfigured (public mount, weak auth). They are still worth fixing because misconfiguration is common and cheap to defend against. Similarly, Flipper's primary API is per-thread (`Flipper.enabled?` uses a per-thread instance), so several concurrency bugs only surface when a single `Flipper`/adapter instance is deliberately shared across threads (a documented, common pattern like `$flipper = Flipper.new(adapter)`) or in fork-based servers. - ---- - -## Severity Summary - -| # | Severity | Category | Issue | Location | -|---|----------|----------|-------|----------| -| 1 | **High** | Security (XSS) | Stored HTML/XSS via unescaped actor identifiers on the dashboard | `ui/decorators/feature.rb:44,48` + `ui/views/features.erb:52` | -| 2 | **High** | Threading | Fork-time `Mutex#unlock` of a foreign mutex raises `ThreadError` in the fork-recovery path | `adapters/memory.rb:125-141`, `poller.rb:133-137` | -| 3 | **Medium** | Security (CSRF) | Authenticity-token check silently dropped when any `rack_protection` option is passed | `ui.rb:37-41` | -| 4 | **Medium** | Correctness | `race_condition_ttl` cache option is a silent no-op | `adapters/active_support_cache_store.rb:83` | -| 5 | **Medium** | Correctness | Comparison expressions raise `ArgumentError` on type-mismatched operands | `expressions/comparable.rb:8` | -| 6 | **Medium** | Security | Webhook replay: signature timestamp tolerance never enforced | `cloud/middleware.rb:36`, `cloud/message_verifier.rb:46` | -| 7 | **Medium** | Reliability | Failover/Failsafe swallow the entire `StandardError` hierarchy by default | `adapters/failsafe.rb:14`, `adapters/failover.rb:23` | -| 8 | **Medium** | Security | Cloud auth token leaked to STDOUT/logs when debug output is enabled | `cloud/configuration.rb:191-202`, `adapters/http/client.rb:94` | -| 9 | **Medium** | Threading | Non-atomic sync gates cause thundering-herd concurrent syncs (`Poll` + `IntervalSynchronizer`) | `adapters/poll.rb:41-49`, `adapters/sync/interval_synchronizer.rb:27-41` | -| 10 | **Medium** | Threading | Shared DSL `@memoized_features` / Memoizable `@cache` mutated concurrently | `dsl.rb:221`, `adapters/memoizable.rb` | -| 11 | **Medium** | Threading | Cloud telemetry reassigns `@metric_storage`/`@pool`/`@timer` without synchronization | `cloud/telemetry.rb:63-118` | -| 12 | **Low** | Correctness | AR migration warning can never fire (inverted memoization guard) | `adapters/active_record.rb:300-303` | -| 13 | **Low** | Correctness | JSON exporter mutates adapter-owned hash in place | `exporters/json/v1.rb:14-20` | -| 14 | **Low** | Data integrity | Moneta adapter non-atomic read-modify-write → lost updates | `adapters/moneta.rb` (`enable`/`disable`/`add`/`remove`) | -| 15 | **Low** | Data integrity | Failover dual-write is non-atomic with no reconciliation | `adapters/failover.rb:54-82` | -| 16 | **Low** | Security | HTTP adapter interpolates feature/gate keys into URLs without escaping | `adapters/http.rb`, `adapters/http/client.rb` | -| 17 | **Low** | Security | `RedisCache` uses `Marshal.load` on cached blobs (RCE gadget sink) | `adapters/redis_cache.rb:23,35,42` | -| 18 | **Low** | Correctness | CRC32 modulo bias in percentage-of-actors bucketing | `gates/percentage_of_actors.rb:33` | -| 19 | **Low** | Reliability | `Poller#stop` uses `Thread#kill` (abrupt, self-kill from sync path) | `poller.rb:55-60` | -| 20 | **Low** | Threading | `Flipper.configuration` / `groups_registry` lazy init is racy at boot | `flipper.rb:29,182` | -| 21 | **Low** | Reliability | CLI auto-opens a server-controlled URL without confirmation | `cli.rb:102-106` | -| 22 | **Low** | Security | Cleartext token transmission if Cloud URL configured as `http://` | `adapters/http/client.rb:96-99` | -| 23 | **Low** | Robustness | Import endpoints: unbounded input + unguarded param access | `ui/actions/import.rb:12`, `api/v1/actions/import.rb:16` | -| 24 | **Info** | Correctness | Multi-actor percentage-of-actors hashes the concatenation of all actors | `gates/percentage_of_actors.rb:33` | -| 25 | **Info** | Various | Minor items: `Typecast.to_set` on scalars, `FeatureEnabled` cleanup bookkeeping, `uri_for_path` leading `&`, gzip has no size cap, webhook error reflected in headers, `at_exit` accumulation, no built-in UI/API auth | see details | - ---- - -## High Severity - -### 1. Stored HTML / XSS via unescaped actor identifiers on the dashboard -**Files:** `lib/flipper/ui/decorators/feature.rb:44,48`; rendered raw at `lib/flipper/ui/views/features.erb:52` - -`gates_in_words` hand-builds an HTML string and interpolates actor values straight into a `title` attribute with no escaping: - -```ruby -statuses << %Q() + ... -``` - -and the view emits it **raw** (`<%==`, unescaped): - -```erb -<%== feature.gates_in_words %> -``` - -Actor `flipper_id`s are free-form strings with no character restrictions and can be introduced by a lower-privilege user via the UI "Add Actor" form (`ui/actions/actors_gate.rb`), the API (`api/v1/actions/actors_gate.rb`), or an imported export file. An id like `x" onmouseover="alert(document.domain)` or `">` is stored and then written unescaped into the dashboard for every admin who loads `/features` (the default landing page). - -**Mitigating factor:** UI responses set a restrictive CSP (`ui/action.rb:39-48`, `script-src 'self'` with no `unsafe-inline`), which blocks injected inline scripts/handlers in modern browsers. This downgrades it from a clean JS-execution bug to HTML/CSS injection + content spoofing — but JS execution returns anywhere the CSP is stripped or weakened (reverse proxies, a host app that sets its own CSP, older browsers). The single-feature page renders the same data safely with `<%= %>` / `Sanitize.fragment`; only this list-page path is unescaped. - -**Fix:** HTML-escape the interpolated actor values inside `gates_in_words` (e.g. `Rack::Utils.escape_html`), or return structured data and escape in the view instead of using `<%==`. - -### 2. Fork-time `Mutex#unlock` of a foreign mutex raises `ThreadError` -**Files:** `lib/flipper/adapters/memory.rb:125-141`; `lib/flipper/poller.rb:133-137` - -The fork-recovery code unlocks a mutex it may not own: - -```ruby -def reset - @pid = Process.pid - @lock&.unlock if @lock&.locked? # unlocking a mutex owned by a now-dead thread -end - -def synchronize(&block) - if @lock - reset if forked? # runs OUTSIDE the lock - @lock.synchronize(&block) - ... -``` - -If a process forks (Puma/Unicorn/Resque preload-then-fork) while another thread holds the mutex, the child inherits a mutex flagged as locked by a thread that no longer exists. `locked?` returns `true`, and `unlock` from the surviving thread raises `ThreadError: Attempt to unlock a mutex which is locked by another thread` — a crash in the exact recovery path meant to prevent one. `memory.rb` has an additional TOCTOU: `reset` runs before the lock is acquired, so two threads in a fresh child can both call `reset` and the second `unlock` hits "not locked." - -**Fix:** After a fork, **replace** the mutex rather than unlock it: `@lock = Mutex.new` (and `@mutex = Mutex.new` in the poller). A fresh mutex is the only safe post-fork state. - ---- - -## Medium Severity - -### 3. Authenticity-token CSRF check silently dropped when a `rack_protection` option is passed -**File:** `lib/flipper/ui.rb:37-41` - -```ruby -if rack_protection_options.empty? - builder.use Rack::Protection::AuthenticityToken # form-token CSRF check -else - builder.use Rack::Protection, rack_protection_options -end -``` - -The UI's forms all embed a CSRF token (`ui/action.rb` `csrf_input_tag`) whose validation depends on `Rack::Protection::AuthenticityToken`. But in rack-protection 3.x/4.x the bundled `Rack::Protection` middleware has `AuthenticityToken` **off by default**. So passing *any* non-empty `rack_protection:` option (e.g. `{ allow_if: ... }`) drops the token check entirely. The code comment ("go whole hog and include all of Rack::Protection") is factually wrong. Residual protections (`HttpOrigin`, `RemoteToken`, `JsonCsrf`) still block many cross-origin POSTs but fail open for requests lacking `Origin`/`Referer`. - -**Fix:** Always include the token check, e.g. add `builder.use Rack::Protection::AuthenticityToken` unconditionally, or merge `use: [:authenticity_token, ...]` into the options path. Fix the comment. - -### 4. `race_condition_ttl` cache option is a silent no-op -**File:** `lib/flipper/adapters/active_support_cache_store.rb:83` - -```ruby -def write_options - write_options = {} - write_options[:expires_in] = @ttl if @ttl - write_options[:race_condition_ttl] if @race_condition_ttl # reads the key, never assigns - write_options -end -``` - -Line 83 evaluates `write_options[:race_condition_ttl]` (nil) as a bare expression and never assigns anything. Users who configure `race_condition_ttl:` to guard against cache-stampede get **zero** protection, with no error or warning — defeating the exact race the option exists to prevent. - -**Fix:** `write_options[:race_condition_ttl] = @race_condition_ttl if @race_condition_ttl` - -### 5. Comparison expressions raise `ArgumentError` on type-mismatched operands -**File:** `lib/flipper/expressions/comparable.rb:8` (used by `greater_than`, `less_than`, `greater_than_or_equal_to`, `less_than_or_equal_to`) - -```ruby -def self.call(left, right) - left.respond_to?(operator) && right.respond_to?(operator) && left.public_send(operator, right) -end -``` - -The `respond_to?` guards confirm both sides respond to `>`/`<` etc., but **not** that they're type-compatible. Every `String` and every `Integer` responds to `>`, yet `"25" > 21` raises `ArgumentError: comparison of String with 21 failed`. Property values commonly arrive as strings from JSON, so an actor with `flipper_properties = { age: "25" }` checked against `Flipper.property(:age).gte(21)` raises out of `Feature#enabled?` — the whole flag check blows up instead of returning `false`. (Missing/`nil` properties are safe because `nil.respond_to?(:>=)` is false.) - -**Fix:** Rescue `ArgumentError` and treat a failed comparison as `false`, or normalize operand types before comparing. - -### 6. Webhook replay: signature timestamp tolerance never enforced -**Files:** `lib/flipper/cloud/middleware.rb:36`; `lib/flipper/cloud/message_verifier.rb:46` - -The HMAC signature check itself is correct and timing-safe (SHA256 digest + constant-time compare in `secure_compare`), and the signed message includes the timestamp, so it can't be tampered. **But** the middleware never passes a `tolerance:`: - -```ruby -if message_verifier.verify(payload, signature) # tolerance defaults to nil → freshness check skipped -``` - -```ruby -def verify(payload, header, tolerance: nil) - ... - if tolerance && timestamp < Time.now - tolerance # skipped entirely when tolerance is nil -``` - -A single captured, validly-signed webhook can be replayed by an unauthenticated caller indefinitely. Impact is bounded — each replay forces `flipper.sync(cache_bust: true)` — so this is a replay/amplification-DoS, not an integrity break. - -**Fix:** Pass a tolerance from the middleware (`verify(payload, signature, tolerance: 60)`) and rescue the failure as a 400. Consider also rejecting timestamps too far in the future. - -### 7. Failover/Failsafe swallow the entire `StandardError` hierarchy by default -**Files:** `lib/flipper/adapters/failsafe.rb:14`; `lib/flipper/adapters/failover.rb:23` - -```ruby -@errors = options.fetch(:errors, [StandardError]) -... -rescue *@errors -``` - -The default catches **everything** — `NoMethodError`, `TypeError`, `JSON::ParserError`, serialization bugs — not just connectivity failures. A genuine code/data bug in the primary adapter is silently masked: Failsafe returns `{}`/`Set.new`/`false` (which reads as "all features disabled" in production), and Failover quietly serves possibly-stale secondary data. The operator sees no error. - -**Fix:** Default to a narrow connectivity-error list (timeouts, `Errno::ECONNREFUSED`, `Redis::BaseConnectionError`, etc.) and/or instrument every swallowed exception so failures stay observable. - -### 8. Cloud auth token leaked to STDOUT/logs when debug output is enabled -**Files:** `lib/flipper/cloud/configuration.rb:191-202`; `lib/flipper/adapters/http/client.rb:94` - -Enabling `FLIPPER_CLOUD_DEBUG_OUTPUT_STDOUT` (or `debug_output=`) hands the raw stream to `Net::HTTP#set_debug_output`, which dumps all request headers — including `flipper-cloud-token` (the environment's bearer credential) — in cleartext to logs. Requires operator opt-in, so it's a footgun rather than a default exposure, but an operator debugging a sync issue in production leaks the token to centralized logging. - -**Fix:** Redact the `flipper-cloud-token` / `authorization` headers before handing the stream to `set_debug_output`, or loudly document that debug output exposes the token. - -### 9. Non-atomic sync gates cause thundering-herd concurrent syncs -**Files:** `lib/flipper/adapters/poll.rb:41-49`; `lib/flipper/adapters/sync/interval_synchronizer.rb:27-41` - -Both use an unsynchronized check-then-act on a plain ivar shared across all request threads: - -```ruby -# interval_synchronizer.rb -def call - return unless time_to_sync? # reads @last_sync_at - @last_sync_at = now # plain ivar, no lock - @synchronizer.call -end -``` - -After the interval elapses, N threads all see `time_to_sync?` true before any updates the timestamp, so **all N** run a full `Synchronizer#call` — each a remote `get_all` round-trip plus overlapping local writes. Defeats the interval limiting. Same pattern in `Poll#synced_adapter` (`@last_synced_at`). - -**Fix:** Make the gate atomic — mutex around the read-check-set, or a `Concurrent::AtomicFixnum` with `compare_and_set` so exactly one thread wins per interval. - -### 10. Shared DSL `@memoized_features` / Memoizable `@cache` mutated concurrently -**Files:** `lib/flipper/dsl.rb:221`; `lib/flipper/adapters/memoizable.rb` - -```ruby -@memoized_features[name.to_sym] ||= Feature.new(name, @adapter, instrumenter: instrumenter) -``` - -Safe under the per-thread module API, but a shared `Flipper.new(adapter)` / `Flipper::Cloud.new` instance across threads (a supported, common pattern) mutates a plain `Hash` with a non-atomic `||=`. Concurrent insert during another thread's iteration raises `can't add a new key into hash during iteration` or loses writes. Memoizable's `@cache.fetch(k){ cache[k]=... }` has the same hazard when memoizing. - -**Fix:** Back both with `Concurrent::Map` (`compute_if_absent` is atomic), or document that a DSL instance isn't safe to share across threads. - -### 11. Cloud telemetry reassigns shared state without synchronization -**File:** `lib/flipper/cloud/telemetry.rb:63-118` - -`record` (arbitrary app threads), `post_to_pool` (timer thread), and `post_to_cloud` (pool thread) all read `@metric_storage`/`@pool`/`@timer`, while `restart` (on fork) and `stop` (on a `telemetry-shutdown` header) reassign/tear them down with no lock. Races drop metrics (increment into a swapped-out storage; `@pool.post` onto a shutting-down pool discarded silently) and can observe an inconsistent storage/pool/timer trio. - -**Fix:** Guard `start`/`stop`/`restart` and the reads with a mutex (or swap an `AtomicReference` atomically); at minimum snapshot `storage = @metric_storage` once per method. - ---- - -## Low Severity - -### 12. AR migration warning can never fire (inverted guard) -**File:** `lib/flipper/adapters/active_record.rb:300-303` - -```ruby -def warned_about_value_not_text? - return @warned_about_value_not_text if defined?(@warned_about_value_not_text) - @warned_about_value_not_text = true # returns true on the FIRST call -end -``` - -On the first call the ivar is undefined, so it falls through, sets `true`, and returns `true` — making `!warned_about_value_not_text?` false forever. The `VALUE_TO_TEXT_WARNING` telling users to run the JSON-column migration is permanently suppressed; they get no heads-up until a hard `raise` on a JSON write. **Fix:** return `false` the first time, flip to `true` after. - -### 13. JSON exporter mutates adapter-owned hash in place -**File:** `lib/flipper/exporters/json/v1.rb:14-20` - -```ruby -features = adapter.get_all -features.each do |feature_key, gates| - gates.each do |key, value| - features[feature_key][key] = value.to_a if value.is_a?(Set) # mutates live adapter data - end -end -``` - -The default `Adapter#get_all` returns references to internal storage for adapters whose `get` returns internal refs. Exporting then replaces stored `Set`s with `Array`s inside the adapter's live data, so a later actor-add does `array << value` and permits duplicates. The stock `Memory` adapter escapes this (it returns a fresh copy), but the documented default path is unsafe. **Fix:** build a new structure with `transform_values` instead of mutating. - -### 14. Moneta adapter non-atomic read-modify-write → lost updates -**File:** `lib/flipper/adapters/moneta.rb` (`enable`/`disable`/`add`/`remove`) - -Unlike Mongo (`$addToSet`/`$pull`) and Redis (`hset`/`hdel`), Moneta does full read → mutate-in-Ruby → write-back with no lock. Two concurrent `enable`s for actors A and B on the same feature both read the same set, each adds one, each writes back — the second clobbers the first and one actor is silently dropped. **Fix:** use Moneta atomic primitives / store-level lock where available, or document that Moneta is unsafe for concurrent writes. - -### 15. Failover dual-write is non-atomic with no reconciliation -**File:** `lib/flipper/adapters/failover.rb:54-82` - -With `dual_write: true`, primary and secondary writes are sequential and unguarded. If the secondary raises, the primary is already mutated and the exception propagates — the stores diverge permanently, and a flaky secondary breaks writes even while the primary is healthy. **Fix:** rescue/instrument the secondary write without failing the primary; provide a reconciliation path. - -### 16. HTTP adapter interpolates feature/gate keys into URLs without escaping -**Files:** `lib/flipper/adapters/http.rb`, `lib/flipper/adapters/http/client.rb` - -Keys are interpolated raw into paths/queries (`"/features/#{feature.key}/#{gate.key}"`, `"/features?keys=#{csv_keys}"`). A key containing `/`, `?`, `&`, `#`, a comma, or spaces produces a malformed request or injects/overrides query parameters. Keys are normally developer-controlled, hence Low. **Fix:** `URI.encode_www_form_component` each key. (Related: `uri_for_path` produces a spurious leading `&` when the base URL has no query string.) - -### 17. `RedisCache` uses `Marshal.load` on cached blobs -**File:** `lib/flipper/adapters/redis_cache.rb:23,35,42` - -Cache values are serialized with `Marshal.dump`/`Marshal.load`. Data is written by Flipper itself, so this is only exploitable if an attacker can write to the cache Redis — but caches are often treated as disposable/less-secured than the primary store, and `Marshal.load` is a classic RCE gadget sink. **Fix:** use a safe serializer (JSON / `Flipper::Typecast`) for cache blobs, or document that the cache store must be as trusted as the primary. - -### 18. CRC32 modulo bias in percentage-of-actors bucketing -**File:** `lib/flipper/gates/percentage_of_actors.rb:33` (and `expressions/percentage_of_actors.rb`) - -`Zlib.crc32(id) % (100 * SCALING_FACTOR)` is not uniform: `2**32` isn't a multiple of `100_000`, so residues `0..67_295` occur once more often than the rest. Since the "enabled" region is the low residues, buckets below 67,296 are over-represented by ≈0.0023%. Boundary conditions (0%/100%) and monotonicity are correct. Tiny fairness deviation. **Fix:** map crc32 to `[0,1)` via a full-range divisor, or use a modulus that divides the hash range. - -### 19. `Poller#stop` uses `Thread#kill` -**File:** `lib/flipper/poller.rb:55-60` - -`Thread#kill` asynchronously terminates the poller wherever it is (mid-`import`), can leave partially-imported state, and doesn't nil out `@thread`. The `poll-shutdown` header path runs on the poller thread and calls `stop` → self-kill mid-`sync`. **Fix:** cooperative shutdown via the existing `@shutdown_requested` AtomicBoolean checked in the run loop, then `join`; clear `@thread = nil`. - -### 20. `Flipper.configuration` / `groups_registry` lazy init is racy -**File:** `lib/flipper.rb:29,182` - -Module-level `@configuration ||= Configuration.new` is a non-atomic check-then-set. Two threads first touching Flipper concurrently (parallel boot/autoload) can each build a `Configuration`; the losing thread's `Flipper.configure` block results are discarded. Boot is usually single-threaded, hence Low. **Fix:** eagerly initialize at load, or memoize behind a mutex. - -### 21. CLI auto-opens a server-controlled URL without confirmation -**File:** `lib/flipper/cli.rb:102-106` - -`flipper cloud migrate` opens whatever `url` the `/migrate` API returns via `system("open", result.url)` with no prompt. The endpoint is overridable via `FLIPPER_CLOUD_URL`; a malicious/MITM'd endpoint yields a click-free open of an attacker-chosen URI in the developer's environment. (The array form of `system` means no shell injection.) **Fix:** validate the URL is `https://` on an allow-listed host, or just print it. - -### 22. Cleartext token transmission if Cloud URL is `http://` -**File:** `lib/flipper/adapters/http/client.rb:96-99` - -SSL is only enabled when `uri.scheme == "https"`. Since `url` is operator-configurable, an `http://` value sends the `flipper-cloud-token` header in cleartext with no warning. **Fix:** warn or refuse when a non-loopback Cloud URL is non-HTTPS while a token/secret is present. - -### 23. Import endpoints: unbounded input and unguarded param access -**Files:** `lib/flipper/ui/actions/import.rb:12`; `lib/flipper/api/v1/actions/import.rb:16` - -Both read the entire body/upload into memory before parsing (a low-grade DoS on an unauthenticated mount), and the UI path assumes `params['file']` is an upload hash — a missing/plain-string `file` raises `NoMethodError` → 500. Deserialization itself is safe (`Typecast.from_json` → plain `JSON.parse`, no object instantiation). **Fix:** guard `params['file']` presence and cap body/upload size before reading. - ---- - -## Informational - -- **24. Multi-actor percentage-of-actors semantics** (`gates/percentage_of_actors.rb:33`): with multiple actors, the gate hashes the sorted concatenation of *all* actor ids as one composite key rather than asking "is any actor in the bucket?". So `enabled?(a, b)` can be true while `enabled?(a)` and `enabled?(b)` are both false — unlike the actor/group gates which use `any?`. Long-standing behavior; worth documenting. -- **25. Minor robustness items:** - - `Typecast.to_set` (`typecast.rb:65-74`) raises `NoMethodError` on a non-nil scalar (`value.empty?` on an Integer) instead of a clear error — only reachable with malformed adapter data. - - `FeatureEnabled` cycle-cleanup (`expressions/feature_enabled.rb:20-30`) unconditionally deletes `feature_name` in `ensure` even on the cycle-break path where this frame didn't add it. Traced cyclic graphs still terminate correctly, but the asymmetric bookkeeping is fragile. - - Gzip response decompression (`serializers/gzip.rb`) has no decompressed-size cap (decompression-bomb pattern) — only reachable from a hostile/MITM'd Cloud endpoint since inbound webhook/import bodies aren't gzip-decompressed. - - Webhook failure reflects the raw exception class/message in `flipper-cloud-response-error-*` response headers (`cloud/middleware.rb:44-49`) — only after successful signature verification, so disclosure risk is minimal. - - Per-instance `at_exit { stop }` handlers (`poller.rb:44`, `cloud/telemetry.rb:59`) accumulate and are never removed; unbounded in fork-heavy servers and inherited handlers close over stale objects. - - **No built-in auth on UI/API** (`ui/middleware.rb`, `api/middleware.rb`): by design, but the root cause that makes findings 1, 3, and 23 reachable when the mount is misconfigured. - ---- - -## Verified as Correct (checked, not bugs) - -- **No SQL injection** in ActiveRecord/Sequel adapters — all queries use parameterized hash conditions and Arel with adapter-controlled table names. -- **Webhook HMAC comparison is timing-safe** — SHA256 digest + constant-time XOR compare in `MessageVerifier#secure_compare`; the signed message authenticates the timestamp. -- **No insecure deserialization** — cloud/import responses use plain `JSON.parse` with no object instantiation; the only `eval` is on trusted compiled template source. -- **HTTP client SSL is correct** for HTTPS URLs (`use_ssl` + `VERIFY_PEER`). -- **Mongo/Redis writes are atomic** (`$addToSet`/`$pull`, `hset`/`hdel`). -- **Path traversal** in `ui/actions/file.rb` is handled by `Rack::Files`; no open-redirect with user-controlled targets. -- **API responses are JSON-only** (no HTML rendering / reflected XSS); reflected `params["error"]` in UI views is HTML-escaped via `<%= %>`. -- Boundary conditions for both percentage gates (0% never, 100% always) and monotonicity are correct; `Registry` locking, `Actor#eql?/hash`, and per-thread cycle/sync-mode tracking are sound. From 8f9e6295765066809060ce36922610445e01d6cf Mon Sep 17 00:00:00 2001 From: John Nunemaker Date: Mon, 3 Aug 2026 17:48:56 -0400 Subject: [PATCH 6/8] Load SQLite adapter explicitly in specs --- spec/flipper/adapters/active_record_spec.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/flipper/adapters/active_record_spec.rb b/spec/flipper/adapters/active_record_spec.rb index f0b2b8ffd..cc8cd1860 100644 --- a/spec/flipper/adapters/active_record_spec.rb +++ b/spec/flipper/adapters/active_record_spec.rb @@ -1,4 +1,5 @@ SpecHelpers.silence { require 'flipper/adapters/active_record' } +require 'active_record/connection_adapters/sqlite3_adapter' # Turn off migration logging for specs ActiveRecord::Migration.verbose = false From 2b5eb70c4bd288bb819b4b2f4737eff7050f7cef Mon Sep 17 00:00:00 2001 From: John Nunemaker Date: Tue, 4 Aug 2026 15:10:43 -0400 Subject: [PATCH 7/8] Use compatible JSON serialization in expression API specs --- spec/flipper/api/v1/actions/expression_gate_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/flipper/api/v1/actions/expression_gate_spec.rb b/spec/flipper/api/v1/actions/expression_gate_spec.rb index 7e5f40a1c..0b0be6a0b 100644 --- a/spec/flipper/api/v1/actions/expression_gate_spec.rb +++ b/spec/flipper/api/v1/actions/expression_gate_spec.rb @@ -124,7 +124,7 @@ describe 'enable with empty group' do before do data = {"Any" => []} - post '/features/my_feature/expression', JSON.dump(data), + post '/features/my_feature/expression', Flipper::Typecast.to_json(data), "CONTENT_TYPE" => "application/json" end @@ -137,7 +137,7 @@ describe 'enable with nested empty group' do before do data = {"All" => [{"All" => []}]} - post '/features/my_feature/expression', JSON.dump(data), + post '/features/my_feature/expression', Flipper::Typecast.to_json(data), "CONTENT_TYPE" => "application/json" end From f6288692a8165ea2f9b0205a9e25d49a6ec88e6a Mon Sep 17 00:00:00 2001 From: John Nunemaker Date: Wed, 5 Aug 2026 12:26:10 -0400 Subject: [PATCH 8/8] Name expression pruning results --- lib/flipper/expression.rb | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/lib/flipper/expression.rb b/lib/flipper/expression.rb index a52db8192..ab753c81b 100644 --- a/lib/flipper/expression.rb +++ b/lib/flipper/expression.rb @@ -5,6 +5,9 @@ module Flipper class Expression include Builder + PruneResult = Struct.new(:expression, :constant_value, keyword_init: true) + private_constant :PruneResult + def self.build(object) return object if object.is_a?(self) || object.is_a?(Constant) @@ -67,7 +70,7 @@ def empty_groups? # constant false), but an empty Any inside an All is kept because # removing it would broaden the expression from never-true. def prune_empty_groups - prune_empty_groups_with_identity.first + prune_empty_groups_with_identity.expression end # Public: Returns true when this expression is guaranteed to match every @@ -85,28 +88,37 @@ def value protected def prune_empty_groups_with_identity - return [self, nil] unless group? - return [nil, all?] if args.empty? + return PruneResult.new(expression: self) unless group? + return PruneResult.new(constant_value: all?) if args.empty? pruned_args = args.map do |arg| - arg.is_a?(Expression) ? arg.prune_empty_groups_with_identity : [arg, nil] + if arg.is_a?(Expression) + arg.prune_empty_groups_with_identity + else + PruneResult.new(expression: arg) + end end - kept = pruned_args.map do |value, constant| - if constant == false - value || build("Any" => []) if all? - elsif constant.nil? - value + kept = pruned_args.map do |result| + if result.constant_value == false + result.expression || build("Any" => []) if all? + elsif result.constant_value.nil? + result.expression end end.compact # No condition remains. Preserve this group's constant value for an # enclosing group, which lets an empty All disappear from All without # treating it as the unsafe empty-Any-in-All case. - return [nil, constant_group_value] if kept.empty? + if kept.empty? + return PruneResult.new(constant_value: constant_group_value) + end pruned = build(name => kept) - [pruned, pruned.constant_group_value] + PruneResult.new( + expression: pruned, + constant_value: pruned.constant_group_value + ) end def constant_group_value