From 47021aa60574a8ade0ee184c751c75af9ee8db53 Mon Sep 17 00:00:00 2001 From: Markus Kahl Date: Mon, 9 Mar 2026 15:54:16 +0000 Subject: [PATCH] fix webhook specs --- lib/open_project/ssrf_protection.rb | 2 +- .../outgoing/request_webhook_service.rb | 2 +- .../workers/attachment_webhook_job_spec.rb | 6 +- .../spec/workers/project_webhook_job_spec.rb | 6 +- .../workers/time_entry_webhook_job_spec.rb | 6 +- .../work_package_comment_webhook_job_spec.rb | 6 +- .../workers/work_package_webhook_job_spec.rb | 6 +- .../support/shared/with_ssrf_webhook_stubs.rb | 59 +++++++++++++++++++ 8 files changed, 81 insertions(+), 12 deletions(-) create mode 100644 spec/support/shared/with_ssrf_webhook_stubs.rb diff --git a/lib/open_project/ssrf_protection.rb b/lib/open_project/ssrf_protection.rb index 65d60bc2ee4..c9e786c2129 100644 --- a/lib/open_project/ssrf_protection.rb +++ b/lib/open_project/ssrf_protection.rb @@ -75,7 +75,7 @@ module OpenProject # http_options: { open_timeout: 5, read_timeout: 10 } # ) def post(url, options = {}, &) - super(url, { max_redirects: 0 }.merge(options), &) + super(url, { max_redirects: 0, resolver: resolver }.merge(options), &) end ## diff --git a/modules/webhooks/app/services/webhooks/outgoing/request_webhook_service.rb b/modules/webhooks/app/services/webhooks/outgoing/request_webhook_service.rb index c8085738a17..bf4f58e4b41 100644 --- a/modules/webhooks/app/services/webhooks/outgoing/request_webhook_service.rb +++ b/modules/webhooks/app/services/webhooks/outgoing/request_webhook_service.rb @@ -53,7 +53,7 @@ module Webhooks def response_attributes(response:, exception:) { response_code: response&.code&.to_i || -1, - response_headers: response&.to_hash&.transform_keys { |k| k.underscore.to_sym }, + response_headers: response&.to_hash&.transform_keys { |k| k.underscore.to_sym }.transform_values(&:first), response_body: response&.body || exception&.message } end diff --git a/modules/webhooks/spec/workers/attachment_webhook_job_spec.rb b/modules/webhooks/spec/workers/attachment_webhook_job_spec.rb index 6e3cbed6754..63f9885e99f 100644 --- a/modules/webhooks/spec/workers/attachment_webhook_job_spec.rb +++ b/modules/webhooks/spec/workers/attachment_webhook_job_spec.rb @@ -29,6 +29,8 @@ require "spec_helper" RSpec.describe AttachmentWebhookJob, :webmock, type: :job do + include_context "with ssrf webhook stubs" + shared_let(:user) { create(:admin) } shared_let(:request_url) { "http://example.net/test/42" } shared_let(:project) { create(:project, name: "Foo Bar") } @@ -50,7 +52,7 @@ RSpec.describe AttachmentWebhookJob, :webmock, type: :job do end let(:stub) do - stub_request(:post, stubbed_url.sub("http://", "")) + stub_request(:post, ssrf_resolved_url(stubbed_url)) .with( body: hash_including( "action" => event, @@ -59,7 +61,7 @@ RSpec.describe AttachmentWebhookJob, :webmock, type: :job do "id" => attachment.id ) ), - headers: request_headers + headers: request_headers.merge(host: "example.net") ) .to_return( status: response_code, diff --git a/modules/webhooks/spec/workers/project_webhook_job_spec.rb b/modules/webhooks/spec/workers/project_webhook_job_spec.rb index 3eac1560955..63813ee0136 100644 --- a/modules/webhooks/spec/workers/project_webhook_job_spec.rb +++ b/modules/webhooks/spec/workers/project_webhook_job_spec.rb @@ -29,6 +29,8 @@ require "spec_helper" RSpec.describe ProjectWebhookJob, :webmock, type: :job do + include_context "with ssrf webhook stubs" + shared_let(:request_url) { "http://example.net/test/42" } shared_let(:project) { create(:project, name: "Foo Bar") } shared_let(:webhook) { create(:webhook, all_projects: true, url: request_url, secret: nil) } @@ -54,7 +56,7 @@ RSpec.describe ProjectWebhookJob, :webmock, type: :job do end let(:stub) do - stub_request(:post, stubbed_url.sub("http://", "")) + stub_request(:post, ssrf_resolved_url(stubbed_url)) .with( body: hash_including( "action" => event, @@ -64,7 +66,7 @@ RSpec.describe ProjectWebhookJob, :webmock, type: :job do **expected_payload ) ), - headers: request_headers + headers: request_headers.merge(host: "example.net") ) .to_return( status: response_code, diff --git a/modules/webhooks/spec/workers/time_entry_webhook_job_spec.rb b/modules/webhooks/spec/workers/time_entry_webhook_job_spec.rb index bde6e4bcab4..a99a56b546e 100644 --- a/modules/webhooks/spec/workers/time_entry_webhook_job_spec.rb +++ b/modules/webhooks/spec/workers/time_entry_webhook_job_spec.rb @@ -29,6 +29,8 @@ require "spec_helper" RSpec.describe TimeEntryWebhookJob, :webmock, type: :job do + include_context "with ssrf webhook stubs" + shared_let(:user) { create(:admin) } shared_let(:request_url) { "http://example.net/test/42" } shared_let(:time_entry) { create(:time_entry, hours: 10) } @@ -51,7 +53,7 @@ RSpec.describe TimeEntryWebhookJob, :webmock, type: :job do end let(:stub) do - stub_request(:post, stubbed_url.sub("http://", "")) + stub_request(:post, ssrf_resolved_url(stubbed_url)) .with( body: hash_including( "action" => event, @@ -60,7 +62,7 @@ RSpec.describe TimeEntryWebhookJob, :webmock, type: :job do "hours" => "PT10H" ) ), - headers: request_headers + headers: request_headers.merge(host: "example.net") ) .to_return( status: response_code, diff --git a/modules/webhooks/spec/workers/work_package_comment_webhook_job_spec.rb b/modules/webhooks/spec/workers/work_package_comment_webhook_job_spec.rb index b25b68de274..43908098b1b 100644 --- a/modules/webhooks/spec/workers/work_package_comment_webhook_job_spec.rb +++ b/modules/webhooks/spec/workers/work_package_comment_webhook_job_spec.rb @@ -31,6 +31,8 @@ require "spec_helper" RSpec.describe WorkPackageCommentWebhookJob, :webmock, type: :model do + include_context "with ssrf webhook stubs" + let(:user) { create(:admin) } let(:request_url) { "http://example.net/test/42" } let(:journal) { work_package.journals.last } @@ -51,7 +53,7 @@ RSpec.describe WorkPackageCommentWebhookJob, :webmock, type: :model do end let(:stub) do - stub_request(:post, stubbed_url.sub("http://", "")).with( + stub_request(:post, ssrf_resolved_url(stubbed_url)).with( body: hash_including( "action" => event_name, "activity" => hash_including( @@ -59,7 +61,7 @@ RSpec.describe WorkPackageCommentWebhookJob, :webmock, type: :model do "comment" => hash_including("raw" => notes) ) ), - headers: request_headers + headers: request_headers.merge(host: "example.net") ).to_return( status: response_code, body: response_body, diff --git a/modules/webhooks/spec/workers/work_package_webhook_job_spec.rb b/modules/webhooks/spec/workers/work_package_webhook_job_spec.rb index 22be3a1e0e6..d38f97b707b 100644 --- a/modules/webhooks/spec/workers/work_package_webhook_job_spec.rb +++ b/modules/webhooks/spec/workers/work_package_webhook_job_spec.rb @@ -31,6 +31,8 @@ require "spec_helper" RSpec.describe WorkPackageWebhookJob, :webmock, type: :model do + include_context "with ssrf webhook stubs" + shared_let(:user) { create(:admin) } shared_let(:title) { "Some workpackage subject" } shared_let(:request_url) { "http://example.net/test/42" } @@ -54,7 +56,7 @@ RSpec.describe WorkPackageWebhookJob, :webmock, type: :model do end let(:stub) do - stub_request(:post, stubbed_url.sub("http://", "")) + stub_request(:post, ssrf_resolved_url(stubbed_url)) .with( body: hash_including( "action" => event, @@ -63,7 +65,7 @@ RSpec.describe WorkPackageWebhookJob, :webmock, type: :model do "subject" => title ) ), - headers: request_headers + headers: request_headers.merge(host: "example.net") ) .to_return( status: response_code, diff --git a/spec/support/shared/with_ssrf_webhook_stubs.rb b/spec/support/shared/with_ssrf_webhook_stubs.rb new file mode 100644 index 00000000000..c1453750226 --- /dev/null +++ b/spec/support/shared/with_ssrf_webhook_stubs.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. + +module WithSsrfWebhookStubsMixin + ## + # A safe public IP returned by the stubbed resolver for any hostname. + # It is not in SsrfFilter's private-address blocklist, so SSRF validation passes, + # and WebMock stubs using this IP will match the actual Net::HTTP request. + SSRF_TEST_IP = "93.184.216.34" + + ## + # Translates a webhook URL containing a hostname to the IP-based URL that + # SsrfFilter will use when making the actual HTTP request. Use this when + # setting up WebMock stubs so that they match the resolved request. + # + # URLs that already contain an IP address are returned unchanged. + def ssrf_resolved_url(url) + uri = URI.parse(url) + return url if [Resolv::IPv4::Regex, Resolv::IPv6::Regex].any? { uri.host.match?(_1) } + + url.sub(uri.host, SSRF_TEST_IP) + end +end + +RSpec.shared_context "with ssrf webhook stubs" do + include WithSsrfWebhookStubsMixin + + before do + safe_ip = IPAddr.new(WithSsrfWebhookStubsMixin::SSRF_TEST_IP) + allow(OpenProject::SsrfProtection).to receive(:resolver).and_return( + ->(_hostname) { [safe_ip] } + ) + end +end