diff --git a/lib/flipper/cloud/configuration.rb b/lib/flipper/cloud/configuration.rb index 7f9a45972..c65db1822 100644 --- a/lib/flipper/cloud/configuration.rb +++ b/lib/flipper/cloud/configuration.rb @@ -1,5 +1,6 @@ require "logger" require "socket" +require "uri" require "flipper/adapters/http" require "flipper/adapters/poll" require "flipper/poller" @@ -25,8 +26,9 @@ class Configuration # Public: The url for http adapter. Really should only be customized for # development work if you are me and you are not me. Feel free to - # forget you ever saw this. - attr_accessor :url + # forget you ever saw this. Must be https so the cloud token is + # never transmitted in cleartext (local development uses https too). + attr_reader :url # Public: net/http read timeout for all http requests (default: 5). attr_accessor :read_timeout @@ -139,6 +141,16 @@ def instrument(name, payload = {}, &block) instrumenter.instrument(name, payload, &block) end + def url=(value) + unless URI(value.to_s).scheme == "https" + raise ArgumentError, + "Flipper::Cloud url must use https but was #{value.inspect}. " \ + "https is required so your token is never sent in cleartext." + end + + @url = value + end + private def app_adapter diff --git a/lib/flipper/cloud/migrate.rb b/lib/flipper/cloud/migrate.rb index 13ef5d57e..1b40c00b2 100644 --- a/lib/flipper/cloud/migrate.rb +++ b/lib/flipper/cloud/migrate.rb @@ -1,3 +1,4 @@ +require "uri" require "flipper/adapters/http/client" require "flipper/typecast" @@ -55,7 +56,7 @@ def self.push(token, flipper = Flipper) # Private: Build an HTTP client for Cloud API requests. def self.build_client(path, headers: {}) - base_url = ENV.fetch("FLIPPER_CLOUD_URL", DEFAULT_CLOUD_URL) + base_url = https_cloud_url(ENV.fetch("FLIPPER_CLOUD_URL", DEFAULT_CLOUD_URL)) Flipper::Adapters::Http::Client.new( url: "#{base_url}#{path}", @@ -67,5 +68,16 @@ def self.build_client(path, headers: {}) ) end private_class_method :build_client + + def self.https_cloud_url(value) + unless URI(value.to_s).scheme == "https" + raise ArgumentError, + "Flipper::Cloud url must use https but was #{value.inspect}. " \ + "https is required so your token is never sent in cleartext." + end + + value + end + private_class_method :https_cloud_url end end diff --git a/spec/flipper/cloud/configuration_spec.rb b/spec/flipper/cloud/configuration_spec.rb index eb300b699..81f27a093 100644 --- a/spec/flipper/cloud/configuration_spec.rb +++ b/spec/flipper/cloud/configuration_spec.rb @@ -113,13 +113,21 @@ end it "can override url using options" do - options = required_options.merge(url: "http://localhost:5000/adapter") + options = required_options.merge(url: "https://localhost:5000/adapter") instance = described_class.new(options) - expect(instance.url).to eq("http://localhost:5000/adapter") + expect(instance.url).to eq("https://localhost:5000/adapter") + + instance = described_class.new(required_options) + instance.url = "https://localhost:5000/adapter" + expect(instance.url).to eq("https://localhost:5000/adapter") + end + + it "requires https url" do + options = required_options.merge(url: "http://localhost:5000/adapter") + expect { described_class.new(options) }.to raise_error(ArgumentError, /must use https/) instance = described_class.new(required_options) - instance.url = "http://localhost:5000/adapter" - expect(instance.url).to eq("http://localhost:5000/adapter") + expect { instance.url = "http://localhost:5000/adapter" }.to raise_error(ArgumentError, /must use https/) end it "can override URL using ENV var" do diff --git a/spec/flipper/cloud/migrate_spec.rb b/spec/flipper/cloud/migrate_spec.rb index 9745eaedf..7d8b0c8b1 100644 --- a/spec/flipper/cloud/migrate_spec.rb +++ b/spec/flipper/cloud/migrate_spec.rb @@ -13,7 +13,7 @@ around do |example| original = ENV["FLIPPER_CLOUD_URL"] - ENV["FLIPPER_CLOUD_URL"] = "http://localhost:5555" + ENV["FLIPPER_CLOUD_URL"] = "https://localhost:5555" example.run ensure ENV["FLIPPER_CLOUD_URL"] = original @@ -26,19 +26,19 @@ def decompress_request_body describe ".migrate" do it "returns a MigrateResult with code and url on success" do - stub_request(:post, "http://localhost:5555/api/migrate") - .to_return(status: 200, body: '{"url":"http://localhost:5555/cloud/setup/abc123"}', headers: {"Content-Type" => "application/json"}) + stub_request(:post, "https://localhost:5555/api/migrate") + .to_return(status: 200, body: '{"url":"https://localhost:5555/cloud/setup/abc123"}', headers: {"Content-Type" => "application/json"}) result = Flipper::Cloud.migrate(flipper) expect(result).to be_a(Flipper::Cloud::MigrateResult) expect(result.code).to eq(200) - expect(result.url).to eq("http://localhost:5555/cloud/setup/abc123") + expect(result.url).to eq("https://localhost:5555/cloud/setup/abc123") end it "sends export data and metadata in the request body" do - stub = stub_request(:post, "http://localhost:5555/api/migrate") - .to_return(status: 200, body: '{"url":"http://localhost:5555/cloud/setup/abc123"}') + stub = stub_request(:post, "https://localhost:5555/api/migrate") + .to_return(status: 200, body: '{"url":"https://localhost:5555/cloud/setup/abc123"}') Flipper::Cloud.migrate(flipper, app_name: "MyApp") @@ -50,9 +50,9 @@ def decompress_request_body end it "sends gzip-compressed request body" do - stub = stub_request(:post, "http://localhost:5555/api/migrate") + stub = stub_request(:post, "https://localhost:5555/api/migrate") .with(headers: {"content-encoding" => "gzip"}) - .to_return(status: 200, body: '{"url":"http://localhost:5555/cloud/setup/abc"}') + .to_return(status: 200, body: '{"url":"https://localhost:5555/cloud/setup/abc"}') Flipper::Cloud.migrate(flipper) @@ -62,7 +62,7 @@ def decompress_request_body end it "handles error responses" do - stub_request(:post, "http://localhost:5555/api/migrate") + stub_request(:post, "https://localhost:5555/api/migrate") .to_return(status: 500, body: '{"error":"Internal Server Error"}') result = Flipper::Cloud.migrate(flipper) @@ -72,7 +72,7 @@ def decompress_request_body end it "includes error message from response body" do - stub_request(:post, "http://localhost:5555/api/migrate") + stub_request(:post, "https://localhost:5555/api/migrate") .to_return(status: 422, body: '{"error":"Invalid export format"}') result = Flipper::Cloud.migrate(flipper) @@ -82,8 +82,8 @@ def decompress_request_body end it "uses FLIPPER_CLOUD_URL environment variable" do - stub = stub_request(:post, "http://localhost:5555/api/migrate") - .to_return(status: 200, body: '{"url":"http://localhost:5555/cloud/setup/abc"}') + stub = stub_request(:post, "https://localhost:5555/api/migrate") + .to_return(status: 200, body: '{"url":"https://localhost:5555/cloud/setup/abc"}') Flipper::Cloud.migrate(flipper) @@ -91,12 +91,12 @@ def decompress_request_body end it "sends content-type and accept headers" do - stub = stub_request(:post, "http://localhost:5555/api/migrate") + stub = stub_request(:post, "https://localhost:5555/api/migrate") .with(headers: { "content-type" => "application/json", "accept" => "application/json", }) - .to_return(status: 200, body: '{"url":"http://localhost:5555/cloud/setup/abc"}') + .to_return(status: 200, body: '{"url":"https://localhost:5555/cloud/setup/abc"}') Flipper::Cloud.migrate(flipper) @@ -106,7 +106,7 @@ def decompress_request_body describe ".push" do it "returns a MigrateResult with code on success" do - stub_request(:post, "http://localhost:5555/adapter/import") + stub_request(:post, "https://localhost:5555/adapter/import") .to_return(status: 204, body: "") result = Flipper::Cloud.push("test-token", flipper) @@ -116,7 +116,7 @@ def decompress_request_body end it "sends the token as a header" do - stub = stub_request(:post, "http://localhost:5555/adapter/import") + stub = stub_request(:post, "https://localhost:5555/adapter/import") .with(headers: {"flipper-cloud-token" => "test-token"}) .to_return(status: 204, body: "") @@ -125,8 +125,14 @@ def decompress_request_body expect(stub).to have_been_requested end + it "requires HTTPS when sending the token" do + ENV["FLIPPER_CLOUD_URL"] = "http://localhost:5555" + + expect { Flipper::Cloud.push("test-token", flipper) }.to raise_error(ArgumentError, /must use https/) + end + it "sends gzip-compressed export contents as the body" do - stub = stub_request(:post, "http://localhost:5555/adapter/import") + stub = stub_request(:post, "https://localhost:5555/adapter/import") .with(headers: {"content-encoding" => "gzip"}) .to_return(status: 204, body: "") @@ -139,7 +145,7 @@ def decompress_request_body end it "handles error responses" do - stub_request(:post, "http://localhost:5555/adapter/import") + stub_request(:post, "https://localhost:5555/adapter/import") .to_return(status: 401, body: '{"error":"Unauthorized"}') result = Flipper::Cloud.push("bad-token", flipper) @@ -148,7 +154,7 @@ def decompress_request_body end it "includes error message from response body" do - stub_request(:post, "http://localhost:5555/adapter/import") + stub_request(:post, "https://localhost:5555/adapter/import") .to_return(status: 401, body: '{"error":"Invalid token"}') result = Flipper::Cloud.push("bad-token", flipper)