From a6f2213f8a704c5adc96de800eae71cf4a413ff0 Mon Sep 17 00:00:00 2001 From: Gerardo Navarro Date: Wed, 15 Oct 2025 11:06:40 +0000 Subject: [PATCH 1/6] Refactor UserDetail to use Sanitizable concern Migrates UserDetail model from deprecated Sanitize.clean() to the Sanitizable concern using modern Sanitize.fragment() API. This aligns with patterns in NamespaceSetting and ApplicationSetting models. Changes: - Use sanitizes! for 8 attributes (bluesky, discord, linkedin, mastodon, orcid, twitter, website_url, github) - Keep custom sanitization for location and organization to preserve ampersand handling behavior - Add validation for pre-escaped HTML, double-encoding, and path traversal attacks The Sanitizable concern prevents double-encoding by using CGI.unescapeHTML(), so ampersands now remain unencoded. Tests updated to reflect this more correct behavior. Changelog: other --- app/models/user_detail.rb | 23 ++++- spec/models/user_detail_spec.rb | 143 ++++++++++++++++++++------------ 2 files changed, 108 insertions(+), 58 deletions(-) diff --git a/app/models/user_detail.rb b/app/models/user_detail.rb index 3e237dfb040b84..1fa2c022ededd6 100644 --- a/app/models/user_detail.rb +++ b/app/models/user_detail.rb @@ -2,6 +2,7 @@ class UserDetail < ApplicationRecord extend ::Gitlab::Utils::Override + include Sanitizable belongs_to :user belongs_to :bot_namespace, class_name: 'Namespace', optional: true, inverse_of: :bot_user_details @@ -71,7 +72,11 @@ class UserDetail < ApplicationRecord validates :onboarding_status, json_schema: { filename: 'user_detail_onboarding_status' } validates :github, length: { maximum: DEFAULT_FIELD_LENGTH }, allow_blank: true - before_validation :sanitize_attrs + # Use Sanitizable concern for most attributes + sanitizes! :bluesky, :discord, :linkedin, :mastodon, :orcid, :twitter, :website_url, :github + + # Keep custom sanitization for location and organization due to special & handling + before_validation :sanitize_location_and_organization before_save :prevent_nil_fields # Exclude the hashed email_otp attribute @@ -79,12 +84,12 @@ def serializable_hash(options = nil) options = options.try(:dup) || {} options[:except] = Array(options[:except]).dup options[:except].concat [:email_otp] - + super end - + private - + def sanitize_attrs %i[bluesky discord linkedin mastodon orcid twitter website_url github].each do |attr| value = self[attr] @@ -95,6 +100,16 @@ def sanitize_attrs self[attr] = Sanitize.clean(value).gsub('&', '&') if value.present? end end + + def sanitize_location_and_organization + %i[location organization].each do |attr| + value = self[attr] + # Use Sanitize.fragment (modern) instead of deprecated Sanitize.clean + # Apply special & to & replacement for these fields + self[attr] = Sanitize.fragment(value).gsub('&', '&') if value.present? + end + end + def prevent_nil_fields self.bluesky = '' if bluesky.nil? diff --git a/spec/models/user_detail_spec.rb b/spec/models/user_detail_spec.rb index e73355a353b07c..3f0d5a2d09917f 100644 --- a/spec/models/user_detail_spec.rb +++ b/spec/models/user_detail_spec.rb @@ -539,62 +539,97 @@ it_behaves_like 'prevents `nil` value', :website_url end - describe '#sanitize_attrs' do - using RSpec::Parameterized::TableSyntax - - subject { build(:user_detail, field => input).tap(&:validate) } - - where(:field, :input, :expected) do - # HTML tags sanitization - all fields - :bluesky | 'did:plc:ewvi7nxzyoun6zhxrhs64oiz' | 'did:plc:ewvi7nxzyoun6zhxrhs64oiz' - :discord | '1234567890987654321' | '1234567890987654321' - :linkedin | 'https://example.com' | 'https://example.com' - :mastodon | '@robin@example.com' | '@robin@example.com' - :orcid | '1234-1234-1234-1234' | '1234-1234-1234-1234' - :twitter | 'https://example.com' | 'https://example.com' - :website_url | 'https://example.com' | 'https://example.com' - :github | 'https://example.com' | 'https://example.com' - :location | 'https://example.com' | 'https://example.com' - :organization | 'https://example.com' | 'https://example.com' - - # iframe scripts sanitization - :bluesky | '