Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion lib/flipper/api/v1/actions/features.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ def get
end

def post
feature_name = params.fetch('name') { json_error_response(:name_invalid) }
feature_name = Typecast.to_feature_name(
params.fetch('name') { json_error_response(:name_invalid) }
)
json_error_response(:name_invalid) if feature_name.empty?
feature = flipper[feature_name]
feature.add
decorated_feature = Decorators::Feature.new(feature)
Expand Down
14 changes: 14 additions & 0 deletions lib/flipper/typecast.rb
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,20 @@ def self.to_set(value)
end
end

# Internal: Convert value to a feature name usable as a feature key.
# Removes invisible format/control characters (zero-width spaces,
# joiners, BOM, etc.) that make visually identical but distinct names,
# and trims unicode whitespace (e.g. non-breaking space) that
# String#strip misses. Visible characters are left untouched.
#
# Returns a String.
def self.to_feature_name(value)
value.to_s
.scrub('')
.gsub(/[\p{Cf}\p{Cc}]/, '')
.gsub(/\A[[:space:]]+|[[:space:]]+\z/, '')
end

def self.features_hash(source)
normalized_source = {}
(source || {}).each do |feature_key, gates|
Expand Down
9 changes: 2 additions & 7 deletions lib/flipper/ui/util.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require "rack/utils"
require "flipper/typecast"

module Flipper
module UI
Expand All @@ -14,14 +15,8 @@ def self.unescape(str)
Rack::Utils.unescape(str)
end

# NFKD decomposes accented/compatibility characters so their base
# characters survive the ASCII conversion; anything unmappable
# (including invisible characters like zero-width spaces) is dropped.
def self.normalize_feature_name(str)
str.to_s
.unicode_normalize(:nfkd)
.encode(Encoding::US_ASCII, invalid: :replace, undef: :replace, replace: '')
.strip
Typecast.to_feature_name(str)
end

def self.blank?(str)
Expand Down
28 changes: 28 additions & 0 deletions spec/flipper/api/v1/actions/features_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,34 @@
end
end

context 'feature name contains invisible characters' do
before do
post '/features', name: "my_\u200Bfeature\u2060"
end

it 'responds 200' do
expect(last_response.status).to eq(200)
end

it 'adds feature with invisible characters removed' do
expect(flipper.features.map(&:key)).to eq(['my_feature'])
end
end

context 'feature name normalizes to empty' do
before do
post '/features', name: "\u200B\u2060"
end

it 'returns correct status code' do
expect(last_response.status).to eq(422)
end

it 'does not add feature' do
expect(flipper.features).to be_empty
end
end

context 'bad request' do
before do
post '/features'
Expand Down
27 changes: 27 additions & 0 deletions spec/flipper/typecast_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,33 @@
end
end


describe "to_feature_name" do
it "keeps accented and non-latin characters" do
expect(described_class.to_feature_name("caf\u00E9")).to eq("caf\u00E9")
expect(described_class.to_feature_name("\u65B0\u6A5F\u80FD")).to eq("\u65B0\u6A5F\u80FD")
end

it "removes invisible format and control characters" do
expect(described_class.to_feature_name("f\u200Bea\u2060ture")).to eq("feature")
expect(described_class.to_feature_name("\uFEFFfeat\u00ADure")).to eq("feature")
expect(described_class.to_feature_name("feat\u0000ure")).to eq("feature")
end

it "trims unicode whitespace" do
expect(described_class.to_feature_name("\u00A0feature\u3000")).to eq("feature")
expect(described_class.to_feature_name(" feature ")).to eq("feature")
end

it "removes invalid utf-8 bytes" do
expect(described_class.to_feature_name("feature\xFF".dup.force_encoding(Encoding::UTF_8))).to eq("feature")
end

it "returns empty string for nil" do
expect(described_class.to_feature_name(nil)).to eq("")
end
end

it "converts to and from json" do
source = {"foo" => "bar"}
output = described_class.to_json(source)
Expand Down
8 changes: 4 additions & 4 deletions spec/flipper/ui/actions/features_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -225,15 +225,15 @@
end

context 'feature name contains accented and invisible characters' do
let(:feature_name) { "f\u200Beá\u2060ture" }
let(:feature_name) { "f\u200Be\u00E1\u2060ture" }

it 'adds feature normalized to ascii' do
expect(flipper.features.map(&:key)).to include('feature')
it 'adds feature with invisible characters removed and accent kept' do
expect(flipper.features.map(&:key)).to include("fe\u00E1ture")
end

it 'redirects to normalized feature' do
expect(last_response.status).to be(302)
expect(last_response.headers['location']).to eq('/features/feature')
expect(last_response.headers['location']).to eq('/features/fe%C3%A1ture')
end
end

Expand Down
40 changes: 23 additions & 17 deletions spec/flipper/ui/util_spec.rb
Original file line number Diff line number Diff line change
@@ -1,35 +1,41 @@
require 'flipper/ui/util'
require "flipper/ui/util"

RSpec.describe Flipper::UI::Util do
describe '#blank?' do
context 'with a string' do
it 'returns true if blank' do
describe "#blank?" do
context "with a string" do
it "returns true if blank" do
expect(described_class.blank?(nil)).to be(true)
expect(described_class.blank?('')).to be(true)
expect(described_class.blank?(' ')).to be(true)
expect(described_class.blank?("")).to be(true)
expect(described_class.blank?(" ")).to be(true)
end

it 'returns false if not blank' do
expect(described_class.blank?('nope')).to be(false)
it "returns false if not blank" do
expect(described_class.blank?("nope")).to be(false)
end
end
end

describe '#normalize_feature_name' do
it 'transliterates accented characters to ascii' do
expect(described_class.normalize_feature_name('café')).to eq('cafe')
describe "#normalize_feature_name" do
it "keeps accented and non-latin characters" do
expect(described_class.normalize_feature_name("caf\u00E9")).to eq("caf\u00E9")
expect(described_class.normalize_feature_name("\u65B0\u6A5F\u80FD")).to eq("\u65B0\u6A5F\u80FD")
end

it 'removes invisible characters' do
expect(described_class.normalize_feature_name("f\u200Beá\u2060ture")).to eq('feature')
it "removes invisible characters like zero width spaces, joiners, bom and soft hyphens" do
expect(described_class.normalize_feature_name("f\u200Bea\u2060ture")).to eq("feature")
expect(described_class.normalize_feature_name("\uFEFFfeat\u00ADure")).to eq("feature")
end

it 'strips surrounding whitespace' do
expect(described_class.normalize_feature_name(' notifications_next ')).to eq('notifications_next')
it "trims unicode whitespace like non-breaking and ideographic spaces" do
expect(described_class.normalize_feature_name("\u00A0feature\u3000")).to eq("feature")
end

it 'returns empty string for nil' do
expect(described_class.normalize_feature_name(nil)).to eq('')
it "strips surrounding whitespace" do
expect(described_class.normalize_feature_name(" notifications_next ")).to eq("notifications_next")
end

it "returns empty string for nil" do
expect(described_class.normalize_feature_name(nil)).to eq("")
end
end
end
Loading