Project

General

Profile

Feature #44652 ยป encrypted_custom_field_format.patch

Hiroyuki Kano, 2026-10-21 09:28

View differences:

app/controllers/admin_controller.rb
89 89
      [:text_mermaid_available, Redmine::Mermaid.available?]
90 90
    ]
91 91
    @checklist << [:text_default_active_job_queue_changed, Rails.application.config.active_job.queue_adapter != :async] if Rails.env.production?
92
    if CustomField.exists?(:field_format => 'encrypted_string')
93
      @checklist << [:text_database_cipher_key_configured, Redmine::Ciphering.cipher_key.present?]
94
    end
92 95
  end
93 96
end
app/models/custom_value.rb
18 18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19 19

  
20 20
class CustomValue < ApplicationRecord
21
  include Redmine::Ciphering
22

  
21 23
  belongs_to :custom_field
22 24
  belongs_to :customized, :polymorphic => true
23 25

  
26
  before_save :encrypt_clear_value
24 27
  after_save :custom_field_after_save_custom_value
25 28

  
26 29
  def initialize(attributes=nil, *args)
......
61 64
    value.to_s
62 65
  end
63 66

  
67
  # Returns the decrypted value for encrypted custom fields, or nil if
68
  # it can not be decrypted (eg. the cipher key is missing or wrong)
69
  def value
70
    text = super
71
    # Checks the format only for ciphered texts to avoid loading the custom field
72
    return text unless ciphered?(text) && encrypted?
73
    return nil if Redmine::Ciphering.cipher_key.blank?
74

  
75
    clear = read_ciphered_attribute(:value).force_encoding('UTF-8')
76
    clear.valid_encoding? ? clear : nil
77
  rescue OpenSSL::Cipher::CipherError, ArgumentError
78
    nil
79
  end
80

  
81
  def value=(arg)
82
    if !encrypted?
83
      # The format is unknown if the value is assigned before the custom field
84
      @value_assigned_without_custom_field = custom_field.nil?
85
      super
86
    # Keeps the stored value if unchanged, as it would be encrypted
87
    # with a new IV each time
88
    elsif !ciphered?(self[:value]) || arg.to_s != value.to_s
89
      write_ciphered_attribute(:value, arg)
90
    end
91
  end
92

  
93
  def value_undecryptable?
94
    encrypted? && ciphered?(self[:value]) && value.nil?
95
  end
96

  
64 97
  private
65 98

  
99
  def encrypted?
100
    custom_field&.field_format == 'encrypted_string'
101
  end
102

  
103
  def ciphered?(text)
104
    text.to_s.start_with?('aes-256-cbc:')
105
  end
106

  
107
  # Encrypts a value that was assigned before the custom field (eg. by
108
  # CustomValue.new(:value => 'x', :custom_field => field)), and refuses
109
  # to store it in clear text if the cipher key is missing
110
  def encrypt_clear_value
111
    return unless @value_assigned_without_custom_field && encrypted?
112

  
113
    @value_assigned_without_custom_field = false
114
    return if self[:value].blank? || ciphered?(self[:value])
115

  
116
    throw :abort if Redmine::Ciphering.cipher_key.blank?
117

  
118
    write_ciphered_attribute(:value, self[:value])
119
  end
120

  
66 121
  def custom_field_after_save_custom_value
67 122
    custom_field.after_save_custom_value(self)
68 123
  end
app/models/issue_custom_field.rb
36 36
  end
37 37

  
38 38
  def visible_by?(project, user=User.current)
39
    # Encrypted values can not be read or written without the cipher key
40
    return false if field_format == 'encrypted_string' && !format.cipher_key_configured?
41

  
39 42
    super || roles.intersect?(user.roles_for_project(project))
40 43
  end
41 44

  
app/models/journal.rb
282 282
    # custom fields changes
283 283
    if @custom_values_before_change
284 284
      values_by_custom_field_id = {}
285
      encrypted_custom_field_ids = []
285 286
      @custom_values_before_change.each_key do |custom_field_id|
286 287
        values_by_custom_field_id[custom_field_id] = nil
287 288
      end
288 289
      journalized.custom_field_values.each do |c|
289 290
        values_by_custom_field_id[c.custom_field_id] = c.value
291
        encrypted_custom_field_ids << c.custom_field_id if c.custom_field.field_format == 'encrypted_string'
290 292
      end
291 293

  
292 294
      values_by_custom_field_id.each do |custom_field_id, after|
......
305 307
          (after - before).reject(&:blank?).each do |value|
306 308
            add_custom_field_detail(custom_field_id, nil, value)
307 309
          end
310
        elsif encrypted_custom_field_ids.include?(custom_field_id)
311
          # Encrypted values must not be stored in clear text
312
          add_custom_field_detail(custom_field_id, nil, nil)
308 313
        else
309 314
          add_custom_field_detail(custom_field_id, before, after)
310 315
        end
app/views/custom_fields/formats/_encrypted_string.html.erb
1
<%= render :partial => 'custom_fields/formats/regexp', :locals => {:f => f, :custom_field => custom_field} %>
config/locales/en.yml
1509 1509
  reaction_text_x_other_users:
1510 1510
    one: "1 other"
1511 1511
    other: "%{count} others"
1512
  label_encrypted_string: Encrypted text
1513
  error_database_cipher_key_not_configured: "The database cipher key is not configured in config/configuration.yml"
1514
  text_database_cipher_key_configured: Database cipher key configured for encrypted custom fields
lib/redmine/field_format.rb
475 475
      end
476 476
    end
477 477

  
478
    # Single line text whose values are stored encrypted in the database
479
    # with the key set by database_cipher_key in config/configuration.yml.
480
    # Values can not be used in SQL statements, so filtering, searching,
481
    # sorting and grouping are not supported, and values are not stored
482
    # in the journal details.
483
    class EncryptedStringFormat < Unbounded
484
      add 'encrypted_string'
485
      self.customized_class_names = %w(Issue)
486
      self.is_filter_supported = false
487
      self.bulk_edit_supported = false
488
      self.change_no_details = true
489
      self.form_partial = 'custom_fields/formats/encrypted_string'
490

  
491
      def cipher_key_configured?
492
        Redmine::Ciphering.cipher_key.present?
493
      end
494

  
495
      def validate_custom_field(custom_field)
496
        errors = super
497
        if custom_field.new_record? && !cipher_key_configured?
498
          errors << [:field_format, l(:error_database_cipher_key_not_configured)]
499
        end
500
        errors
501
      end
502

  
503
      def validate_custom_value(custom_value)
504
        errors = super
505
        if custom_value.value.present? && !cipher_key_configured?
506
          errors << l(:error_database_cipher_key_not_configured)
507
        end
508
        errors
509
      end
510

  
511
      def order_statement(custom_field)
512
        nil
513
      end
514

  
515
      # Renders a password field that is submitted only if the user edits it,
516
      # so that the stored value is not sent to the browser (same as the
517
      # repository and LDAP passwords)
518
      def edit_tag(view, tag_id, tag_name, custom_value, options={})
519
        if custom_value.value.present? && custom_value.value == custom_value.value_was
520
          view.password_field_tag(
521
            'ignore', 'x' * 15,
522
            options.merge(:id => tag_id, :autocomplete => 'new-password',
523
                          :onfocus => "this.value=''; this.name='#{tag_name}';",
524
                          :onchange => "this.name='#{tag_name}';")
525
          )
526
        else
527
          view.password_field_tag(
528
            tag_name, custom_value.value,
529
            options.merge(:id => tag_id, :autocomplete => 'new-password')
530
          )
531
        end
532
      end
533
    end
534

  
478 535
    class Numeric < Unbounded
479 536
      self.form_partial = 'custom_fields/formats/numeric'
480 537
      self.totalable_supported = true
lib/tasks/ciphering.rake
17 17

  
18 18

  
19 19
namespace :db do
20
  desc 'Encrypts SCM and LDAP passwords in the database.'
20
  encrypted_custom_values = lambda do
21
    CustomValue.where(:custom_field_id => CustomField.where(:field_format => 'encrypted_string').select(:id))
22
  end
23

  
24
  desc 'Encrypts SCM and LDAP passwords and encrypted custom field values in the database.'
21 25
  task :encrypt => :environment do
22 26
    unless (Repository.encrypt_all(:password) &&
23 27
      AuthSource.encrypt_all(:account_password) &&
24
      User.encrypt_all(:twofa_totp_key))
28
      User.encrypt_all(:twofa_totp_key) &&
29
      encrypted_custom_values.call.encrypt_all(:value))
25 30
      raise "Some objects could not be saved after encryption, update was rolled back."
26 31
    end
27 32
  end
28 33

  
29
  desc 'Decrypts SCM and LDAP passwords in the database.'
34
  desc 'Decrypts SCM and LDAP passwords and encrypted custom field values in the database.'
30 35
  task :decrypt => :environment do
36
    if encrypted_custom_values.call.any?(&:value_undecryptable?)
37
      raise "Some encrypted custom field values could not be decrypted, check the database_cipher_key."
38
    end
31 39
    unless (Repository.decrypt_all(:password) &&
32 40
      AuthSource.decrypt_all(:account_password) &&
33
      User.decrypt_all(:twofa_totp_key))
41
      User.decrypt_all(:twofa_totp_key) &&
42
      encrypted_custom_values.call.decrypt_all(:value))
34 43
      raise "Some objects could not be saved after decryption, update was rolled back."
35 44
    end
36 45
  end
test/functional/admin_controller_test.rb
213 213
    assert_response :success
214 214
  end
215 215

  
216
  def test_info_should_check_cipher_key_if_encrypted_custom_fields_exist
217
    get :info
218
    assert_select 'td.name', :text => I18n.t(:text_database_cipher_key_configured), :count => 0
219

  
220
    Redmine::Configuration.with 'database_cipher_key' => 'secret' do
221
      IssueCustomField.create!(:name => 'Secret', :field_format => 'encrypted_string')
222
    end
223
    Redmine::Configuration.with 'database_cipher_key' => nil do
224
      get :info
225
    end
226
    assert_select 'tr' do |rows|
227
      row = rows.detect {|tr| tr.at('td.name')&.text == I18n.t(:text_database_cipher_key_configured)}
228
      assert_not_nil row
229
      assert_not_nil row.at('td.tick .icon-error')
230
    end
231
  end
232

  
216 233
  def test_admin_menu_plugin_extension
217 234
    Redmine::MenuManager.map :admin_menu do |menu|
218 235
      menu.push :test_admin_menu_plugin_extension, '/foo/bar', :caption => 'Test'
test/functional/issues_controller_test.rb
6749 6749
    assert_mail_body_match "Searchable field changed from 125 to New custom value", mail
6750 6750
  end
6751 6751

  
6752
  def test_get_edit_should_not_render_encrypted_custom_field_value
6753
    Redmine::Configuration.with 'database_cipher_key' => 'secret' do
6754
      field = IssueCustomField.create!(:name => 'Secret', :field_format => 'encrypted_string',
6755
                                       :is_for_all => true, :tracker_ids => [1])
6756
      issue = Issue.find(1)
6757
      issue.custom_field_values = {field.id.to_s => 'my secret'}
6758
      issue.save!
6759

  
6760
      @request.session[:user_id] = 2
6761
      get(:edit, :params => {:id => 1})
6762
      assert_response :success
6763
      assert_select "input[type=password][id=issue_custom_field_values_#{field.id}][name=ignore]"
6764
      assert_not_include 'my secret', response.body
6765
    end
6766
  end
6767

  
6768
  def test_put_update_with_encrypted_custom_field_change
6769
    Redmine::Configuration.with 'database_cipher_key' => 'secret' do
6770
      field = IssueCustomField.create!(:name => 'Secret', :field_format => 'encrypted_string',
6771
                                       :is_for_all => true, :tracker_ids => [1])
6772
      @request.session[:user_id] = 2
6773
      with_settings :notified_events => %w(issue_updated) do
6774
        put(
6775
          :update,
6776
          :params => {:id => 1, :issue => {:custom_field_values => {field.id.to_s => 'my secret'}}}
6777
        )
6778
      end
6779
      assert_redirected_to :action => 'show', :id => '1'
6780
      assert_equal 'my secret', Issue.find(1).custom_field_value(field)
6781

  
6782
      detail = Journal.last.details.detect {|d| d.prop_key == field.id.to_s}
6783
      assert_nil detail.value
6784
      mail = ActionMailer::Base.deliveries.last
6785
      assert_mail_body_match 'Secret updated', mail
6786
    end
6787
  end
6788

  
6789
  def test_put_update_should_keep_encrypted_custom_field_value_if_not_edited
6790
    Redmine::Configuration.with 'database_cipher_key' => 'secret' do
6791
      field = IssueCustomField.create!(:name => 'Secret', :field_format => 'encrypted_string',
6792
                                       :is_for_all => true, :tracker_ids => [1])
6793
      issue = Issue.find(1)
6794
      issue.custom_field_values = {field.id.to_s => 'my secret'}
6795
      issue.save!
6796

  
6797
      @request.session[:user_id] = 2
6798
      put(:update, :params => {:id => 1, :ignore => 'xxxxxxxxxxxxxxx', :issue => {:subject => 'Updated'}})
6799
      assert_redirected_to :action => 'show', :id => '1'
6800
      assert_equal 'my secret', Issue.find(1).custom_field_value(field)
6801
    end
6802
  end
6803

  
6752 6804
  def test_put_update_with_multi_custom_field_change
6753 6805
    field = CustomField.find(1)
6754 6806
    field.update_attribute :multiple, true
test/integration/api_test/issues_test.rb
970 970
    assert_equal ['MySQL', 'PostgreSQL'], issue.custom_field_value(1).sort
971 971
  end
972 972

  
973
  test "PUT /issues/:id.json with encrypted custom field" do
974
    Redmine::Configuration.with 'database_cipher_key' => 'secret' do
975
      field = IssueCustomField.create!(:name => 'Secret', :field_format => 'encrypted_string',
976
                                       :is_for_all => true, :tracker_ids => [1])
977
      put(
978
        '/issues/3.json',
979
        :params => {:issue => {:custom_fields => [{'id' => field.id.to_s, 'value' => 'my secret'}]}},
980
        :headers => credentials('jsmith'))
981
      assert_response :no_content
982
      assert_match /\Aaes-256-cbc:/, Issue.find(3).custom_value_for(field).read_attribute(:value)
983

  
984
      get '/issues/3.json', :headers => credentials('jsmith')
985
      json = ActiveSupport::JSON.decode(response.body)
986
      assert_equal 'my secret', json['issue']['custom_fields'].detect {|f| f['id'] == field.id}['value']
987
    end
988
  end
989

  
973 990
  test "PUT /issues/:id.xml with project change" do
974 991
    put(
975 992
      '/issues/3.xml',
test/unit/lib/redmine/field_format/encrypted_string_format_test.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
require_relative '../../../../test_helper'
21
require 'redmine/field_format'
22

  
23
class Redmine::EncryptedStringFieldFormatTest < ActionView::TestCase
24
  def setup
25
    User.current = nil
26
    @field = with_cipher_key do
27
      IssueCustomField.create!(:name => 'Secret', :field_format => 'encrypted_string',
28
                               :is_for_all => true, :tracker_ids => [1])
29
    end
30
  end
31

  
32
  def test_value_should_be_stored_encrypted
33
    with_cipher_key do
34
      issue = save_issue_with_secret('my secret')
35
      custom_value = CustomValue.find_by(:customized => issue, :custom_field => @field)
36

  
37
      assert_match /\Aaes-256-cbc:.+\z/, custom_value.read_attribute(:value)
38
      assert_not_includes custom_value.read_attribute(:value), 'my secret'
39
      assert_equal 'my secret', custom_value.value
40
      assert_equal 'my secret', Issue.find(issue.id).custom_field_value(@field)
41
    end
42
  end
43

  
44
  def test_unchanged_value_should_not_be_encrypted_again
45
    with_cipher_key do
46
      issue = save_issue_with_secret('my secret')
47
      stored = CustomValue.find_by(:customized => issue, :custom_field => @field).read_attribute(:value)
48

  
49
      issue = Issue.find(issue.id)
50
      issue.custom_field_values = {@field.id.to_s => 'my secret'}
51
      issue.subject = 'Updated'
52
      issue.save!
53

  
54
      assert_equal stored, CustomValue.find_by(:customized => issue, :custom_field => @field).read_attribute(:value)
55
    end
56
  end
57

  
58
  def test_journal_details_should_not_store_values
59
    with_cipher_key do
60
      issue = save_issue_with_secret('old secret')
61
      issue = Issue.find(issue.id)
62
      issue.init_journal(User.find(1))
63
      issue.custom_field_values = {@field.id.to_s => 'new secret'}
64
      issue.save!
65

  
66
      detail = issue.journals.last.details.detect {|d| d.prop_key == @field.id.to_s}
67
      assert_not_nil detail
68
      assert_nil detail.old_value
69
      assert_nil detail.value
70
    end
71
  end
72

  
73
  def test_custom_field_should_not_be_created_without_cipher_key
74
    with_cipher_key(nil) do
75
      field = IssueCustomField.new(:name => 'Other secret', :field_format => 'encrypted_string')
76
      assert_not field.save
77
      assert_include ::I18n.t(:error_database_cipher_key_not_configured), field.errors[:field_format]
78
    end
79
  end
80

  
81
  def test_custom_field_should_not_be_visible_without_cipher_key
82
    issue = with_cipher_key {save_issue_with_secret('my secret')}
83

  
84
    with_cipher_key(nil) do
85
      issue = Issue.find(issue.id)
86
      admin = User.find(1)
87
      assert_not @field.visible_by?(issue.project, admin)
88
      assert_not_include @field, issue.visible_custom_field_values(admin).map(&:custom_field)
89
      assert_not_include @field, issue.editable_custom_fields(admin)
90
      assert_nil issue.custom_field_value(@field)
91
    end
92
  end
93

  
94
  def test_stored_value_should_be_kept_without_cipher_key
95
    issue = with_cipher_key {save_issue_with_secret('my secret')}
96
    stored = CustomValue.find_by(:customized => issue, :custom_field => @field).read_attribute(:value)
97

  
98
    with_cipher_key(nil) do
99
      issue = Issue.find(issue.id)
100
      issue.init_journal(User.find(1))
101
      issue.safe_attributes = {'subject' => 'Updated', 'custom_field_values' => {@field.id.to_s => 'other'}}
102
      issue.save!
103
    end
104

  
105
    assert_equal stored, CustomValue.find_by(:customized => issue, :custom_field => @field).read_attribute(:value)
106
    with_cipher_key {assert_equal 'my secret', Issue.find(issue.id).custom_field_value(@field)}
107
  end
108

  
109
  def test_value_should_be_nil_with_wrong_cipher_key
110
    issue = with_cipher_key {save_issue_with_secret('my secret')}
111
    stored = CustomValue.find_by(:customized => issue, :custom_field => @field).read_attribute(:value)
112

  
113
    with_cipher_key('wrong') do
114
      issue = Issue.find(issue.id)
115
      assert_nil issue.custom_field_value(@field)
116

  
117
      issue.subject = 'Updated'
118
      issue.save!
119
    end
120

  
121
    assert_equal stored, CustomValue.find_by(:customized => issue, :custom_field => @field).read_attribute(:value)
122
  end
123

  
124
  def test_value_should_be_encrypted_by_encrypt_all
125
    issue = with_cipher_key(nil) {save_issue_with_secret('my secret')}
126
    custom_value = CustomValue.find_by(:customized => issue, :custom_field => @field)
127
    custom_value.update_column(:value, 'my secret')
128

  
129
    with_cipher_key do
130
      assert CustomValue.where(:id => custom_value.id).encrypt_all(:value)
131
      assert_match /\Aaes-256-cbc:/, custom_value.reload.read_attribute(:value)
132
      assert_equal 'my secret', custom_value.value
133

  
134
      assert CustomValue.where(:id => custom_value.id).decrypt_all(:value)
135
      assert_equal 'my secret', custom_value.reload.read_attribute(:value)
136
    end
137
  end
138

  
139
  def test_value_assigned_before_custom_field_should_be_encrypted
140
    with_cipher_key do
141
      custom_value = CustomValue.create!(:value => 'my secret', :customized => Issue.find(1), :custom_field => @field)
142

  
143
      assert_match /\Aaes-256-cbc:/, custom_value.reload.read_attribute(:value)
144
      assert_equal 'my secret', custom_value.value
145
    end
146
  end
147

  
148
  def test_value_assigned_before_custom_field_should_not_be_saved_without_cipher_key
149
    with_cipher_key(nil) do
150
      custom_value = CustomValue.new(:value => 'my secret', :customized => Issue.find(1), :custom_field => @field)
151

  
152
      assert_not custom_value.save
153
      assert_not CustomValue.where(:custom_field => @field).exists?
154
    end
155
  end
156

  
157
  def test_value_of_other_formats_should_not_be_decrypted
158
    field = IssueCustomField.create!(:name => 'Text', :field_format => 'string')
159
    custom_value = CustomValue.new(:custom_field => field, :value => 'aes-256-cbc:foo')
160

  
161
    with_cipher_key do
162
      assert_equal 'aes-256-cbc:foo', custom_value.value
163
    end
164
  end
165

  
166
  def test_format_should_not_support_filter_sort_and_bulk_edit
167
    assert_not @field.format.is_filter_supported
168
    assert_not @field.format.searchable_supported
169
    assert_not @field.format.bulk_edit_supported
170
    assert_nil @field.order_statement
171
    assert_nil @field.group_statement
172
  end
173

  
174
  def test_edit_tag_should_not_render_stored_value
175
    custom_value = CustomFieldValue.new(:custom_field => @field, :customized => Issue.new)
176
    custom_value.instance_variable_set(:@value, 'my secret')
177
    custom_value.value_was = 'my secret'
178

  
179
    html = @field.format.edit_tag(self, 'issue_custom_field_values_1', 'issue[custom_field_values][1]', custom_value)
180
    assert_select_in html, 'input[type=password][name=ignore][value=?]', 'x' * 15
181
    assert_not_includes html, 'my secret'
182
  end
183

  
184
  def test_edit_tag_without_stored_value
185
    custom_value = CustomFieldValue.new(:custom_field => @field, :customized => Issue.new, :value => '')
186

  
187
    html = @field.format.edit_tag(self, 'issue_custom_field_values_1', 'issue[custom_field_values][1]', custom_value)
188
    assert_select_in html, 'input[type=password][name=?]', 'issue[custom_field_values][1]'
189
  end
190

  
191
  private
192

  
193
  def with_cipher_key(key='secret')
194
    result = nil
195
    Redmine::Configuration.with('database_cipher_key' => key) {result = yield}
196
    result
197
  end
198

  
199
  def save_issue_with_secret(secret)
200
    issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :subject => 'Secret')
201
    issue.custom_field_values = {@field.id.to_s => secret}
202
    issue.save!
203
    issue
204
  end
205
end
    (1-1/1)