diff --git a/Gemfile b/Gemfile
index 55be5a6c730..0a69e6086a0 100644
--- a/Gemfile
+++ b/Gemfile
@@ -286,6 +286,9 @@ gem 'bootsnap', '~> 1.4.5', require: false
# API gems
gem 'grape', '~> 1.3.0'
+# CORS for API
+gem 'rack-cors', '~> 1.1.1'
+
gem 'reform', '~> 2.2.0'
gem 'reform-rails', '~> 0.1.7'
gem 'roar', '~> 1.1.0'
diff --git a/Gemfile.lock b/Gemfile.lock
index d831fa7f833..ff92a879507 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -724,6 +724,8 @@ GEM
rack (>= 0.4)
rack-attack (6.2.2)
rack (>= 1.0, < 3)
+ rack-cors (1.1.1)
+ rack (>= 2.0.0)
rack-mini-profiler (2.0.1)
rack (>= 1.2.0)
rack-oauth2 (1.10.1)
@@ -1088,6 +1090,7 @@ DEPENDENCIES
puffing-billy (~> 2.3.1)
puma (~> 4.3.5)
rack-attack (~> 6.2.2)
+ rack-cors (~> 1.1.1)
rack-mini-profiler
rack-protection (~> 2.0.8)
rack-test (~> 1.1.0)
diff --git a/app/controllers/concerns/admin_settings_updater.rb b/app/controllers/concerns/admin_settings_updater.rb
index a2c93c4bc52..61dd9d348cd 100644
--- a/app/controllers/concerns/admin_settings_updater.rb
+++ b/app/controllers/concerns/admin_settings_updater.rb
@@ -40,11 +40,17 @@ module AdminSettingsUpdater
if params[:settings]
Settings::UpdateService
.new(user: current_user)
- .call(settings: permitted_params.settings.to_h)
+ .call(settings: settings_params)
flash[:notice] = t(:notice_successful_update)
redirect_to action: 'show', tab: params[:tab]
end
end
+
+ protected
+
+ def settings_params
+ permitted_params.settings.to_h
+ end
end
end
diff --git a/app/controllers/settings/api_controller.rb b/app/controllers/settings/api_controller.rb
new file mode 100644
index 00000000000..a21fd9cb02b
--- /dev/null
+++ b/app/controllers/settings/api_controller.rb
@@ -0,0 +1,48 @@
+#-- encoding: UTF-8
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
+#++
+
+class Settings::ApiController < SettingsController
+ include AdminSettingsUpdater
+
+ menu_item :settings_api
+
+ def show
+ render template: 'settings/_api'
+ end
+
+ def default_breadcrumb
+ t(:label_api_access_key_type)
+ end
+
+ def settings_params
+ super.tap do |settings|
+ settings["apiv3_cors_origins"] = settings["apiv3_cors_origins"].split(/\r?\n/)
+ end
+ end
+end
diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb
index ebd6c6c9b6b..e41c72800a7 100644
--- a/app/helpers/settings_helper.rb
+++ b/app/helpers/settings_helper.rb
@@ -50,6 +50,11 @@ module SettingsHelper
action: { controller: '/settings/projects', action: 'show' },
label: :label_project_plural
},
+ {
+ name: 'api',
+ action: { controller: '/settings/api', action: 'show' },
+ label: :label_api_access_key_type
+ },
{
name: 'repositories',
action: { controller: '/settings/repositories', action: 'show' },
@@ -116,7 +121,13 @@ module SettingsHelper
def setting_text_area(setting, options = {})
setting_label(setting, options) +
wrap_field_outer(options) do
- styled_text_area_tag("settings[#{setting}]", Setting.send(setting), options)
+ value = Setting.send(setting)
+
+ if value.is_a?(Array)
+ value = value.join("\n")
+ end
+
+ styled_text_area_tag("settings[#{setting}]", value, options)
end
end
diff --git a/app/views/settings/_api.html.erb b/app/views/settings/_api.html.erb
new file mode 100644
index 00000000000..a87587956f2
--- /dev/null
+++ b/app/views/settings/_api.html.erb
@@ -0,0 +1,49 @@
+<%#-- copyright
+OpenProject is an open source project management software.
+Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
+
+++#%>
+<%= toolbar title: t(:label_api_access_key_type) %>
+
+<%= styled_form_tag(update_api_settings_path, method: :patch) do %>
+
+ <%= styled_button_tag t(:button_save), class: '-highlight -with-icon icon-checkmark' %>
+<% end %>
diff --git a/config/initializers/rack-cors.rb b/config/initializers/rack-cors.rb
new file mode 100644
index 00000000000..0ebcc24012c
--- /dev/null
+++ b/config/initializers/rack-cors.rb
@@ -0,0 +1,38 @@
+#-- encoding: UTF-8
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
+#++
+Rails.application.config.middleware.insert_before 0, Rack::Cors do
+ allow do
+ origins { |source, env| ::API::V3::CORS.allowed?(source) }
+ resource '/api/v3*',
+ headers: :any,
+ methods: :any,
+ credentials: true,
+ if: proc { ::API::V3::CORS.enabled? }
+ end
+end
diff --git a/config/initializers/zeitwerk.rb b/config/initializers/zeitwerk.rb
index 3904ef8691c..a5dc7202710 100644
--- a/config/initializers/zeitwerk.rb
+++ b/config/initializers/zeitwerk.rb
@@ -55,6 +55,7 @@ OpenProject::Inflector.inflection(
'scm' => 'SCM',
'imap' => 'IMAP',
'pop3' => 'POP3',
+ 'cors' => 'CORS',
'openid_connect' => 'OpenIDConnect',
'pdf_export' => 'PDFExport'
)
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 8c64412ef12..78b7bb14bde 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -2161,6 +2161,12 @@ en:
search_input_placeholder: "Search ..."
+ setting_apiv3_cors_enabled: "Enable CORS"
+ setting_apiv3_cors_origins: "API V3 Cross-Origin Resource Sharing (CORS) allowed origins"
+ setting_apiv3_cors_origins_text_html: >
+ If CORS is enabled, these are the origins that are allowed to access OpenProject API.
+
+ Please check the Documentation on the Origin header on how to specify the expected values.
setting_email_delivery_method: "Email delivery method"
setting_sendmail_location: "Location of the sendmail executable"
setting_smtp_enable_starttls_auto: "Automatically use STARTTLS if available"
diff --git a/config/settings.yml b/config/settings.yml
index 69dd9d50ae6..59cadaa7faa 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -365,3 +365,9 @@ installation_uuid:
oauth_allow_remapping_of_existing_users:
default: false
format: boolean
+apiv3_cors_enabled:
+ default: false
+ format: boolean
+apiv3_cors_origins:
+ serialized: true
+ default: []
diff --git a/docs/api/apiv3/introduction.apib b/docs/api/apiv3/introduction.apib
index 659f50f488c..a53de4854d4 100644
--- a/docs/api/apiv3/introduction.apib
+++ b/docs/api/apiv3/introduction.apib
@@ -100,6 +100,15 @@ On the other hand using **API keys** has some advantages too, which is why we we
Most importantly users may not actually have a password to begin with. Specifically when they have registered
through an OpenID Connect provider.
+# Cross-Origin Resource Sharing (CORS)
+
+By default, the OpenProject API is _not_ responding with any CORS headers.
+If you want to allow cross-domain AJAX calls against your OpenProject instance, you need to enable CORS headers being returned.
+
+Please see [our API settings documentation](https://docs.openproject.org/system-admin-guide/system-settings/api-settings/) on
+how to selectively enable CORS.
+
+
# Allowed HTTP methods
- `GET` - Get a single resource or collection of resources
diff --git a/docs/system-admin-guide/authentication/oauth-applications/README.md b/docs/system-admin-guide/authentication/oauth-applications/README.md
index 3ae5e297dad..53a746d4ccb 100644
--- a/docs/system-admin-guide/authentication/oauth-applications/README.md
+++ b/docs/system-admin-guide/authentication/oauth-applications/README.md
@@ -50,3 +50,11 @@ In Postman the configuration should look like this (Replace `{{protocolHostPort}
i.e. `https://example.com`)

+
+## CORS headers
+
+By default, the OpenProject API is _not_ responding with any CORS headers.
+If you want to allow cross-domain AJAX calls against your OpenProject instance, you need to enable CORS headers being returned.
+
+Please see [our API settings documentation](https://docs.openproject.org/system-admin-guide/system-settings/api-settings/) on
+how to selectively enable CORS.
\ No newline at end of file
diff --git a/docs/system-admin-guide/system-settings/api-settings/README.md b/docs/system-admin-guide/system-settings/api-settings/README.md
new file mode 100644
index 00000000000..bb63a796bbf
--- /dev/null
+++ b/docs/system-admin-guide/system-settings/api-settings/README.md
@@ -0,0 +1,24 @@
+---
+sidebar_navigation:
+ title: API settings
+description: Settings for API functionality of OpenProject
+robots: index, follow
+keywords: API settings
+---
+# API system settings
+
+In the API settings, you can selectively control whether foreign applications may access your OpenProject
+API endpoints from within the browser.
+
+## Cross-Origin Resource Sharing (CORS)
+
+To enable CORS headers being returned by the [OpenProject APIv3](https://docs.openproject.org/api/),
+enable the check box on this page.
+
+You will then have to enter the allowed values for the Origin header that OpenProject will allow access to.
+This is necessary, since authenticated resources of OpenProject cannot be accessible to all origins with the `*` header value.
+
+For more information on the concepts of Cross-Origin Resource Sharing (CORS), please see:
+
+- [an overview of CORS from MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS).
+- [a tutorial on CORS by Auth0](https://auth0.com/blog/cors-tutorial-a-guide-to-cross-origin-resource-sharing/)
diff --git a/lib/api/v3/cors.rb b/lib/api/v3/cors.rb
new file mode 100644
index 00000000000..4129437e1c8
--- /dev/null
+++ b/lib/api/v3/cors.rb
@@ -0,0 +1,50 @@
+#-- encoding: UTF-8
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
+#++
+
+# CORS helper methods for the API v3
+module API
+ module V3
+ module CORS
+ ##
+ # Returns whether CORS headers should
+ # be set on the APIv3 resources
+ def self.enabled?
+ Setting.apiv3_cors_enabled?
+ end
+
+ ##
+ # Determine whether the given origin is included
+ # in the allowed origin list
+ def self.allowed?(source)
+ Setting.apiv3_cors_origins.include?(source)
+ end
+ end
+ end
+end
diff --git a/lib/open_project/cache.rb b/lib/open_project/cache.rb
index 20f3023af8a..37d42e4d6e6 100644
--- a/lib/open_project/cache.rb
+++ b/lib/open_project/cache.rb
@@ -26,6 +26,8 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
+require_relative 'cache/cache_key'
+
module OpenProject
module Cache
def self.fetch(*parts, &block)
diff --git a/lib/open_project/static/links.rb b/lib/open_project/static/links.rb
index af3ee43d2e4..ed912aadeb5 100644
--- a/lib/open_project/static/links.rb
+++ b/lib/open_project/static/links.rb
@@ -184,6 +184,9 @@ module OpenProject
ldap_encryption_documentation: {
href: 'https://www.rubydoc.info/gems/net-ldap/Net/LDAP#constructor_details',
},
+ origin_mdn_documentation: {
+ href: 'https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Origin'
+ },
security_badge_documentation: {
href: 'https://docs.openproject.org/system-admin-guide/information/#security-badge'
},
diff --git a/spec/requests/api/v3/cors_header_spec.rb b/spec/requests/api/v3/cors_header_spec.rb
new file mode 100644
index 00000000000..b4141ea162c
--- /dev/null
+++ b/spec/requests/api/v3/cors_header_spec.rb
@@ -0,0 +1,100 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
+#++
+
+require 'spec_helper'
+require 'rack/test'
+
+describe 'API v3 CORS headers',
+ type: :request,
+ content_type: :json do
+ include Rack::Test::Methods
+ include Capybara::RSpecMatchers
+ include API::V3::Utilities::PathHelper
+
+ context 'with setting enabled',
+ with_settings: { apiv3_cors_enabled: true } do
+
+ context 'with allowed origin set to specific values',
+ with_settings: { apiv3_cors_origins: %w[https://foo.example.com bla.test] } do
+
+ it 'outputs CORS headers', :aggregate_failures do
+ options '/api/v3',
+ nil,
+ 'HTTP_ORIGIN' => 'https://foo.example.com',
+ 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'GET',
+ 'HTTP_ACCESS_CONTROL_REQUEST_HEADERS' => 'test'
+
+ expect(last_response.headers['Access-Control-Allow-Origin']).to eq('https://foo.example.com')
+ expect(last_response.headers['Access-Control-Allow-Methods']).to eq('GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS')
+ expect(last_response.headers['Access-Control-Allow-Headers']).to eq('test')
+ expect(last_response.headers).to have_key('Access-Control-Max-Age')
+ end
+
+ it 'rejects CORS headers for invalid origin' do
+ options '/api/v3',
+ nil,
+ 'HTTP_ORIGIN' => 'invalid.example.com',
+ 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'GET',
+ 'HTTP_ACCESS_CONTROL_REQUEST_HEADERS' => 'test'
+
+ expect(last_response.headers).not_to have_key 'Access-Control-Allow-Origin'
+ expect(last_response.headers).not_to have_key 'Access-Control-Allow-Methods'
+ expect(last_response.headers).not_to have_key 'Access-Control-Allow-Headers'
+ expect(last_response.headers).not_to have_key 'Access-Control-Max-Age'
+ end
+
+ # CORS needs to output headers even if you're unauthorized to allow authentication
+ # to happen
+ it 'returns the CORS header on an unauthorized resource as well', :aggregate_failures do
+ options '/api/v3/work_packages/form',
+ nil,
+ 'HTTP_ORIGIN' => 'https://foo.example.com'
+
+ expect(last_response.headers['Access-Control-Allow-Origin']).to eq('https://foo.example.com')
+ expect(last_response.headers['Access-Control-Allow-Methods']).to eq('GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS')
+ expect(last_response.headers).to have_key('Access-Control-Max-Age')
+ end
+ end
+ end
+
+ context 'when disabled',
+ with_settings: { apiv3_cors_enabled: false, apiv3_cors_origins: %w[foo.example.com] } do
+ it 'does not output CORS headers even though origin matches', :aggregate_failures do
+ options '/api/v3',
+ nil,
+ 'HTTP_ORIGIN' => 'foo.example.com',
+ 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'GET',
+ 'HTTP_ACCESS_CONTROL_REQUEST_HEADERS' => 'test'
+
+ expect(last_response.headers).not_to have_key 'Access-Control-Allow-Origin'
+ expect(last_response.headers).not_to have_key 'Access-Control-Allow-Methods'
+ expect(last_response.headers).not_to have_key 'Access-Control-Allow-Headers'
+ expect(last_response.headers).not_to have_key 'Access-Control-Max-Age'
+ end
+ end
+end