Skip to content

Commit adac39a

Browse files
feat: add new URL-related validation args (public, https_only, no_ip) (#24)
* New rule: domain-only URLs (no IP addresses allowed) * test: add not_ip_url rule test cases * feat: add URL validation modifiers (public, https_only, no_ip) Adds three independent URL validation rules: - public: validates against real public domains - https_only: enforces HTTPS protocol - no_ip: blocks direct IP addresses * `not_ip_url.rb` + `not_local_url.rb` rules have been deleted * feat: add URL validation modifiers (public, https_only, no_ip) Adds three independent URL validation rules: - public: validates against real public domains - https_only: enforces HTTPS protocol - no_ip: blocks direct IP addresses * `not_ip_url.rb` + `not_local_url.rb` rules have been deleted * refactor(url): enhance URL rule validation with domain checks + private network check - Use proper public domain (`public_suffix`) validation checking both structure and access - Add pattern loading with memoization for private/reserved domains - Fix error message consistency across validation scenarios - Add tests for validation scenarios * Restructured private patterns based on trustworthy srcs + some tests
1 parent 1e24e48 commit adac39a

File tree

6 files changed

+313
-76
lines changed

6 files changed

+313
-76
lines changed
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# RFC 2606 - Reserved Domain Names
2+
# https://datatracker.ietf.org/doc/html/rfc2606
3+
*.test
4+
*.example
5+
*.invalid
6+
localhost
7+
*.localhost
8+
example.com
9+
example.net
10+
example.org
11+
12+
# RFC 6761 - Special-Use Domain Names
13+
# https://datatracker.ietf.org/doc/html/rfc6761
14+
*.local
15+
*.onion
16+
*.home.arpa
17+
18+
# RFC 1918 - Private Address Space
19+
# https://datatracker.ietf.org/doc/html/rfc1918
20+
10.*
21+
172.(1[6-9]|2[0-9]|3[0-1]).*
22+
192.168.*
23+
24+
# RFC 3330 - Special-Use IPv4 Addresses
25+
# https://datatracker.ietf.org/doc/html/rfc3330
26+
127.*
27+
169.254.*
28+
0.0.0.0
29+
30+
# RFC 4291 - IPv6 Addressing Architecture
31+
# https://datatracker.ietf.org/doc/html/rfc4291
32+
::1
33+
fe80:*
34+
::
35+
::ffff:*
36+
37+
# RFC 4193 - Unique Local IPv6 Unicast Addresses
38+
# https://datatracker.ietf.org/doc/html/rfc4193
39+
fc00:*
40+
fd00:*
41+
42+
# Common Internal Network Patterns (based on RFC 2606 and 6761)
43+
*.intranet
44+
*.internal
45+
*.corp
46+
*.lan
47+
intranet.*
48+
internal.*
49+
corp.*
50+
lan.*
51+
52+
# Development Environments (based on RFC 2606)
53+
*.dev.test
54+
*.staging.test
55+
*.qa.test
56+
dev.example.*
57+
staging.example.*
58+
qa.example.*
59+
60+
# RFC 6052 - IPv6 Translation
61+
64:ff9b::*
62+
63+
# RFC 3986 - URI Encoded Forms
64+
%6C%6F%63%61%6C%68%6F%73%74

lib/mini_defender/rules/not_local_url.rb

Lines changed: 0 additions & 31 deletions
This file was deleted.

lib/mini_defender/rules/url.rb

Lines changed: 115 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,130 @@
11
# frozen_string_literal: true
22

33
require 'uri'
4+
require 'ipaddr'
5+
require 'public_suffix'
46

7+
# TODO: ensure rescues
58
class MiniDefender::Rules::Url < MiniDefender::Rule
9+
ALLOWED_MODIFIERS = %w[https public not_ip not_private]
10+
11+
def initialize(modifiers = [])
12+
@modifiers = Array(modifiers).map(&:to_s)
13+
14+
unless @modifiers.empty?
15+
validate_modifiers!
16+
end
17+
18+
@validation_error = "URL modifiers list contains only #{ALLOWED_MODIFIERS.join(', ')}."
19+
end
20+
621
def self.signature
722
'url'
823
end
924

25+
def self.make(modifiers) # no need to raise an error when no modifier is entered; as 'url' rule checks URL structure on its own
26+
new(modifiers)
27+
end
28+
1029
def passes?(attribute, value, validator)
11-
value.is_a?(String) && URI.regexp(%w[http https]).match?(value)
30+
# TODO: warning: URI.regexp is obsolete; use URI::DEFAULT_PARSER.make_regexp instead
31+
unless value.is_a?(String) && URI::DEFAULT_PARSER.make_regexp(%w[http https]).match?(value)
32+
return false
33+
end
34+
35+
begin
36+
uri = URI.parse(value)
37+
38+
if @modifiers.empty?
39+
return true
40+
end
41+
42+
if @modifiers.include?('https') && uri.scheme != 'https'
43+
@validation_error = 'The URL must use HTTPS.'
44+
return false
45+
end
46+
47+
if @modifiers.include?('public') && (!PublicSuffix.valid?(uri.host) || self.class.private_network?(uri.host))
48+
@validation_error = 'The URL must use a valid public domain.'
49+
return false
50+
end
51+
52+
if @modifiers.include?('not_ip') && ip_address?(uri.host)
53+
@validation_error = 'IP addresses are not allowed in URLs.'
54+
return false
55+
end
56+
57+
if @modifiers.include?('not_private') && self.class.private_network?(uri.host)
58+
@validation_error = 'Private or reserved resources are not allowed.'
59+
return false
60+
end
61+
62+
true
63+
rescue URI::InvalidURIError
64+
@validation_error = 'The field must contain a valid URL.'
65+
false
66+
rescue PublicSuffix::Error
67+
false
68+
end
69+
end
70+
71+
def self.private_network?(host)
72+
unless host
73+
return false
74+
end
75+
76+
host = host.downcase
77+
78+
private_patterns.any? { |pattern| pattern.match?(host) }
1279
end
1380

1481
def message(attribute, value, validator)
15-
'The field must contain a valid URL.'
82+
@validation_error || 'The field must contain a valid URL.'
83+
end
84+
85+
private
86+
87+
def validate_modifiers!
88+
invalid_modifiers = @modifiers - ALLOWED_MODIFIERS
89+
if invalid_modifiers.empty?
90+
return
91+
end
92+
93+
raise ArgumentError, "Invalid URL modifiers: #{invalid_modifiers.join(', ')}"
94+
end
95+
96+
def ip_address?(host)
97+
unless host
98+
return false
99+
end
100+
101+
begin
102+
IPAddr.new(host)
103+
true
104+
rescue IPAddr::InvalidAddressError
105+
false
106+
end
107+
end
108+
109+
def self.private_patterns
110+
@private_patterns ||= begin
111+
pattern_file = File.expand_path('../data/private_network_patterns.txt', __dir__)
112+
File.readlines(pattern_file).filter_map do |line|
113+
line = line.strip
114+
115+
if line.empty? || line.start_with?('#')
116+
next
117+
end
118+
119+
# Pattern => regex (once)
120+
pattern = line
121+
.gsub('.', '\.') # escape dots
122+
.gsub('*', '.*') # wildcards => regex
123+
.gsub('[0-9]+', '\d+') # convert number ranges
124+
.gsub(/\[(.+?)\]/, '(\1)') # convert chars classes
125+
126+
Regexp.new("^#{pattern}$", Regexp::IGNORECASE)
127+
end
128+
end
16129
end
17130
end

mini_defender.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,5 @@ Gem::Specification.new do |spec|
3434
spec.add_runtime_dependency 'countries'
3535
spec.add_runtime_dependency 'money'
3636
spec.add_runtime_dependency 'marcel'
37+
spec.add_runtime_dependency 'public_suffix'
3738
end

test/rules/not_local_url_test.rb

Lines changed: 0 additions & 43 deletions
This file was deleted.

0 commit comments

Comments
 (0)