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 %> +
+
+ Cross-Origin Resource Sharing (CORS) +
+
+ <%= setting_check_box :apiv3_cors_enabled %> +
+
+ <%= setting_text_area :apiv3_cors_origins, rows: 5, container_class: '-wide' %> +
+

<%= t(:text_line_separated) %>

+

<%= t(:setting_apiv3_cors_origins_text_html, + origin_link: ::OpenProject::Static::Links[:origin_mdn_documentation][:href]) %>

+
+
+
+ <%= 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`) ![Sys-admin-authentication-add-oauth-application](Sys-admin-authentication-oauth-postman.png) + +## 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