Use ssrf filtering in Jira Import.

This commit is contained in:
Pavel Balashou
2026-04-21 09:06:02 +02:00
parent 28e7546420
commit 53e33770c2
12 changed files with 200 additions and 68 deletions
+50 -44
View File
@@ -46,23 +46,19 @@ module Import
end
end
HTTP_OPTIONS = {
open_timeout: 30,
read_timeout: 30
}.freeze
def initialize(url:, personal_access_token:)
raise ApiError.new(I18n.t(:"admin.jira.test.token_error")) if personal_access_token.nil?
@httpx = OpenProject
.httpx
.plugin(:auth)
.bearer_auth(personal_access_token)
.with(
headers: { "accept" => "application/json" },
timeout: {
connect_timeout: 30,
operation_timeout: 30,
request_timeout: 30,
read_timeout: 30
}
)
@url = url.chomp("/")
@headers = {
"Accept" => "application/json",
"Authorization" => "Bearer #{personal_access_token}"
}
end
def mypermissions
@@ -124,7 +120,7 @@ module Import
def issue_types_count
response = get_response("/rest/api/2/issuetype/page", params: { maxResults: 0 })
if response.status == 200
if response.is_a?(Net::HTTPSuccess)
parse_json(response)["total"]
else
issue_types.count
@@ -149,7 +145,7 @@ module Import
def statuses_count
response = get_response("/rest/api/2/status/search", params: { maxResults: 0 })
if response.status == 200
if response.is_a?(Net::HTTPSuccess)
parse_json(response)["total"]
else
statuses.count
@@ -223,24 +219,34 @@ module Import
})
end
def download_attachment(content_url)
case (response = @httpx.get(content_url))
in { status: 200..299 }
response.body
in { status: 300..399 }
case (redirect_response = @httpx.get(response.headers["location"]))
in { status: 200..299 }
redirect_response.body
in { status: 300.. }
raise "BAD RESPONSE: #{redirect_response.status}, #{redirect_response.body}"
in { error: error }
raise error
##
# Downloads a file from the given URL and saves it to a temporary file.
#
# @param content_url [String] The URL to download the attachment from
# @param filename [String] The name to use for the temporary file
# @return [Tempfile] The temporary file containing the downloaded content
# @raise [ConnectionError] If SSRF protection blocks the request or connection fails
# @raise [ApiError] If the server returns a non-success response
# @note The caller is responsible for removing the temporary file after use,
# for example with +File.unlink+
def download_attachment(content_url, filename) # rubocop:disable Metrics/AbcSize
tempfile = nil
OpenProject::SsrfProtection.get(content_url, headers: @headers, http_options: HTTP_OPTIONS, max_redirects: 1) do |response|
case response
when Net::HTTPSuccess
tempfile = Tempfile.create(filename, binmode: true)
response.read_body do |chunk|
tempfile.write chunk
end
else
raise ApiError.new(I18n.t("admin.jira.client.api_error"), status: response.code.to_i, response_body: response.body)
end
in { status: 400.. }
raise "BAD RESPONSE: #{response}"
in { error: error }
raise error
end
tempfile
rescue SsrfFilter::Error => e
raise ConnectionError, I18n.t("admin.jira.client.connection_error", message: e.message)
rescue Timeout::Error => e
raise ConnectionError, I18n.t("admin.jira.client.connection_timeout", message: e.message)
end
private
@@ -251,34 +257,34 @@ module Import
end
def get_response(path, params: {})
response = @httpx.get("#{@url}#{path}", params:)
if response.is_a?(HTTPX::ErrorResponse)
raise ConnectionError, I18n.t("admin.jira.client.connection_error", message: response.error.message)
end
response
rescue HTTPX::ConnectionError, SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH => e
OpenProject::SsrfProtection.get(
"#{@url}#{path}",
headers: @headers,
params:,
http_options: HTTP_OPTIONS
)
rescue SsrfFilter::Error, SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH => e
raise ConnectionError, I18n.t("admin.jira.client.connection_error", message: e.message)
rescue Timeout::Error => e
raise ConnectionError, I18n.t("admin.jira.client.connection_timeout", message: e.message)
end
def handle_response(response)
if response.status >= 200 && response.status < 300
status = response.code.to_i
if response.is_a?(Net::HTTPSuccess)
parse_json(response)
else
raise ApiError.new(
I18n.t("admin.jira.client.#{response.status}_error", status: response.status, default: :"admin.jira.client.api_error"),
status: response.status,
I18n.t("admin.jira.client.#{status}_error", status:, default: :"admin.jira.client.api_error"),
status:,
response_body: response.body.to_s
)
end
end
def parse_json(response)
response.json
rescue JSON::ParserError, HTTPX::Error => e
JSON.parse(response.body)
rescue JSON::ParserError => e
raise ParseError, I18n.t("admin.jira.client.parse_error", message: e.message)
end
end
+11 -13
View File
@@ -286,21 +286,19 @@ module Import
content_url = attachment["content"]
mime_type = attachment["mimeType"]
size = attachment["size"]
response_body = jira_client.download_attachment(content_url)
tempfile = jira_client.download_attachment(content_url, filename)
Tempfile.create(filename, binmode: true) do |tempfile|
response_body.copy_to(tempfile)
tempfile.rewind
tempfile.define_singleton_method(:original_filename) { filename }
tempfile.define_singleton_method(:content_type) { mime_type }
tempfile.define_singleton_method(:size) { size }
call = Attachments::CreateService
.new(user: author, contract_class: EmptyContract)
.call(container: work_package, filename:, file: tempfile)
tempfile.rewind
tempfile.define_singleton_method(:original_filename) { filename }
tempfile.define_singleton_method(:content_type) { mime_type }
tempfile.define_singleton_method(:size) { size }
call = Attachments::CreateService
.new(user: author, contract_class: EmptyContract)
.call(container: work_package, filename:, file: tempfile)
call.on_failure do
raise call.message
end
File.unlink(tempfile)
call.on_failure do
raise call.message
end
end
# rubocop:enable Metrics/AbcSize
+49 -1
View File
@@ -47,7 +47,7 @@ module OpenProject
# @option options [Integer] :max_redirects Maximum number of redirects to follow (default: 10)
# @option options [Hash] :http_options Options passed directly to Net::HTTP.start (e.g. read_timeout:, open_timeout:)
# @option options [Proc] :resolver Custom DNS resolver; receives a hostname and returns an array of IPAddr objects
# @yield [Net::HTTP::Post] Optional block to further configure the request object before it is sent
# @yield [Net::HTTPResponse] Optional block to handle response object
# @return [Net::HTTPResponse] The HTTP response
# @raise [SsrfFilter::InvalidUriScheme] If the URI scheme is not in the whitelist
# @raise [SsrfFilter::UnresolvedHostname] If the hostname cannot be resolved
@@ -78,6 +78,54 @@ module OpenProject
super(url, { max_redirects: 0, resolver: resolver }.merge(options), &)
end
##
# Performs an SSRF-safe HTTP GET request to the given URL.
#
# Resolves the hostname and blocks requests to private/reserved IP ranges
# (loopback, link-local, RFC 1918, etc.) to prevent server-side request forgery.
# Use OPENPROJECT_SSRF_PROTECTION_IP_ALLOWLIST to explicitly permit specific private IPs.
#
# @param url [String, URI] The URL to GET from (must use http or https)
# @param options [Hash] Request options
# @option options [Hash] :headers Additional HTTP headers, e.g. { "Authorization" => "Bearer token" }
# @option options [Hash] :params Query parameters to merge into the URL
# @option options [Array<String>] :scheme_whitelist Allowed URI schemes (default: ["http", "https"])
# @option options [Integer] :max_redirects Maximum number of redirects to follow (default: 10)
# @option options [Hash] :http_options Options passed directly to Net::HTTP.start (e.g. read_timeout:, open_timeout:)
# @option options [Proc] :resolver Custom DNS resolver; receives a hostname and returns an array of IPAddr objects
# @yield [Net::HTTPResponse] Optional block to handle response object
# @return [Net::HTTPResponse] The HTTP response
# @raise [SsrfFilter::InvalidUriScheme] If the URI scheme is not in the whitelist
# @raise [SsrfFilter::UnresolvedHostname] If the hostname cannot be resolved
# @raise [SsrfFilter::PrivateIPAddress] If all resolved IPs are private/blocked
# @raise [SsrfFilter::CRLFInjection] If CRLF characters are detected in headers
# @raise [SsrfFilter::TooManyRedirects] If the redirect limit is exceeded
#
# @example Simple GET with authorization
# response = OpenProject::SsrfProtection.get(
# "https://example.com/api/data",
# headers: { "Authorization" => "Bearer token123" }
# )
#
# @example GET with custom timeout
# response = OpenProject::SsrfProtection.get(
# "https://example.com/api/resource",
# http_options: { open_timeout: 5, read_timeout: 10 }
# )
#
# @example GET with streamed response
# response = OpenProject::SsrfProtection.get(
# "https://example.com/api/resource",
# http_options: { open_timeout: 5, read_timeout: 10 }
# ) do |response|
# response.read_body do |chunk|
# print chunk
# end
# end
def get(url, options = {}, &)
super(url, { resolver: resolver }.merge(options), &)
end
##
# Given a hostname or IP address, returns the first one which is safe to use
# for triggering a user initiated request.
@@ -31,7 +31,7 @@
require "spec_helper"
RSpec.describe Webhooks::Outgoing::RequestWebhookService, :webmock, type: :model do
include_context "with ssrf webhook stubs"
include_context "with ssrf stubs"
let(:user) { build_stubbed(:user) }
let(:instance) { described_class.new(webhook, event_name: :created, current_user: user) }
@@ -29,7 +29,7 @@
require "spec_helper"
RSpec.describe AttachmentWebhookJob, :webmock, type: :job do
include_context "with ssrf webhook stubs"
include_context "with ssrf stubs"
shared_let(:user) { create(:admin) }
shared_let(:request_url) { "http://example.net/test/42" }
@@ -29,7 +29,7 @@
require "spec_helper"
RSpec.describe ProjectWebhookJob, :webmock, type: :job do
include_context "with ssrf webhook stubs"
include_context "with ssrf stubs"
shared_let(:request_url) { "http://example.net/test/42" }
shared_let(:project) { create(:project, name: "Foo Bar") }
@@ -29,7 +29,7 @@
require "spec_helper"
RSpec.describe TimeEntryWebhookJob, :webmock, type: :job do
include_context "with ssrf webhook stubs"
include_context "with ssrf stubs"
shared_let(:user) { create(:admin) }
shared_let(:request_url) { "http://example.net/test/42" }
@@ -31,7 +31,7 @@
require "spec_helper"
RSpec.describe WorkPackageCommentWebhookJob, :webmock, type: :model do
include_context "with ssrf webhook stubs"
include_context "with ssrf stubs"
let(:user) { create(:admin) }
let(:request_url) { "http://example.net/test/42" }
@@ -31,7 +31,7 @@
require "spec_helper"
RSpec.describe WorkPackageWebhookJob, :webmock, type: :model do
include_context "with ssrf webhook stubs"
include_context "with ssrf stubs"
shared_let(:user) { create(:admin) }
shared_let(:title) { "Some workpackage subject" }
+78
View File
@@ -0,0 +1,78 @@
# frozen_string_literal: true
#-- copyright
# 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.
#++
require "spec_helper"
RSpec.describe Import::JiraClient do
subject(:client) { described_class.new(url:, personal_access_token:) }
let(:url) { "https://jira.example.com" }
let(:personal_access_token) { "test-token" }
describe "#initialize" do
context "when personal_access_token is nil" do
let(:personal_access_token) { nil }
it "raises ApiError" do
expect { client }.to raise_error(Import::JiraClient::ApiError)
end
end
end
describe "SSRF protection" do
context "when using a loopback address" do
let(:url) { "http://127.0.0.1" }
it "raises ConnectionError for API requests" do
expect { client.server_info }
.to raise_error(Import::JiraClient::ConnectionError)
end
it "raises ConnectionError for download_attachment" do
expect { client.download_attachment("#{url}/attachment/123", "filename") }
.to raise_error(Import::JiraClient::ConnectionError)
end
end
context "when using a private network address (10.x.x.x)" do
let(:url) { "http://10.0.0.1" }
it "raises ConnectionError for API requests" do
expect { client.projects }
.to raise_error(Import::JiraClient::ConnectionError)
end
it "raises ConnectionError for download_attachment" do
expect { client.download_attachment("#{url}/attachment/123", "filename") }
.to raise_error(Import::JiraClient::ConnectionError)
end
end
end
end
@@ -26,7 +26,7 @@
#
# See COPYRIGHT and LICENSE files for more details.
module WithSsrfWebhookStubsMixin
module WithSsrfStubsMixin
##
# 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.
@@ -39,11 +39,11 @@ module WithSsrfWebhookStubsMixin
end
end
RSpec.shared_context "with ssrf webhook stubs" do
include WithSsrfWebhookStubsMixin
RSpec.shared_context "with ssrf stubs" do
include WithSsrfStubsMixin
before do
safe_ip = IPAddr.new(WithSsrfWebhookStubsMixin::SSRF_TEST_IP)
safe_ip = IPAddr.new(WithSsrfStubsMixin::SSRF_TEST_IP)
allow(OpenProject::SsrfProtection).to receive(:resolver).and_return(
->(hostname) { ip_address?(hostname) ? [IPAddr.new(hostname)] : [safe_ip] }
)
@@ -118,6 +118,8 @@ RSpec.describe Import::JiraImportProjectsJob, :webmock do
let(:attachment_content) { Rails.root.join("spec/fixtures/files/image.png").binread }
include_context "with ssrf stubs"
before do
stub_request(:get, "https://jira-software.local/secure/attachment/10000/solid-color-image.png")
.to_return(status: 200, body: attachment_content, headers: { "Content-Type" => "image/png" })