From adb9f6b07103b6118ddee340f3ef5c4569027bad Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Wed, 10 Jun 2026 12:18:07 +0200 Subject: [PATCH 1/2] Authorize project custom field access in inplace edit dialog --- .../inplace_edit_fields_controller.rb | 10 ++ .../inplace_edit_fields_controller_spec.rb | 61 ++++++++ ...idden_project_custom_field_comment_spec.rb | 138 ++++++++++++++++++ 3 files changed, 209 insertions(+) create mode 100644 spec/requests/inplace_edit_fields/hidden_project_custom_field_comment_spec.rb diff --git a/app/controllers/inplace_edit_fields_controller.rb b/app/controllers/inplace_edit_fields_controller.rb index ca2b8591959..edf3240a97f 100644 --- a/app/controllers/inplace_edit_fields_controller.rb +++ b/app/controllers/inplace_edit_fields_controller.rb @@ -33,6 +33,7 @@ class InplaceEditFieldsController < ApplicationController before_action :find_model before_action :set_attribute + before_action :authorize_project_custom_field_visibility!, if: :custom_field_attribute? no_authorization_required! :edit, :update, :reset, :dialog def edit @@ -125,6 +126,15 @@ class InplaceEditFieldsController < ApplicationController @attribute = params[:attribute].to_sym end + def authorize_project_custom_field_visibility! + return unless @model.is_a?(Project) + + custom_field_id = @attribute.to_s.delete_prefix("custom_field_").to_i + unless ProjectCustomField.visible(current_user, project: @model).exists?(custom_field_id) + head :not_found + end + end + def permitted_params if custom_field_via_fields_for? transform_custom_field_values_params.merge(custom_comments_params) diff --git a/spec/controllers/inplace_edit_fields_controller_spec.rb b/spec/controllers/inplace_edit_fields_controller_spec.rb index 70fdc965a5d..649c0d9d55e 100644 --- a/spec/controllers/inplace_edit_fields_controller_spec.rb +++ b/spec/controllers/inplace_edit_fields_controller_spec.rb @@ -144,6 +144,10 @@ RSpec.describe InplaceEditFieldsController do let(:custom_field) { create(:project_custom_field) } let(:attribute) { custom_field.attribute_name.to_sym } + before do + allow(ProjectCustomField).to receive(:visible).and_return(ProjectCustomField.all) + end + it "accepts custom_field_values hash params and returns ok" do patch :update, params: { model: model_param, @@ -161,6 +165,10 @@ RSpec.describe InplaceEditFieldsController do let(:custom_field) { create(:project_custom_field) } let(:attribute) { custom_field.attribute_name.to_sym } + before do + allow(ProjectCustomField).to receive(:visible).and_return(ProjectCustomField.all) + end + it "accepts custom_field_values array params and returns ok" do patch :update, params: { model: model_param, @@ -204,6 +212,59 @@ RSpec.describe InplaceEditFieldsController do end end + describe "project custom field visibility guard" do + let(:handler) { double } + + context "when the model is a project and the custom field is not visible to the user" do + let(:custom_field) { create(:project_custom_field) } + let(:attribute) { custom_field.attribute_name.to_sym } + + before do + allow(ProjectCustomField) + .to receive(:visible) + .and_return(ProjectCustomField.none) + end + + it "returns 404" do + get :dialog, params: { + model: model_param, + id: model.id, + attribute: + }, format: :turbo_stream + + expect(response).to have_http_status(:not_found) + end + end + + context "when the model is not a project" do + let(:non_project_model) { create(:user) } + + let(:update_registry) do + registry = OpenProject::InplaceEdit::UpdateRegistry.new + contract = double + allow(contract).to receive(:new).and_return(double(writable?: true)) + registry.register(User, handler:, contract:) + registry + end + + before do + allow(controller).to receive_messages(current_user: user, update_registry:) + allow(User).to receive(:visible).and_return(User.where(id: non_project_model.id)) + allow(controller).to receive(:respond_with_dialog) # skip component rendering for non-project model + end + + it "does not apply the project custom field visibility check for a custom field attribute" do + get :dialog, params: { + model: "user", + id: non_project_model.id, + attribute: :custom_field_1 + }, format: :turbo_stream + + expect(response).not_to have_http_status(:not_found) + end + end + end + describe "model resolution errors" do let(:handler) { double } diff --git a/spec/requests/inplace_edit_fields/hidden_project_custom_field_comment_spec.rb b/spec/requests/inplace_edit_fields/hidden_project_custom_field_comment_spec.rb new file mode 100644 index 00000000000..9eda0e10c59 --- /dev/null +++ b/spec/requests/inplace_edit_fields/hidden_project_custom_field_comment_spec.rb @@ -0,0 +1,138 @@ +# 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" + +# Security issue where the inplace-edit dialog endpoint +# would expose stored comment text of admin_only project custom fields to +# non-admin project members who cannot see the field through normal visibility +# boundaries. (Regression#SC-244) +# +# The endpoint GET /inplace_edit_fields/project/:id/custom_field_/dialog +# must enforce the proper project custom field visibility. +RSpec.describe "InplaceEditFieldsController — admin_only project custom field visibility", + :skip_csrf, + type: :rails_request do + let(:project) { create(:project) } + + let(:hidden_custom_field) do + create(:project_custom_field, :admin_only, :has_comment, projects: [project]) + end + + let(:confidential_comment_text) { "confidential sponsor comment" } + + let!(:custom_comment) do + create(:custom_comment, + customized: project, + custom_field: hidden_custom_field, + text: confidential_comment_text) + end + + let(:attribute) { hidden_custom_field.attribute_name } + let(:path_params) { { model: "project", id: project.id, attribute: } } + let(:turbo_headers) { { "Accept" => "text/vnd.turbo-stream.html" } } + + context "as a non-admin project member with edit_project_attributes" do + let(:member_role) do + create(:project_role, permissions: %i[view_project edit_project_attributes]) + end + let(:non_admin_user) do + create(:user, member_with_roles: { project => member_role }) + end + + current_user { non_admin_user } + + it "confirms that normal visibility scoping excludes the admin_only field" do + expect(ProjectCustomField.visible(non_admin_user, project:)).not_to include(hidden_custom_field) + end + + it "confirms that the update contract marks the field as not writable" do + contract = Projects::UpdateContract.new(project, non_admin_user, options: { project_attributes_only: true }) + expect(contract.writable?(hidden_custom_field.attribute_name)).to be(false) + end + + it "blocks GET edit" do + get inplace_edit_field_edit_path(path_params), headers: turbo_headers + expect(response).to have_http_status(:not_found) + end + + it "blocks PATCH update" do + patch inplace_edit_field_update_path(path_params), headers: turbo_headers + expect(response).to have_http_status(:not_found) + end + + it "blocks GET reset" do + get inplace_edit_field_reset_path(path_params), headers: turbo_headers + expect(response).to have_http_status(:not_found) + end + + it "blocks GET dialog and does not expose the comment text", :aggregate_failures do + get inplace_edit_field_dialog_path(path_params), headers: turbo_headers + + expect(response).to have_http_status(:not_found) + expect(response.body).not_to include(confidential_comment_text) + end + end + + context "when the attribute is not a custom field" do + let(:member_role) do + create(:project_role, permissions: %i[view_project edit_project_attributes]) + end + let(:non_admin_user) do + create(:user, member_with_roles: { project => member_role }) + end + + current_user { non_admin_user } + + it "does not block the non-admin user for standard project attributes" do + get inplace_edit_field_dialog_path(model: "project", id: project.id, attribute: "name"), + headers: turbo_headers + + expect(response).to have_http_status(:ok) + end + end + + context "as an admin" do + let(:admin) { create(:admin) } + + current_user { admin } + + it "confirms that visibility scoping includes the admin_only field for admins" do + expect(ProjectCustomField.visible(admin, project:)).to include(hidden_custom_field) + end + + it "can access the dialog and see the comment", :aggregate_failures do + get inplace_edit_field_dialog_path(path_params), headers: turbo_headers + + expect(response).to have_http_status(:ok) + expect(response.body).to include(confidential_comment_text) + end + end +end From 45c29be300825caebb87b461f9e4cd2036aaf51b Mon Sep 17 00:00:00 2001 From: OpenProject Actions CI Date: Thu, 11 Jun 2026 04:36:13 +0000 Subject: [PATCH 2/2] update locales from crowdin [ci skip] --- config/locales/crowdin/es.yml | 4 ++-- modules/meeting/config/locales/crowdin/cs.yml | 22 +++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/config/locales/crowdin/es.yml b/config/locales/crowdin/es.yml index 9b77a0fc4c8..50034ed6bab 100644 --- a/config/locales/crowdin/es.yml +++ b/config/locales/crowdin/es.yml @@ -437,7 +437,7 @@ es: ' box_header: - table_title: Projects with identifiers to update + table_title: Proyectos con identificadores para actualizar label_project: Proyecto label_previous_identifier: Identificador anterior label_autofixed_suggestion: Identificador futuro @@ -3153,7 +3153,7 @@ es: description: 'Personalice la configuración del formulario con estas extensiones:' features: groups: Add new attribute sections - rename: Rename attribute sections + rename: Renombrar secciones de atributos related: Añadir una tabla de paquetes de trabajo relacionados readonly_work_packages: description: Marque los paquetes de trabajo como de solo lectura para estados específicos. diff --git a/modules/meeting/config/locales/crowdin/cs.yml b/modules/meeting/config/locales/crowdin/cs.yml index ad209d8a05d..4547c9eea63 100644 --- a/modules/meeting/config/locales/crowdin/cs.yml +++ b/modules/meeting/config/locales/crowdin/cs.yml @@ -387,7 +387,7 @@ cs: submit_button: Stáhnout notifications: sidepanel: - title: Email calendar updates + title: E-mailové aktualizace kalendářů description: disabled: Participants will not receive an email informing them of changes. enabled: All participants will receive updated calendar invites via email informing them of changes. @@ -411,7 +411,7 @@ cs: enabled: 'Všichni účastníci obdrží e-mailem aktualizované pozvánky do kalendáře, když přidáte nebo odeberete účastníky. ' - disabled: 'Email calendar updates are disabled. Participants will not receive an email informing them when you add or remove participants. + disabled: 'E-mailové aktualizace kalendářů jsou zakázány. Účastníci nedostanou e-mail s informacemi o přidání nebo odebrání účastníků. ' draft_disabled: 'Participants will not receive an email informing them when you add or remove participants. @@ -425,17 +425,17 @@ cs: ' occurrence: - enabled: 'Email calendar updates are enabled for the meeting series. All participants will receive updated calendar invites informing them of your changes to this occurrence. + enabled: 'E-mailové aktualizace kalendářů jsou povoleny pro tuto sérii schůzek. Účastníci dostanou e-mail s informacemi, když provedete změny. ' - disabled: 'Email calendar updates are disabled for the meeting series. Participants will not receive an email informing them of your changes to this occurrence. + disabled: 'E-mailové aktualizace kalendářů jsou zakázány pro tuto sérii schůzek. Účastníci nedostanou e-mail s informacemi, když provedete změny. ' template: - enabled: 'Email calendar updates are enabled for the meeting series. All participants will receive updated calendar invites informing them of your changes to this template or to individual occurrences. + enabled: 'E-mailové aktualizace kalendářů jsou povoleny. Účastníci budou dostávat aktualizované pozvánky e-mailem, když upravíte tuto šablonu či individuální výskyty. ' - disabled: 'Email calendar updates are disabled for the meeting series. Participants will not receive an email informing them of your changes to this template or to individual occurrences. + disabled: 'E-mailové aktualizace kalendářů jsou zakázány pro tuto sérii schůzek. Účastníci nebudou dostávat aktualizované pozvánky e-mailem, když upravíte tuto šablonu či individuální výskyty. ' presentation_mode: @@ -449,13 +449,13 @@ cs: no_items: Žádné body programu no_items_flash: Na programu nejsou žádné body. ical_response: - update_failed: Could not update participation status. + update_failed: Nelze aktualizovat stav účasti. meeting_not_found: Schůzka pro daný UID nebyla nalezena. widgets: blankslate: - heading: No upcoming meetings + heading: Žádné nadcházející schůzky description: Upcoming meetings you are participating in will appear here. - view_details: View all meetings + view_details: Zobrazit všechny schůzky meeting_section: untitled_title: Sekce bez názvu delete_confirmation: Smazáním sekce také smažete všechny body agendy. Opravdu to chcete udělat? @@ -757,8 +757,8 @@ cs: text_agenda_item_dialog_skipping_closed_many: "%{count} closed meetings" text_work_package_add_to_meeting_hint: Použijte tlačítko "Přidat do schůzky" pro přidání tohoto pracovního balíčku na nadcházející schůzku. text_work_package_has_no_past_meeting_agenda_items: Tento pracovní balíček nebyl na minulém zasedání zařazen jako bod programu. - text_email_updates_muted: Email calendar updates are muted. Participants will not receive updated invites via email when you make changes. - text_email_updates_enabled: Email calendar updates are enabled. All participants will receive updated invites via email when you make changes. + text_email_updates_muted: E-mailové aktualizace kalendářů jsou ztlumeny. Účastníci nebudou dostávat aktualizované pozvánky e-mailem, když provedete změny. + text_email_updates_enabled: E-mailové aktualizace kalendářů jsou povoleny. Účastníci budou dostávat aktualizované pozvánky e-mailem, když provedete změny. my_account: access_tokens: token/ical_meeting: