diff --git a/app/interactions/actions/domain_create.rb b/app/interactions/actions/domain_create.rb index bd4f032d2..3f90eb5eb 100644 --- a/app/interactions/actions/domain_create.rb +++ b/app/interactions/actions/domain_create.rb @@ -18,6 +18,8 @@ module Actions # domain.attach_default_contacts assign_expiry_time maybe_attach_legal_doc + validate_ns_records + validate_dns_records commit end @@ -181,6 +183,26 @@ module Actions ::Actions::BaseAction.attach_legal_doc_to_new(domain, params[:legal_document], domain: true) end + def validate_ns_records + return unless domain.nameservers.any? + return unless dns_validation_enabled? + + result = DNSValidator.validate(domain: domain, name: domain.name, record_type: 'NS') + return if result[:errors].blank? + + assign_dns_validation_error(result[:errors]) + end + + def validate_dns_records + return unless domain.dnskeys.any? + return unless dns_validation_enabled? + + result = DNSValidator.validate(domain: domain, name: domain.name, record_type: 'DNSKEY') + return if result[:errors].blank? + + assign_dns_validation_error(result[:errors]) + end + def process_auction_and_disputes dn = DNS::DomainName.new(domain.name) Dispute.close_by_domain(domain.name) @@ -200,6 +222,19 @@ module Actions domain.save end + def assign_dns_validation_error(errors) + errors.each do |error| + domain.add_epp_error('2306', nil, nil, error) + end + end + + def dns_validation_enabled? + # Enable DNS validation in production or when explicitly enabled + # Disabled in test environment by default unless explicitly enabled + return ENV['DNS_VALIDATION_ENABLED'] == 'true' if Rails.env.test? + Rails.env.production? || ENV['DNS_VALIDATION_ENABLED'] == 'true' + end + def validation_process_errored? return if domain.valid? diff --git a/app/interactions/actions/domain_update.rb b/app/interactions/actions/domain_update.rb index 36752b2a0..b05a56896 100644 --- a/app/interactions/actions/domain_update.rb +++ b/app/interactions/actions/domain_update.rb @@ -13,6 +13,8 @@ module Actions validate_domain_integrity assign_new_registrant if params[:registrant] assign_relational_modifications + validate_ns_records + validate_dns_records assign_requested_statuses ::Actions::BaseAction.maybe_attach_legal_doc(domain, params[:legal_document]) @@ -97,6 +99,39 @@ module Actions end end + def validate_ns_records + return unless domain.nameservers.any? + return unless dns_validation_enabled? + + result = DNSValidator.validate(domain: domain, name: domain.name, record_type: 'NS') + return if result[:errors].blank? + + assign_dns_validation_error(result[:errors]) + end + + def validate_dns_records + return unless domain.dnskeys.any? + return unless dns_validation_enabled? + + result = DNSValidator.validate(domain: domain, name: domain.name, record_type: 'DNSKEY') + return if result[:errors].blank? + + assign_dns_validation_error(result[:errors]) + end + + def assign_dns_validation_error(errors) + errors.each do |error| + domain.add_epp_error('2306', nil, nil, error) + end + end + + def dns_validation_enabled? + # Enable DNS validation in production or when explicitly enabled + # Disabled in test environment by default unless explicitly enabled + return ENV['DNS_VALIDATION_ENABLED'] == 'true' if Rails.env.test? + Rails.env.production? || ENV['DNS_VALIDATION_ENABLED'] == 'true' + end + def assign_dnssec_modifications @dnskeys = [] params[:dns_keys].each do |key| diff --git a/app/jobs/dns_validation_job.rb b/app/jobs/dns_validation_job.rb index c0c0a169a..13410f339 100644 --- a/app/jobs/dns_validation_job.rb +++ b/app/jobs/dns_validation_job.rb @@ -3,6 +3,6 @@ class DNSValidationJob < ApplicationJob def perform(domain_id) domain = Domain.find(domain_id) - DNSValidator.validate(domain: domain, name: domain.name) + DNSValidator.validate(domain: domain, name: domain.name, record_type: 'all') end end diff --git a/app/services/dns_validator.rb b/app/services/dns_validator.rb index c924f6f7e..16aa58cf9 100644 --- a/app/services/dns_validator.rb +++ b/app/services/dns_validator.rb @@ -4,11 +4,12 @@ require 'dnsruby' class DNSValidator include Dnsruby - attr_reader :domain, :results + attr_reader :domain, :results, :record_type - def initialize(domain:, name:) + def initialize(domain:, name:, record_type:) @domain = domain.present? ? domain : Domain.find_by_name(name) raise "Domain not found" if @domain.blank? + @record_type = record_type @results = { nameservers: {}, @@ -20,17 +21,31 @@ class DNSValidator } end - def self.validate(domain:, name:) - new(domain: domain, name: name).validate + def self.validate(domain:, name:, record_type:) + new(domain: domain, name: name, record_type: record_type).validate end def validate Rails.logger.info "Starting DNS validation for domain: #{domain.name}" - validate_nameservers - validate_dns_records - check_dnssec_sync_records - check_csync_records + case record_type + when 'NS' + validate_nameservers + when 'A', 'AAAA' + validate_dns_records + when 'DNSKEY' + check_dnssec_sync_records + when 'CSYNC' + check_csync_records + when 'all' + validate_nameservers + validate_dns_records + check_dnssec_sync_records + check_csync_records + else + raise "Invalid record type: #{record_type}" + end + apply_enforcement_actions Rails.logger.info "DNS validation completed for domain: #{domain.name}" @@ -638,8 +653,8 @@ class DNSValidator # Class methods for easy usage class << self - def validate_domain(domain) - validator = new(domain) + def validate_domain(domain, record_type: 'all') + validator = new(domain: domain, name: domain.name, record_type: record_type) validator.validate end end diff --git a/test/integration/repp/v1/domains/create_test.rb b/test/integration/repp/v1/domains/create_test.rb index 7907e709e..1fae52aae 100644 --- a/test/integration/repp/v1/domains/create_test.rb +++ b/test/integration/repp/v1/domains/create_test.rb @@ -134,4 +134,148 @@ class ReppV1DomainsCreateTest < ActionDispatch::IntegrationTest assert @user.registrar.domains.find_by(name: 'domeener.test').present? assert_equal 'ABADIATS', @user.registrar.domains.find_by(name: 'domeener.test').transfer_code end + + def test_creates_domain_with_nameservers_validates_dns + ENV['DNS_VALIDATION_ENABLED'] = 'true' + + @auth_headers['Content-Type'] = 'application/json' + contact = contacts(:john) + + # Mock successful DNS validation + DNSValidator.stub :validate, { errors: [] } do + payload = { + domain: { + name: 'domeener.test', + registrant: contact.code, + period: 1, + period_unit: 'y', + nameservers_attributes: [ + { hostname: 'ns1.example.com' }, + { hostname: 'ns2.example.com' } + ] + } + } + + post "/repp/v1/domains", headers: @auth_headers, params: payload.to_json + json = JSON.parse(response.body, symbolize_names: true) + assert_response :ok + assert_equal 1000, json[:code] + assert_equal 'Command completed successfully', json[:message] + + domain = @user.registrar.domains.find_by(name: 'domeener.test') + assert domain.present? + assert_equal 2, domain.nameservers.count + end + ensure + ENV.delete('DNS_VALIDATION_ENABLED') + end + + def test_fails_to_create_domain_with_invalid_nameservers + ENV['DNS_VALIDATION_ENABLED'] = 'true' + + @auth_headers['Content-Type'] = 'application/json' + contact = contacts(:john) + + # Mock DNS validation failure + DNSValidator.stub :validate, { errors: ['Nameserver ns1.example.com is not authoritative for domain'] } do + payload = { + domain: { + name: 'domeener.test', + registrant: contact.code, + period: 1, + period_unit: 'y', + nameservers_attributes: [ + { hostname: 'ns1.example.com' }, + { hostname: 'ns2.example.com' } + ] + } + } + + post "/repp/v1/domains", headers: @auth_headers, params: payload.to_json + json = JSON.parse(response.body, symbolize_names: true) + assert_response :bad_request + assert_equal 2306, json[:code] + assert json[:message].include?('Nameserver ns1.example.com is not authoritative') + + refute @user.registrar.domains.find_by(name: 'domeener.test').present? + end + ensure + ENV.delete('DNS_VALIDATION_ENABLED') + end + + def test_creates_domain_with_dnssec_validates_dnskey + ENV['DNS_VALIDATION_ENABLED'] = 'true' + + @auth_headers['Content-Type'] = 'application/json' + contact = contacts(:john) + + # Mock successful DNSSEC validation + DNSValidator.stub :validate, { errors: [] } do + payload = { + domain: { + name: 'domeener.test', + registrant: contact.code, + period: 1, + period_unit: 'y', + dnskeys_attributes: [ + { + flags: '257', + protocol: '3', + alg: '8', + public_key: 'AwEAAddt2AkLfYGKgiEZB5SmIF8EvrjxNMH6HtxWEA4RJ9Ao6LCWheg8' + } + ] + } + } + + post "/repp/v1/domains", headers: @auth_headers, params: payload.to_json + json = JSON.parse(response.body, symbolize_names: true) + assert_response :ok + assert_equal 1000, json[:code] + assert_equal 'Command completed successfully', json[:message] + + domain = @user.registrar.domains.find_by(name: 'domeener.test') + assert domain.present? + assert_equal 1, domain.dnskeys.count + end + ensure + ENV.delete('DNS_VALIDATION_ENABLED') + end + + def test_fails_to_create_domain_with_invalid_dnskey + ENV['DNS_VALIDATION_ENABLED'] = 'true' + + @auth_headers['Content-Type'] = 'application/json' + contact = contacts(:john) + + # Mock DNSSEC validation failure + DNSValidator.stub :validate, { errors: ['DNSKEY record not found in DNS'] } do + payload = { + domain: { + name: 'domeener.test', + registrant: contact.code, + period: 1, + period_unit: 'y', + dnskeys_attributes: [ + { + flags: '257', + protocol: '3', + alg: '8', + public_key: 'AwEAAddt2AkLfYGKgiEZB5SmIF8EvrjxNMH6HtxWEA4RJ9Ao6LCWheg8' + } + ] + } + } + + post "/repp/v1/domains", headers: @auth_headers, params: payload.to_json + json = JSON.parse(response.body, symbolize_names: true) + assert_response :bad_request + assert_equal 2306, json[:code] + assert json[:message].include?('DNSKEY record not found') + + refute @user.registrar.domains.find_by(name: 'domeener.test').present? + end + ensure + ENV.delete('DNS_VALIDATION_ENABLED') + end end diff --git a/test/integration/repp/v1/domains/dnssec_test.rb b/test/integration/repp/v1/domains/dnssec_test.rb index 46e239fbf..697970063 100644 --- a/test/integration/repp/v1/domains/dnssec_test.rb +++ b/test/integration/repp/v1/domains/dnssec_test.rb @@ -138,4 +138,63 @@ class ReppV1DomainsDnssecTest < ActionDispatch::IntegrationTest ENV["shunter_default_threshold"] = '10000' ENV["shunter_enabled"] = 'false' end + + def test_validates_dns_when_adding_dnskey + ENV['DNS_VALIDATION_ENABLED'] = 'true' + + # Mock successful DNS validation + DNSValidator.stub :validate, { errors: [] } do + payload = { + dns_keys: [ + { flags: '257', + alg: '8', + protocol: '3', + public_key: 'AwEAAddt2AkLfYGKgiEZB5SmIF8EvrjxNMH6HtxWEA4RJ9Ao6LCWheg8' }, + ], + } + + post "/repp/v1/domains/#{@domain.name}/dnssec", params: payload, headers: @auth_headers + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :ok + assert_equal 1000, json[:code] + assert_equal 'Command completed successfully', json[:message] + + @domain.reload + assert @domain.dnskeys.present? + dnssec_key = @domain.dnskeys.last + assert_equal 257, dnssec_key.flags + assert_equal 8, dnssec_key.alg + end + ensure + ENV.delete('DNS_VALIDATION_ENABLED') + end + + def test_fails_to_add_dnskey_with_invalid_dns + ENV['DNS_VALIDATION_ENABLED'] = 'true' + + # Mock DNS validation failure + DNSValidator.stub :validate, { errors: ['DNSKEY record not found in DNS'] } do + payload = { + dns_keys: [ + { flags: '257', + alg: '8', + protocol: '3', + public_key: 'AwEAAddt2AkLfYGKgiEZB5SmIF8EvrjxNMH6HtxWEA4RJ9Ao6LCWheg8' }, + ], + } + + post "/repp/v1/domains/#{@domain.name}/dnssec", params: payload, headers: @auth_headers + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :bad_request + assert_equal 2306, json[:code] + assert json[:message].include?('DNSKEY record not found') + + @domain.reload + assert @domain.dnskeys.empty? + end + ensure + ENV.delete('DNS_VALIDATION_ENABLED') + end end diff --git a/test/integration/repp/v1/domains/nameservers_test.rb b/test/integration/repp/v1/domains/nameservers_test.rb index 3ff85260e..b45863e0c 100644 --- a/test/integration/repp/v1/domains/nameservers_test.rb +++ b/test/integration/repp/v1/domains/nameservers_test.rb @@ -109,4 +109,58 @@ class ReppV1DomainsNameserversTest < ActionDispatch::IntegrationTest assert_equal 'Data management policy violation; Nameserver count must be between 2-11 for active ' \ 'domains [nameservers]', json[:message] end + + def test_validates_dns_when_adding_nameserver + ENV['DNS_VALIDATION_ENABLED'] = 'true' + + # Mock successful DNS validation + DNSValidator.stub :validate, { errors: [] } do + payload = { + nameservers: [ + { hostname: "ns3.example.com", + ipv4: ["192.168.1.1"], + ipv6: ["FE80::AEDE:48FF:FE00:1122"]} + ] + } + + post "/repp/v1/domains/#{@domain.name}/nameservers", params: payload, headers: @auth_headers + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :ok + assert_equal 1000, json[:code] + assert_equal 'Command completed successfully', json[:message] + + @domain.reload + assert @domain.nameservers.where(hostname: 'ns3.example.com').any? + end + ensure + ENV.delete('DNS_VALIDATION_ENABLED') + end + + def test_fails_to_add_nameserver_with_invalid_dns + ENV['DNS_VALIDATION_ENABLED'] = 'true' + + # Mock DNS validation failure + DNSValidator.stub :validate, { errors: ['Nameserver ns3.example.com is not authoritative for domain'] } do + payload = { + nameservers: [ + { hostname: "ns3.example.com", + ipv4: ["192.168.1.1"], + ipv6: ["FE80::AEDE:48FF:FE00:1122"]} + ] + } + + post "/repp/v1/domains/#{@domain.name}/nameservers", params: payload, headers: @auth_headers + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :bad_request + assert_equal 2306, json[:code] + assert json[:message].include?('Nameserver ns3.example.com is not authoritative') + + @domain.reload + refute @domain.nameservers.where(hostname: 'ns3.example.com').any? + end + ensure + ENV.delete('DNS_VALIDATION_ENABLED') + end end diff --git a/test/integration/repp/v1/domains/update_test.rb b/test/integration/repp/v1/domains/update_test.rb index a91639c7a..093a2b338 100644 --- a/test/integration/repp/v1/domains/update_test.rb +++ b/test/integration/repp/v1/domains/update_test.rb @@ -121,4 +121,5 @@ class ReppV1DomainsUpdateTest < ActionDispatch::IntegrationTest assert_equal 2202, json[:code] assert_equal 'Invalid authorization information; invalid reserved>pw value', json[:message] end + end diff --git a/test/services/dns_validator_test.rb b/test/services/dns_validator_test.rb index dec46c7b8..568e74610 100644 --- a/test/services/dns_validator_test.rb +++ b/test/services/dns_validator_test.rb @@ -19,7 +19,7 @@ class DNSValidatorTest < ActiveSupport::TestCase # Basic functionality tests test 'initializes with correct structure' do - validator = DNSValidator.new(@domain) + validator = DNSValidator.new(domain: @domain, name: @domain.name, record_type: 'NS') assert_equal @domain, validator.domain assert_instance_of Hash, validator.results @@ -31,27 +31,19 @@ class DNSValidatorTest < ActiveSupport::TestCase end test 'class method validate creates instance' do - validator = DNSValidator.new(@domain) + result = { test: 'result' } - # Mock validate method to avoid DNS calls - validator.define_singleton_method(:validate) { { test: 'result' } } - - # Temporarily override new method - original_new = DNSValidator.method(:new) - DNSValidator.define_singleton_method(:new) { |domain| validator } - - result = DNSValidator.validate(@domain) - - assert_equal({ test: 'result' }, result) - ensure - # Restore original new method - DNSValidator.define_singleton_method(:new, original_new) + # Mock the class method directly + DNSValidator.stub :validate, result do + actual_result = DNSValidator.validate(domain: @domain, name: @domain.name, record_type: 'NS') + assert_equal result, actual_result + end end # Story 1: Nameserver Validation Tests test 'validates nameservers successfully' do - validator = DNSValidator.new(@domain) + validator = DNSValidator.new(domain: @domain, name: @domain.name, record_type: 'NS') # Mock successful nameserver validation validator.define_singleton_method(:validate_single_nameserver) do |ns| @@ -70,7 +62,7 @@ class DNSValidatorTest < ActiveSupport::TestCase end test 'handles failed nameserver validation' do - validator = DNSValidator.new(@domain) + validator = DNSValidator.new(domain: @domain, name: @domain.name, record_type: 'NS') # Mock failed nameserver validation validator.define_singleton_method(:validate_single_nameserver) do |ns| @@ -89,7 +81,7 @@ class DNSValidatorTest < ActiveSupport::TestCase end test 'validates single nameserver with mocked DNS' do - validator = DNSValidator.new(@domain) + validator = DNSValidator.new(domain: @domain, name: @domain.name, record_type: 'NS') # Create mock resolver resolver = create_mock_resolver @@ -123,7 +115,7 @@ class DNSValidatorTest < ActiveSupport::TestCase end test 'detects nameserver not in NS records' do - validator = DNSValidator.new(@domain) + validator = DNSValidator.new(domain: @domain, name: @domain.name, record_type: 'NS') resolver = create_mock_resolver # Pre-create responses @@ -150,7 +142,7 @@ class DNSValidatorTest < ActiveSupport::TestCase end test 'detects CNAME at apex' do - validator = DNSValidator.new(@domain) + validator = DNSValidator.new(domain: @domain, name: @domain.name, record_type: 'NS') resolver = create_mock_resolver # Pre-create CNAME response @@ -173,7 +165,7 @@ class DNSValidatorTest < ActiveSupport::TestCase # Story 2: DNS Records Validation Tests test 'validates DNS records from valid nameservers' do - validator = DNSValidator.new(@domain) + validator = DNSValidator.new(domain: @domain, name: @domain.name, record_type: 'A') # Set up nameserver validation results validator.instance_variable_set(:@results, { @@ -216,7 +208,7 @@ class DNSValidatorTest < ActiveSupport::TestCase end test 'skips DNS validation when no valid nameservers' do - validator = DNSValidator.new(@domain) + validator = DNSValidator.new(domain: @domain, name: @domain.name, record_type: 'A') # Set up scenario with no valid nameservers validator.instance_variable_set(:@results, { @@ -235,7 +227,7 @@ class DNSValidatorTest < ActiveSupport::TestCase # Story 4: DNSSEC Tests test 'processes CDS records for DNSSEC sync' do - validator = DNSValidator.new(@domain) + validator = DNSValidator.new(domain: @domain, name: @domain.name, record_type: 'DNSKEY') resolver = create_mock_resolver # Pre-create responses @@ -273,7 +265,7 @@ class DNSValidatorTest < ActiveSupport::TestCase # Ensure domain has enough nameservers @domain.nameservers.create!(hostname: 'backup.ns.com') - validator = DNSValidator.new(@domain) + validator = DNSValidator.new(domain: @domain, name: @domain.name, record_type: 'NS') # Initialize complete results structure validator.instance_variable_set(:@results, { @@ -300,7 +292,7 @@ class DNSValidatorTest < ActiveSupport::TestCase @nameserver1.update!(validation_counter: 3, failed_validation_reason: 'Failed validation') @nameserver2.destroy # Only one nameserver left - validator = DNSValidator.new(@domain) + validator = DNSValidator.new(domain: @domain, name: @domain.name, record_type: 'NS') validator.instance_variable_set(:@results, { nameservers: {}, @@ -323,7 +315,7 @@ class DNSValidatorTest < ActiveSupport::TestCase # Integration Tests test 'full validation workflow' do - validator = DNSValidator.new(@domain) + validator = DNSValidator.new(domain: @domain, name: @domain.name, record_type: 'all') # Mock all validation methods to avoid DNS calls validator.define_singleton_method(:validate_nameservers) { nil } @@ -342,7 +334,7 @@ class DNSValidatorTest < ActiveSupport::TestCase end test 'handles validation exceptions gracefully' do - validator = DNSValidator.new(@domain) + validator = DNSValidator.new(domain: @domain, name: @domain.name, record_type: 'NS') validator.define_singleton_method(:validate_nameservers) do raise StandardError.new('DNS timeout') @@ -356,7 +348,7 @@ class DNSValidatorTest < ActiveSupport::TestCase # Helper method tests test 'helper methods work correctly' do - validator = DNSValidator.new(@domain) + validator = DNSValidator.new(domain: @domain, name: @domain.name, record_type: 'NS') # Test parse_type_bitmap assert_equal ['NS', 'A'], validator.send(:parse_type_bitmap, ['NS', 'A']) @@ -378,7 +370,7 @@ class DNSValidatorTest < ActiveSupport::TestCase registrant: @domain.registrant ) - validator = DNSValidator.new(domain_without_ns) + validator = DNSValidator.new(domain: domain_without_ns, name: domain_without_ns.name, record_type: 'NS') assert_nothing_raised do validator.send(:validate_nameservers)