From d369c55eb2288eeb3691c9134a961889d07cef4d Mon Sep 17 00:00:00 2001 From: Nick Muerdter <12112+GUI@users.noreply.github.com> Date: Fri, 1 Dec 2023 19:11:57 -0700 Subject: [PATCH] Add options for beginning to enforce recaptcha validations. - Allow configurable origin hosts for determining when recaptcha should be required. - Remove deprecated `ssl_handshake` usage for lua-resty-http calls (it is now integrated into the connect call). --- config/schema.cue | 9 + src/api-umbrella/utils/elasticsearch.lua | 10 +- src/api-umbrella/web-app/actions/v1/users.lua | 96 ++++- templates/etc/test-env/nginx/apis.conf.etlua | 19 + test/apis/v1/users/test_create.rb | 9 + .../apis/v1/users/test_create_recaptcha_v2.rb | 311 +++++++++++++++ .../apis/v1/users/test_create_recaptcha_v3.rb | 374 ++++++++++++++++++ .../api_umbrella_test_helpers/setup.rb | 13 + 8 files changed, 818 insertions(+), 23 deletions(-) create mode 100644 test/apis/v1/users/test_create_recaptcha_v2.rb create mode 100644 test/apis/v1/users/test_create_recaptcha_v3.rb diff --git a/config/schema.cue b/config/schema.cue index f7c18daf8..b4a6bc551 100644 --- a/config/schema.cue +++ b/config/schema.cue @@ -369,8 +369,17 @@ import "path" default_host?: string send_notify_email?: bool admin_notify_email?: string + #scheme: "http" | "https" + recaptcha_scheme: #scheme | *"https" + recaptcha_host: string | *"www.google.com" + recaptcha_port: uint16 | *443 recaptcha_v2_secret_key?: string + recaptcha_v2_required: bool | *false + recaptcha_v2_required_origin_regex?: string recaptcha_v3_secret_key?: string + recaptcha_v3_required: bool | *false + recaptcha_v3_required_score: float | *0.9 + recaptcha_v3_required_origin_regex?: string } static_site: { diff --git a/src/api-umbrella/utils/elasticsearch.lua b/src/api-umbrella/utils/elasticsearch.lua index e65c84bcb..25f40b5d7 100644 --- a/src/api-umbrella/utils/elasticsearch.lua +++ b/src/api-umbrella/utils/elasticsearch.lua @@ -59,20 +59,14 @@ function _M.query(path, options) scheme = server["scheme"], host = server["host"], port = server["port"], + ssl_server_name = server["host"], + ssl_verify = true, }) if not connect_ok then httpc:close() return nil, "elasticsearch connect error: " .. (connect_err or "") end - if server["scheme"] == "https" then - local ssl_ok, ssl_err = httpc:ssl_handshake(nil, server["host"], true) - if not ssl_ok then - httpc:close() - return nil, "elasticsearch ssl handshake error: " .. (ssl_err or "") - end - end - local res, err = httpc:request(options) if err then httpc:close() diff --git a/src/api-umbrella/web-app/actions/v1/users.lua b/src/api-umbrella/web-app/actions/v1/users.lua index 174b321ed..fa8bab6f3 100644 --- a/src/api-umbrella/web-app/actions/v1/users.lua +++ b/src/api-umbrella/web-app/actions/v1/users.lua @@ -18,7 +18,7 @@ local is_array = require "api-umbrella.utils.is_array" local is_email = require "api-umbrella.utils.is_email" local is_empty = require "api-umbrella.utils.is_empty" local is_hash = require "api-umbrella.utils.is_hash" -local json_decode = require("cjson").decode +local json_decode = require("cjson.safe").decode local json_encode = require "api-umbrella.utils.json_encode" local json_response = require "api-umbrella.web-app.utils.json_response" local known_domains = require "api-umbrella.web-app.utils.known_domains" @@ -32,6 +32,7 @@ local wrapped_json_params = require "api-umbrella.web-app.utils.wrapped_json_par local db_null = db.NULL local gsub = ngx.re.gsub +local re_find = ngx.re.find local _M = {} @@ -135,20 +136,17 @@ local function verify_recaptcha(secret, response) end local connect_ok, connect_err = httpc:connect({ - scheme = "https", - host = "www.google.com", + scheme = config["web"]["recaptcha_scheme"], + host = config["web"]["recaptcha_host"], + port = config["web"]["recaptcha_port"], + ssl_server_name = config["web"]["recaptcha_host"], + ssl_verify = true, }) if not connect_ok then httpc:close() return nil, "recaptcha connect error: " .. (connect_err or "") end - local ssl_ok, ssl_err = httpc:ssl_handshake(nil, "www.google.com", true) - if not ssl_ok then - httpc:close() - return nil, "recaptcha ssl handshake error: " .. (ssl_err or "") - end - local res, err = httpc:request({ method = "POST", path = "/recaptcha/api/siteverify", @@ -182,10 +180,61 @@ local function verify_recaptcha(secret, response) return nil, "Unsuccessful response: " .. (body or "") end - local data = json_decode(body) + local data, json_err = json_decode(body) + if json_err then + return nil, "recaptcha json error: " .. (json_err or "") + end + return data end +local function recaptcha_required_for_origin(required_origin_regex, request_origin) + if not required_origin_regex then + return true + end + + local find_from, _, find_err = re_find(request_origin or "", required_origin_regex, "ijo") + if find_err then + ngx.log(ngx.ERR, "regex error: ", find_err) + return false + end + + if find_from then + return true + else + return false + end +end + +local function recaptcha_passes(self, user_params) + -- Admins don't need captcha. + if self.current_admin then + return true + end + + if config["web"]["recaptcha_v2_required"] and recaptcha_required_for_origin(config["web"]["recaptcha_v2_required_origin_regex"], user_params["registration_origin"]) then + if not user_params["registration_recaptcha_v2_success"] then + return false, "reCAPTCHA v2 not successful" + elseif not known_domains.is_allowed_domain(user_params["registration_recaptcha_v2_hostname"]) then + return false, "reCAPTCHA v2 disallowed domain: " .. (user_params["registration_recaptcha_v2_hostname"] or "") + end + end + + if config["web"]["recaptcha_v3_required"] and recaptcha_required_for_origin(config["web"]["recaptcha_v3_required_origin_regex"], user_params["registration_origin"]) then + if not user_params["registration_recaptcha_v3_success"] then + return false, "reCAPTCHA v3 not successful" + elseif not known_domains.is_allowed_domain(user_params["registration_recaptcha_v3_hostname"]) then + return false, "reCAPTCHA v3 disallowed domain: " .. (user_params["registration_recaptcha_v3_hostname"] or "") + elseif not user_params["registration_recaptcha_v3_score"] then + return false, "reCAPTCHA v3 missing score" + elseif user_params["registration_recaptcha_v3_score"] < config["web"]["recaptcha_v3_required_score"] then + return false, "reCAPTCHA v3 below required score: " .. (user_params["registration_recaptcha_v3_score"] or "") + end + end + + return true +end + function _M.index(self) return datatables.index(self, ApiUser, { where = { @@ -294,14 +343,31 @@ function _M.create(self) end end + local recaptcha_ok, recaptcha_err = recaptcha_passes(self, user_params) + if not recaptcha_ok then + ngx.log(ngx.WARN, "reCAPTCHA failed: ", (recaptcha_err or "") .. "; " .. json_encode(user_params) .. "; " .. json_encode(request_headers)) + return coroutine.yield("error", { + _render = { + errors = { + { + code = "UNEXPECTED_ERROR", + message = t("CAPTCHA verification failed. Please try again or contact us for assistance."), + }, + }, + }, + }) + end + if not self.current_admin and request_headers["referer"] and (not request_headers["user-agent"] or not request_headers["origin"]) then ngx.log(ngx.WARN, "Missing `User-Agent` or `Origin`: " .. json_encode(request_headers) .. "; " .. json_encode(user_params)) return coroutine.yield("error", { - { - code = "UNEXPECTED_ERROR", - field = "email", - field_label = "email", - message = t("An unexpected error occurred during signup. Please try again or contact us for assistance."), + _render = { + errors = { + { + code = "UNEXPECTED_ERROR", + message = t("An unexpected error occurred during signup. Please try again or contact us for assistance."), + }, + }, }, }) end diff --git a/templates/etc/test-env/nginx/apis.conf.etlua b/templates/etc/test-env/nginx/apis.conf.etlua index 188c885ac..37e080a90 100644 --- a/templates/etc/test-env/nginx/apis.conf.etlua +++ b/templates/etc/test-env/nginx/apis.conf.etlua @@ -775,6 +775,25 @@ server { } } + location = /recaptcha/api/siteverify/set-mock { + content_by_lua_block { + local cjson = require "cjson" + ngx.req.read_body() + local body = ngx.req.get_body_data() + ngx.shared.test_data:set("recaptcha_mock", body) + } + } + + location = /recaptcha/api/siteverify { + content_by_lua_block { + local cjson = require "cjson" + local mock = cjson.decode(ngx.shared.test_data:get("recaptcha_mock")) + ngx.status = mock["status"] + ngx.print(mock["body"]) + ngx.exit(ngx.HTTP_OK) + } + } + location = / { echo -n "Test Home Page"; } diff --git a/test/apis/v1/users/test_create.rb b/test/apis/v1/users/test_create.rb index 7e4be0f10..4d6023e95 100644 --- a/test/apis/v1/users/test_create.rb +++ b/test/apis/v1/users/test_create.rb @@ -785,6 +785,15 @@ def test_rejects_empty_origin_for_non_admins :body => MultiJson.dump(:user => attributes), })) assert_response_code(422, response) + data = MultiJson.load(response.body) + assert_equal({ + "errors" => [{ + "code" => "INVALID_INPUT", + "field" => "first_name", + "message" => "is too long (maximum is 80 characters)", + "full_message" => "First name: is too long (maximum is 80 characters)", + }], + }, data) end def test_accepts_empty_origin_for_admins diff --git a/test/apis/v1/users/test_create_recaptcha_v2.rb b/test/apis/v1/users/test_create_recaptcha_v2.rb new file mode 100644 index 000000000..1c819566b --- /dev/null +++ b/test/apis/v1/users/test_create_recaptcha_v2.rb @@ -0,0 +1,311 @@ +require_relative "../../../test_helper" + +class Test::Apis::V1::Users::TestCreateRecaptchaV2 < Minitest::Test + include ApiUmbrellaTestHelpers::AdminAuth + include ApiUmbrellaTestHelpers::Setup + include Minitest::Hooks + + def setup + super + setup_server + once_per_class_setup do + override_config_set({ + "web" => { + "recaptcha_scheme" => "http", + "recaptcha_host" => "127.0.0.1", + "recaptcha_port" => 9444, + "recaptcha_v2_secret_key" => "foobar", + "recaptcha_v2_required" => true, + }, + }) + end + end + + def after_all + super + override_config_reset + end + + def test_successful_recaptcha_response + set_recaptcha_mock({ + "success" => true, + "challenge_ts" => "2023-12-01T20:15:43Z", + "hostname" => "127.0.0.1", + }) + + response = Typhoeus.post("https://127.0.0.1:9081/api-umbrella/v1/users.json", http_options.deep_merge(non_admin_key_creator_api_key).deep_merge({ + :headers => { "Content-Type" => "application/json" }, + :body => MultiJson.dump({ + :user => FactoryBot.attributes_for(:api_user), + "g-recaptcha-response-v2" => "foobar", + }), + })) + assert_response_code(201, response) + data = MultiJson.load(response.body) + user = ApiUser.find(data["user"]["id"]) + assert_equal(true, user.registration_recaptcha_v2_success) + assert_nil(user.registration_recaptcha_v2_error_codes) + assert_equal("127.0.0.1", user.registration_recaptcha_v2_hostname) + end + + def test_rejects_unsuccessful_recaptcha_response + set_recaptcha_mock({ + "success" => false, + "error-codes" => ["timeout-or-duplicate"], + }) + + response = Typhoeus.post("https://127.0.0.1:9081/api-umbrella/v1/users.json", http_options.deep_merge(non_admin_key_creator_api_key).deep_merge({ + :headers => { "Content-Type" => "application/json" }, + :body => MultiJson.dump({ + :user => FactoryBot.attributes_for(:api_user), + "g-recaptcha-response-v2" => "foobar", + }), + })) + assert_recaptcha_rejected(response) + end + + def test_allows_unsuccessful_recaptcha_response_for_admins + set_recaptcha_mock({ + "success" => false, + "error-codes" => ["timeout-or-duplicate"], + }) + + response = Typhoeus.post("https://127.0.0.1:9081/api-umbrella/v1/users.json", http_options.deep_merge(admin_token).deep_merge({ + :headers => { "Content-Type" => "application/json" }, + :body => MultiJson.dump({ + :user => FactoryBot.attributes_for(:api_user), + "g-recaptcha-response-v2" => "foobar", + }), + })) + assert_response_code(201, response) + data = MultiJson.load(response.body) + user = ApiUser.find(data["user"]["id"]) + assert_equal(false, user.registration_recaptcha_v2_success) + assert_equal(["timeout-or-duplicate"], user.registration_recaptcha_v2_error_codes) + assert_nil(user.registration_recaptcha_v2_hostname) + end + + def test_rejects_missing_recaptcha_input + set_recaptcha_mock({ + "success" => true, + }) + + response = Typhoeus.post("https://127.0.0.1:9081/api-umbrella/v1/users.json", http_options.deep_merge(non_admin_key_creator_api_key).deep_merge({ + :headers => { "Content-Type" => "application/json" }, + :body => MultiJson.dump({ + :user => FactoryBot.attributes_for(:api_user), + }), + })) + assert_recaptcha_rejected(response) + end + + def test_allows_missing_recaptcha_input_for_admins + set_recaptcha_mock({ + "success" => false, + "error-codes" => ["timeout-or-duplicate"], + }) + + response = Typhoeus.post("https://127.0.0.1:9081/api-umbrella/v1/users.json", http_options.deep_merge(admin_token).deep_merge({ + :headers => { "Content-Type" => "application/json" }, + :body => MultiJson.dump({ + :user => FactoryBot.attributes_for(:api_user), + }), + })) + assert_response_code(201, response) + data = MultiJson.load(response.body) + user = ApiUser.find(data["user"]["id"]) + assert_nil(user.registration_recaptcha_v2_success) + assert_nil(user.registration_recaptcha_v2_error_codes) + assert_nil(user.registration_recaptcha_v2_hostname) + end + + def test_rejects_failed_recaptcha_response + set_recaptcha_mock("", status: 500) + + response = Typhoeus.post("https://127.0.0.1:9081/api-umbrella/v1/users.json", http_options.deep_merge(non_admin_key_creator_api_key).deep_merge({ + :headers => { "Content-Type" => "application/json" }, + :body => MultiJson.dump({ + :user => FactoryBot.attributes_for(:api_user), + "g-recaptcha-response-v2" => "foobar", + }), + })) + assert_recaptcha_rejected(response) + end + + def test_rejects_invalid_recaptcha_response + set_recaptcha_mock("abc,123") + + response = Typhoeus.post("https://127.0.0.1:9081/api-umbrella/v1/users.json", http_options.deep_merge(non_admin_key_creator_api_key).deep_merge({ + :headers => { "Content-Type" => "application/json" }, + :body => MultiJson.dump({ + :user => FactoryBot.attributes_for(:api_user), + "g-recaptcha-response-v2" => "foobar", + }), + })) + assert_recaptcha_rejected(response) + end + + def test_performs_domain_validation_based_on_allowed_domain_logic + set_recaptcha_mock({ + "success" => true, + "challenge_ts" => "2023-12-01T20:15:43Z", + "hostname" => unique_test_hostname, + }) + + response = Typhoeus.post("https://127.0.0.1:9081/api-umbrella/v1/users.json", http_options.deep_merge(non_admin_key_creator_api_key).deep_merge({ + :headers => { "Content-Type" => "application/json" }, + :body => MultiJson.dump({ + :user => FactoryBot.attributes_for(:api_user), + "g-recaptcha-response-v2" => "foobar", + }), + })) + assert_recaptcha_rejected(response) + + prepend_api_backends([ + { + :frontend_host => unique_test_hostname, + :backend_host => "127.0.0.1", + :servers => [{ :host => "127.0.0.1", :port => 9444 }], + :url_matches => [{ :frontend_prefix => "/#{unique_test_id}/", :backend_prefix => "/" }], + }, + ]) do + response = Typhoeus.post("https://127.0.0.1:9081/api-umbrella/v1/users.json", http_options.deep_merge(non_admin_key_creator_api_key).deep_merge({ + :headers => { "Content-Type" => "application/json" }, + :body => MultiJson.dump({ + :user => FactoryBot.attributes_for(:api_user), + "g-recaptcha-response-v2" => "foobar", + }), + })) + assert_response_code(201, response) + data = MultiJson.load(response.body) + user = ApiUser.find(data["user"]["id"]) + assert_equal(true, user.registration_recaptcha_v2_success) + assert_nil(user.registration_recaptcha_v2_error_codes) + assert_equal(unique_test_hostname, user.registration_recaptcha_v2_hostname) + end + end + + def test_optional_when_only_required_for_certain_origins + set_recaptcha_mock({ + "success" => false, + "error-codes" => ["timeout-or-duplicate"], + }) + + override_config_merge({ + "web" => { + "recaptcha_v2_required_origin_regex" => "^(?!https://example.com|https://bar.example.com)", + }, + }) do + # Origin *not* requiring recaptcha. + response = Typhoeus.post("https://127.0.0.1:9081/api-umbrella/v1/users.json", http_options.deep_merge(non_admin_key_creator_api_key).deep_merge({ + :headers => { + "Content-Type" => "application/json", + "Origin" => "https://example.com", + }, + :body => MultiJson.dump({ + :user => FactoryBot.attributes_for(:api_user), + "g-recaptcha-response-v2" => "foobar", + }), + })) + assert_response_code(201, response) + data = MultiJson.load(response.body) + user = ApiUser.find(data["user"]["id"]) + assert_equal(false, user.registration_recaptcha_v2_success) + assert_equal(["timeout-or-duplicate"], user.registration_recaptcha_v2_error_codes) + assert_nil(user.registration_recaptcha_v2_hostname) + + # Origin *not* requiring recaptcha. + response = Typhoeus.post("https://127.0.0.1:9081/api-umbrella/v1/users.json", http_options.deep_merge(non_admin_key_creator_api_key).deep_merge({ + :headers => { + "Content-Type" => "application/json", + "Origin" => "https://bar.example.com", + }, + :body => MultiJson.dump({ + :user => FactoryBot.attributes_for(:api_user), + "g-recaptcha-response-v2" => "foobar", + }), + })) + assert_response_code(201, response) + data = MultiJson.load(response.body) + user = ApiUser.find(data["user"]["id"]) + assert_equal(false, user.registration_recaptcha_v2_success) + assert_equal(["timeout-or-duplicate"], user.registration_recaptcha_v2_error_codes) + assert_nil(user.registration_recaptcha_v2_hostname) + + # Origin requiring recaptcha. + response = Typhoeus.post("https://127.0.0.1:9081/api-umbrella/v1/users.json", http_options.deep_merge(non_admin_key_creator_api_key).deep_merge({ + :headers => { + "Content-Type" => "application/json", + "Origin" => "https://foo.example.com", + }, + :body => MultiJson.dump({ + :user => FactoryBot.attributes_for(:api_user), + "g-recaptcha-response-v2" => "foobar", + }), + })) + assert_recaptcha_rejected(response) + + # Origin requiring recaptcha as admin + response = Typhoeus.post("https://127.0.0.1:9081/api-umbrella/v1/users.json", http_options.deep_merge(admin_token).deep_merge({ + :headers => { + "Content-Type" => "application/json", + "Origin" => "https://foo.example.com", + }, + :body => MultiJson.dump({ + :user => FactoryBot.attributes_for(:api_user), + "g-recaptcha-response-v2" => "foobar", + }), + })) + assert_response_code(201, response) + data = MultiJson.load(response.body) + user = ApiUser.find(data["user"]["id"]) + assert_equal(false, user.registration_recaptcha_v2_success) + assert_equal(["timeout-or-duplicate"], user.registration_recaptcha_v2_error_codes) + assert_nil(user.registration_recaptcha_v2_hostname) + + # Missing origin, requiring recaptcha. + response = Typhoeus.post("https://127.0.0.1:9081/api-umbrella/v1/users.json", http_options.deep_merge(non_admin_key_creator_api_key).deep_merge({ + :headers => { + "Content-Type" => "application/json", + }, + :body => MultiJson.dump({ + :user => FactoryBot.attributes_for(:api_user), + "g-recaptcha-response-v2" => "foobar", + }), + })) + assert_recaptcha_rejected(response) + end + end + + private + + def non_admin_key_creator_api_key + @non_admin_key_creator_api_user = FactoryBot.create(:api_user, { + :roles => ["api-umbrella-key-creator"], + }) + + { :headers => { "X-Api-Key" => @non_admin_key_creator_api_user.api_key } } + end + + def set_recaptcha_mock(body, status: 200) + response = Typhoeus.get("http://127.0.0.1:9444/recaptcha/api/siteverify/set-mock", http_options.deep_merge({ + :headers => { "Content-Type" => "application/json" }, + :body => MultiJson.dump({ + "status" => status, + "body" => body.kind_of?(String) ? body : MultiJson.dump(body), + }), + })) + assert_response_code(200, response) + end + + def assert_recaptcha_rejected(response) + assert_response_code(422, response) + data = MultiJson.load(response.body) + assert_equal({ + "errors" => [{ + "code" => "UNEXPECTED_ERROR", + "message" => "CAPTCHA verification failed. Please try again or contact us for assistance.", + }], + }, data) + end +end diff --git a/test/apis/v1/users/test_create_recaptcha_v3.rb b/test/apis/v1/users/test_create_recaptcha_v3.rb new file mode 100644 index 000000000..a9d559a2e --- /dev/null +++ b/test/apis/v1/users/test_create_recaptcha_v3.rb @@ -0,0 +1,374 @@ +require_relative "../../../test_helper" + +class Test::Apis::V1::Users::TestCreateRecaptchaV3 < Minitest::Test + include ApiUmbrellaTestHelpers::AdminAuth + include ApiUmbrellaTestHelpers::Setup + include Minitest::Hooks + + def setup + super + setup_server + once_per_class_setup do + override_config_set({ + "web" => { + "recaptcha_scheme" => "http", + "recaptcha_host" => "127.0.0.1", + "recaptcha_port" => 9444, + "recaptcha_v3_secret_key" => "foobar", + "recaptcha_v3_required" => true, + }, + }) + end + end + + def after_all + super + override_config_reset + end + + def test_successful_recaptcha_response + set_recaptcha_mock({ + "success" => true, + "challenge_ts" => "2023-12-01T20:15:43Z", + "hostname" => "127.0.0.1", + "score" => 0.9, + "action" => "signup", + }) + + response = Typhoeus.post("https://127.0.0.1:9081/api-umbrella/v1/users.json", http_options.deep_merge(non_admin_key_creator_api_key).deep_merge({ + :headers => { "Content-Type" => "application/json" }, + :body => MultiJson.dump({ + :user => FactoryBot.attributes_for(:api_user), + "g-recaptcha-response-v3" => "foobar", + }), + })) + assert_response_code(201, response) + data = MultiJson.load(response.body) + user = ApiUser.find(data["user"]["id"]) + assert_equal(true, user.registration_recaptcha_v3_success) + assert_nil(user.registration_recaptcha_v3_error_codes) + assert_equal("127.0.0.1", user.registration_recaptcha_v3_hostname) + assert_in_delta(0.9, user.registration_recaptcha_v3_score) + assert_equal("signup", user.registration_recaptcha_v3_action) + end + + def test_rejects_unsuccessful_recaptcha_response + set_recaptcha_mock({ + "success" => false, + "error-codes" => ["timeout-or-duplicate"], + }) + + response = Typhoeus.post("https://127.0.0.1:9081/api-umbrella/v1/users.json", http_options.deep_merge(non_admin_key_creator_api_key).deep_merge({ + :headers => { "Content-Type" => "application/json" }, + :body => MultiJson.dump({ + :user => FactoryBot.attributes_for(:api_user), + "g-recaptcha-response-v3" => "foobar", + }), + })) + assert_recaptcha_rejected(response) + end + + def test_allows_unsuccessful_recaptcha_response_for_admins + set_recaptcha_mock({ + "success" => false, + "error-codes" => ["timeout-or-duplicate"], + }) + + response = Typhoeus.post("https://127.0.0.1:9081/api-umbrella/v1/users.json", http_options.deep_merge(admin_token).deep_merge({ + :headers => { "Content-Type" => "application/json" }, + :body => MultiJson.dump({ + :user => FactoryBot.attributes_for(:api_user), + "g-recaptcha-response-v3" => "foobar", + }), + })) + assert_response_code(201, response) + data = MultiJson.load(response.body) + user = ApiUser.find(data["user"]["id"]) + assert_equal(false, user.registration_recaptcha_v3_success) + assert_equal(["timeout-or-duplicate"], user.registration_recaptcha_v3_error_codes) + assert_nil(user.registration_recaptcha_v3_hostname) + assert_nil(user.registration_recaptcha_v3_score) + assert_nil(user.registration_recaptcha_v3_action) + end + + def test_rejects_missing_recaptcha_input + set_recaptcha_mock({ + "success" => true, + }) + + response = Typhoeus.post("https://127.0.0.1:9081/api-umbrella/v1/users.json", http_options.deep_merge(non_admin_key_creator_api_key).deep_merge({ + :headers => { "Content-Type" => "application/json" }, + :body => MultiJson.dump({ + :user => FactoryBot.attributes_for(:api_user), + }), + })) + assert_recaptcha_rejected(response) + end + + def test_allows_missing_recaptcha_input_for_admins + set_recaptcha_mock({ + "success" => false, + "error-codes" => ["timeout-or-duplicate"], + }) + + response = Typhoeus.post("https://127.0.0.1:9081/api-umbrella/v1/users.json", http_options.deep_merge(admin_token).deep_merge({ + :headers => { "Content-Type" => "application/json" }, + :body => MultiJson.dump({ + :user => FactoryBot.attributes_for(:api_user), + }), + })) + assert_response_code(201, response) + data = MultiJson.load(response.body) + user = ApiUser.find(data["user"]["id"]) + assert_nil(user.registration_recaptcha_v3_success) + assert_nil(user.registration_recaptcha_v3_error_codes) + assert_nil(user.registration_recaptcha_v3_hostname) + assert_nil(user.registration_recaptcha_v3_score) + assert_nil(user.registration_recaptcha_v3_action) + end + + def test_rejects_below_score_recaptcha_response + set_recaptcha_mock({ + "success" => true, + "challenge_ts" => "2023-12-01T20:15:43Z", + "hostname" => "127.0.0.1", + "score" => 0.8, + "action" => "signup", + }) + + response = Typhoeus.post("https://127.0.0.1:9081/api-umbrella/v1/users.json", http_options.deep_merge(non_admin_key_creator_api_key).deep_merge({ + :headers => { "Content-Type" => "application/json" }, + :body => MultiJson.dump({ + :user => FactoryBot.attributes_for(:api_user), + "g-recaptcha-response-v3" => "foobar", + }), + })) + assert_recaptcha_rejected(response) + end + + def test_allows_below_score_recaptcha_response_for_admins + set_recaptcha_mock({ + "success" => true, + "challenge_ts" => "2023-12-01T20:15:43Z", + "hostname" => "127.0.0.1", + "score" => 0.8, + "action" => "signup", + }) + + response = Typhoeus.post("https://127.0.0.1:9081/api-umbrella/v1/users.json", http_options.deep_merge(admin_token).deep_merge({ + :headers => { "Content-Type" => "application/json" }, + :body => MultiJson.dump({ + :user => FactoryBot.attributes_for(:api_user), + "g-recaptcha-response-v3" => "foobar", + }), + })) + assert_response_code(201, response) + data = MultiJson.load(response.body) + user = ApiUser.find(data["user"]["id"]) + assert_equal(true, user.registration_recaptcha_v3_success) + assert_nil(user.registration_recaptcha_v3_error_codes) + assert_equal("127.0.0.1", user.registration_recaptcha_v3_hostname) + assert_in_delta(0.8, user.registration_recaptcha_v3_score) + assert_equal("signup", user.registration_recaptcha_v3_action) + end + + def test_rejects_failed_recaptcha_response + set_recaptcha_mock("", status: 500) + + response = Typhoeus.post("https://127.0.0.1:9081/api-umbrella/v1/users.json", http_options.deep_merge(non_admin_key_creator_api_key).deep_merge({ + :headers => { "Content-Type" => "application/json" }, + :body => MultiJson.dump({ + :user => FactoryBot.attributes_for(:api_user), + "g-recaptcha-response-v3" => "foobar", + }), + })) + assert_recaptcha_rejected(response) + end + + def test_rejects_invalid_recaptcha_response + set_recaptcha_mock("abc,123") + + response = Typhoeus.post("https://127.0.0.1:9081/api-umbrella/v1/users.json", http_options.deep_merge(non_admin_key_creator_api_key).deep_merge({ + :headers => { "Content-Type" => "application/json" }, + :body => MultiJson.dump({ + :user => FactoryBot.attributes_for(:api_user), + "g-recaptcha-response-v3" => "foobar", + }), + })) + assert_recaptcha_rejected(response) + end + + def test_performs_domain_validation_based_on_allowed_domain_logic + set_recaptcha_mock({ + "success" => true, + "challenge_ts" => "2023-12-01T20:15:43Z", + "hostname" => unique_test_hostname, + "score" => 0.9, + "action" => "signup", + }) + + response = Typhoeus.post("https://127.0.0.1:9081/api-umbrella/v1/users.json", http_options.deep_merge(non_admin_key_creator_api_key).deep_merge({ + :headers => { "Content-Type" => "application/json" }, + :body => MultiJson.dump({ + :user => FactoryBot.attributes_for(:api_user), + "g-recaptcha-response-v3" => "foobar", + }), + })) + assert_recaptcha_rejected(response) + + prepend_api_backends([ + { + :frontend_host => unique_test_hostname, + :backend_host => "127.0.0.1", + :servers => [{ :host => "127.0.0.1", :port => 9444 }], + :url_matches => [{ :frontend_prefix => "/#{unique_test_id}/", :backend_prefix => "/" }], + }, + ]) do + response = Typhoeus.post("https://127.0.0.1:9081/api-umbrella/v1/users.json", http_options.deep_merge(non_admin_key_creator_api_key).deep_merge({ + :headers => { "Content-Type" => "application/json" }, + :body => MultiJson.dump({ + :user => FactoryBot.attributes_for(:api_user), + "g-recaptcha-response-v3" => "foobar", + }), + })) + assert_response_code(201, response) + data = MultiJson.load(response.body) + user = ApiUser.find(data["user"]["id"]) + assert_equal(true, user.registration_recaptcha_v3_success) + assert_nil(user.registration_recaptcha_v3_error_codes) + assert_equal(unique_test_hostname, user.registration_recaptcha_v3_hostname) + assert_in_delta(0.9, user.registration_recaptcha_v3_score) + assert_equal("signup", user.registration_recaptcha_v3_action) + end + end + + def test_optional_when_only_required_for_certain_origins + set_recaptcha_mock({ + "success" => false, + "error-codes" => ["timeout-or-duplicate"], + }) + + override_config_merge({ + "web" => { + "recaptcha_v3_required_origin_regex" => "^(?!https://example.com|https://bar.example.com)", + }, + }) do + # Origin *not* requiring recaptcha. + response = Typhoeus.post("https://127.0.0.1:9081/api-umbrella/v1/users.json", http_options.deep_merge(non_admin_key_creator_api_key).deep_merge({ + :headers => { + "Content-Type" => "application/json", + "Origin" => "https://example.com", + }, + :body => MultiJson.dump({ + :user => FactoryBot.attributes_for(:api_user), + "g-recaptcha-response-v3" => "foobar", + }), + })) + assert_response_code(201, response) + data = MultiJson.load(response.body) + user = ApiUser.find(data["user"]["id"]) + assert_equal(false, user.registration_recaptcha_v3_success) + assert_equal(["timeout-or-duplicate"], user.registration_recaptcha_v3_error_codes) + assert_nil(user.registration_recaptcha_v3_hostname) + assert_nil(user.registration_recaptcha_v3_score) + assert_nil(user.registration_recaptcha_v3_action) + + # Origin *not* requiring recaptcha. + response = Typhoeus.post("https://127.0.0.1:9081/api-umbrella/v1/users.json", http_options.deep_merge(non_admin_key_creator_api_key).deep_merge({ + :headers => { + "Content-Type" => "application/json", + "Origin" => "https://bar.example.com", + }, + :body => MultiJson.dump({ + :user => FactoryBot.attributes_for(:api_user), + "g-recaptcha-response-v3" => "foobar", + }), + })) + assert_response_code(201, response) + data = MultiJson.load(response.body) + user = ApiUser.find(data["user"]["id"]) + assert_equal(false, user.registration_recaptcha_v3_success) + assert_equal(["timeout-or-duplicate"], user.registration_recaptcha_v3_error_codes) + assert_nil(user.registration_recaptcha_v3_hostname) + assert_nil(user.registration_recaptcha_v3_score) + assert_nil(user.registration_recaptcha_v3_action) + + # Origin requiring recaptcha. + response = Typhoeus.post("https://127.0.0.1:9081/api-umbrella/v1/users.json", http_options.deep_merge(non_admin_key_creator_api_key).deep_merge({ + :headers => { + "Content-Type" => "application/json", + "Origin" => "https://foo.example.com", + }, + :body => MultiJson.dump({ + :user => FactoryBot.attributes_for(:api_user), + "g-recaptcha-response-v3" => "foobar", + }), + })) + assert_recaptcha_rejected(response) + + # Origin requiring recaptcha as admin + response = Typhoeus.post("https://127.0.0.1:9081/api-umbrella/v1/users.json", http_options.deep_merge(admin_token).deep_merge({ + :headers => { + "Content-Type" => "application/json", + "Origin" => "https://foo.example.com", + }, + :body => MultiJson.dump({ + :user => FactoryBot.attributes_for(:api_user), + "g-recaptcha-response-v3" => "foobar", + }), + })) + assert_response_code(201, response) + data = MultiJson.load(response.body) + user = ApiUser.find(data["user"]["id"]) + assert_equal(false, user.registration_recaptcha_v3_success) + assert_equal(["timeout-or-duplicate"], user.registration_recaptcha_v3_error_codes) + assert_nil(user.registration_recaptcha_v3_hostname) + assert_nil(user.registration_recaptcha_v3_score) + assert_nil(user.registration_recaptcha_v3_action) + + # Missing origin, requiring recaptcha. + response = Typhoeus.post("https://127.0.0.1:9081/api-umbrella/v1/users.json", http_options.deep_merge(non_admin_key_creator_api_key).deep_merge({ + :headers => { + "Content-Type" => "application/json", + }, + :body => MultiJson.dump({ + :user => FactoryBot.attributes_for(:api_user), + "g-recaptcha-response-v3" => "foobar", + }), + })) + assert_recaptcha_rejected(response) + end + end + + private + + def non_admin_key_creator_api_key + @non_admin_key_creator_api_user = FactoryBot.create(:api_user, { + :roles => ["api-umbrella-key-creator"], + }) + + { :headers => { "X-Api-Key" => @non_admin_key_creator_api_user.api_key } } + end + + def set_recaptcha_mock(body, status: 200) + response = Typhoeus.get("http://127.0.0.1:9444/recaptcha/api/siteverify/set-mock", http_options.deep_merge({ + :headers => { "Content-Type" => "application/json" }, + :body => MultiJson.dump({ + "status" => status, + "body" => body.kind_of?(String) ? body : MultiJson.dump(body), + }), + })) + assert_response_code(200, response) + end + + def assert_recaptcha_rejected(response) + assert_response_code(422, response) + data = MultiJson.load(response.body) + assert_equal({ + "errors" => [{ + "code" => "UNEXPECTED_ERROR", + "message" => "CAPTCHA verification failed. Please try again or contact us for assistance.", + }], + }, data) + end +end diff --git a/test/support/api_umbrella_test_helpers/setup.rb b/test/support/api_umbrella_test_helpers/setup.rb index 110dda1ed..7eb5f75b2 100644 --- a/test/support/api_umbrella_test_helpers/setup.rb +++ b/test/support/api_umbrella_test_helpers/setup.rb @@ -297,6 +297,19 @@ def override_config(config, options = {}) end end + def override_config_merge(config, options = {}) + self.config_lock.synchronize do + original_config = @@current_override_config.deep_dup + + begin + override_config_set(original_config.deep_stringify_keys.deep_merge(config.deep_stringify_keys), options) + yield + ensure + override_config_set(original_config, options) + end + end + end + def override_config_set(config, options = {}) self.config_set_lock.synchronize do if(self.class.test_order == :parallel)