rodsec 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,152 @@
1
+ require_relative '../rodsec.rb'
2
+
3
+ module Rodsec
4
+ # Thanks to rack-contrib/deflect for the basic idea, and some of the docs.
5
+ class Rack
6
+ # === Required Options:
7
+ #
8
+ # :config Proc, or the directory containing the ModSecurity config files
9
+ # modsecurity.conf and crs-setup.conf. If it's a Proc, which
10
+ # must return a Rodsec::Ruleset instance containing all the
11
+ # rules you want.
12
+ #
13
+ # === Optional Options:
14
+ #
15
+ # :rules the directory containing the ModSecurity rules files.
16
+ # Defaults to ${config}/rules. Ignored if you pass a proc to config
17
+ #
18
+ # :logger must respond_to #puts which takes a string. Defaults to a StringIO at #logger
19
+ #
20
+ # :log_blk a callable that takes |tag,string| Defaults to sending
21
+ # only the string to logger. The ModSecurity logs are highly
22
+ # structured and you might want to parse them, so the tag
23
+ # helps disambiguate the source of the logs.
24
+ #
25
+ # ? :msi_blk called with [status, headers, body] if there's an intervention from ModSecurity.
26
+ #
27
+ #
28
+ # === Examples:
29
+ #
30
+ # use Rodsec::Rack, config: 'your_config_path', log: (mylogger = StringIO.new)
31
+ # use Rodsec::Rack, config: 'your_config_path', log_blk: -> src_class, str { my_funky_parse_msi_to_hash str }
32
+ def initialize app, config:, rules: nil, logger: nil, log_blk: nil
33
+ @app = app
34
+
35
+ @log_blk = log_blk || -> _tag, str{self.logger.puts str}
36
+ @msc = Rodsec::Modsec.new{|tag,str| @log_blk.call tag, str}
37
+
38
+ @logger = logger || StringIO.new
39
+
40
+ @log_blk.call self.class, "#{self.class} starting with #{@msc.version_info}"
41
+
42
+ set_rules config, rules
43
+ end
44
+
45
+ attr_reader :log_blk, :logger
46
+
47
+ include ReadConfig
48
+
49
+ protected def set_rules config, rules
50
+ case config
51
+ when Proc
52
+ @rules = config.call
53
+ else
54
+ @rules = read_config config, rules, &log_blk
55
+ end
56
+ end
57
+
58
+ REQUEST_URI = 'REQUEST_URI'.freeze
59
+ REMOTE_HOST = 'REMOTE_HOST'.freeze
60
+ REMOTE_ADDR = 'REMOTE_ADDR'.freeze
61
+ SERVER_NAME = 'SERVER_NAME'.freeze
62
+ HTTP_HOST = 'HTTP_HOST'.freeze
63
+ SERVER_PORT = 'SERVER_PORT'.freeze
64
+ HTTP_VERSION = 'HTTP_VERSION'.freeze
65
+ REQUEST_METHOD = 'REQUEST_METHOD'.freeze
66
+ SLASH = '/'.freeze
67
+ HTTP_HEADER_RX = /HTTP_(.*)|(CONTENT_.*)/.freeze
68
+ DASH = '-'.freeze
69
+ UNDERSCORE = '_'.freeze
70
+ EMPTY = String.new.freeze
71
+
72
+ RACK_INPUT = 'rack.input'.freeze
73
+
74
+ def call env
75
+ txn = Rodsec::Transaction.new @msc, @rules, txn_log_tag: env[REQUEST_URI]
76
+
77
+ ################
78
+ # incoming
79
+
80
+ # uri! scope for variables
81
+ lambda do
82
+ remote_addr = env[REMOTE_HOST] || env[REMOTE_ADDR]
83
+ server_addr = env[HTTP_HOST] || env[SERVER_NAME]
84
+ txn.connection! remote_addr, 0, server_addr, (env[SERVER_PORT] || 0)
85
+
86
+ _, version = env[HTTP_VERSION]&.split(SLASH)
87
+
88
+ txn.uri! env[REQUEST_URI], env[REQUEST_METHOD], version
89
+ end.call
90
+
91
+ # request_headers! - another scope for variables
92
+ lambda do
93
+ http_headers = env.map do |key,val|
94
+ key =~ HTTP_HEADER_RX or next
95
+ header_name = $1 || $2
96
+ dashified = header_name.split(UNDERSCORE).map(&:capitalize).join(DASH)
97
+ [dashified, val]
98
+ end.compact.to_h
99
+
100
+ txn.request_headers! http_headers
101
+ end.call
102
+
103
+ # request_body! MUST be called (even with an empty body is fine),
104
+ # otherwise ModSecurity never triggers the rules, even though ModSecurity
105
+ # can detect something dodgy in the headers. That needs what they call
106
+ # self-contained mode.
107
+ env[RACK_INPUT].tap do |rack_input|
108
+ # ruby-2.3 syntax :-|
109
+ begin
110
+ # What about a DOS from a very large body?
111
+ #
112
+ # Rack spec says rack.input must be rewindable at the http-server
113
+ # level, so it's all in memory by now anyway, nothing we can do to
114
+ # affect that here.
115
+ txn.request_body! rack_input
116
+ ensure
117
+ # Have to rewind input, otherwise other rack apps can't get the content
118
+ rack_input.rewind
119
+ end
120
+ end
121
+
122
+ ################
123
+ # rack chain
124
+ status, headers, body = @app.call env
125
+
126
+ ################
127
+ # outgoing
128
+ txn.response_headers! status, env[HTTP_VERSION], headers
129
+
130
+ # TODO handle hijacking? Not sure.
131
+ # body is an Enumerable, which response_body! will handle
132
+ txn.response_body! body
133
+
134
+ # Logging. From ModSecurity's point of view this could be in a separate
135
+ # thread. Dunno how rack will handle that though. Also, there's no way to
136
+ # wait for a thread doing that logging. So it would have to be spawned and
137
+ # then left to die. Alone. In the rain.
138
+ txn.logging
139
+
140
+ # all ok
141
+ return status, headers, body
142
+
143
+ rescue Rodsec::Intervention => iex
144
+ log_blk.call :intervention, iex.msi.log
145
+ # rack interface specification says we have to call close on the body, if
146
+ # it responds to close
147
+ body.respond_to?(:close) && body.close
148
+ # Intervention!
149
+ return iex.msi.status, {'Content-Type' => 'text/plain'}, [ ::Rack::Utils::HTTP_STATUS_CODES[iex.msi.status] ].compact
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,80 @@
1
+ module Rodsec
2
+ module ReadConfig
3
+ # log_blk takes a tag and a string
4
+ module_function def read_config config, rules, &log_blk
5
+ config_dir = Pathname config
6
+ rules_dir = Pathname(rules || config_dir + 'rules')
7
+
8
+ # NOTE the first two config files MUST be loaded before the rules files
9
+ config_rules = RuleSet.new
10
+ config_rules.add_file config_dir + 'modsecurity.conf'
11
+ config_rules.add_file config_dir + 'crs-setup.conf'
12
+
13
+ # Now load the rules files
14
+ rules_files = rules_dir.children.select{|p| p.to_s =~ /.*conf$/}.sort
15
+
16
+ # merge rules files.
17
+ rules_files.reduce config_rules do |ax, fn|
18
+ # ruby 2.3.x syntax :-|
19
+ begin
20
+ log_blk&.call self.class, "loading rules file: #{fn}"
21
+ rules = RuleSet.new tag: fn
22
+ rules.add_file fn
23
+ ax.merge rules
24
+ rescue
25
+ log_blk&.call $!.class, "error loading rules file: #{$!}"
26
+ ax
27
+ end
28
+ end
29
+ end
30
+
31
+ # Hacky Workaround for the bug that's tickled by merging Rules.
32
+ #
33
+ # What we do is read all the files separately. Check each one to see that it
34
+ # has no syntax errors. If that works, append the file contents to one giant
35
+ # string containing all the rule files. When we've read all the rule files,
36
+ # create one RuleSet from the combined string. That way we don't need to
37
+ # merge rulesets.
38
+ module_function def read_combined_config config, rules, &log_blk
39
+ # part of the hacky workaround, so it's self-contained when we remove it.
40
+ require 'stringio'
41
+ config_dir = Pathname config
42
+ rules_dir = Pathname(rules || config_dir + 'rules')
43
+
44
+ # NOTE the first two config files MUST be loaded before the rules files
45
+ files = [(config_dir + 'modsecurity.conf'), (config_dir + 'crs-setup.conf')]
46
+
47
+ # Now add the rules files
48
+ files.concat rules_dir.children.select{|p| p.to_s =~ /.*conf$/}.sort
49
+
50
+ # merge rules files.
51
+ combined_rules = files.each_with_object StringIO.new do |fn, sio|
52
+ begin
53
+ log_blk&.call self.class, "loading rules file: #{fn}"
54
+
55
+ # syntax check rule set
56
+ RuleSet.new.add_file fn.to_s
57
+
58
+ File.open fn do |io| IO.copy_stream io, sio end
59
+ rescue
60
+ log_blk&.call $!.class, "error loading rules file: #{$!}"
61
+ end
62
+ end
63
+
64
+ # make sure the rules can access their *.data files - we lose the file
65
+ # location information when we use this approach.
66
+ save_dir = Dir.pwd
67
+ Dir.chdir rules_dir
68
+
69
+ # add the combined rules
70
+ log_blk&.call self.class, 'loading combined rules'
71
+ p size: combined_rules.string.length
72
+ rules = RuleSet.new
73
+ rules.add combined_rules.string
74
+ rules
75
+ ensure
76
+ # restore original directory
77
+ save_dir and Dir.chdir save_dir
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,69 @@
1
+ require 'pathname'
2
+
3
+ require_relative 'wrapper'
4
+ require_relative 'string_pointers'
5
+
6
+ module Rodsec
7
+ class RuleSet
8
+ def initialize tag: nil
9
+ @tag = tag
10
+
11
+ @rules_ptr = Wrapper.msc_create_rules_set
12
+ @rules_ptr.free = Wrapper['msc_rules_cleanup']
13
+
14
+ # just mirroring the c-api, not sure if it's actually useful
15
+ @rule_count = 0
16
+ end
17
+
18
+ # srsly, don't mess with this
19
+ attr_reader :rules_ptr
20
+
21
+ attr_reader :tag, :rule_count
22
+
23
+ include StringPointers
24
+
25
+ # add rules from the given file
26
+ # return number of rules added? I think?
27
+ def add_file conf_pathname
28
+ conf_pathname = Pathname conf_pathname
29
+ err = Fiddle::Pointer[0]
30
+ rv = Wrapper.msc_rules_add_file rules_ptr, (strptr conf_pathname.realpath.to_s), err.ref
31
+
32
+ raise Error, [conf_pathname, err.to_s] if rv < 0
33
+ @rule_count += rv
34
+ self
35
+ end
36
+
37
+ # dump rules to stdout. No way to redirect that, from what I can see.
38
+ def dump
39
+ Wrapper.msc_rules_dump rules_ptr
40
+ self
41
+ end
42
+
43
+ def add rules_text
44
+ err = Fiddle::Pointer[0]
45
+ rv = Wrapper.msc_rules_add rules_ptr, (strptr rules_text.to_s), err.ref
46
+ raise Error, err.to_s if rv < 0
47
+ @rule_count += rv
48
+ self
49
+ end
50
+
51
+ def add_url key, url
52
+ err = Fiddle::Pointer[0]
53
+ rv = Wrapper.msc_rules_add_remote rules_ptr, (strptr key), (strptr uri), err.ref
54
+ raise Error, err.to_s if rv < 0
55
+ @rule_count += rv
56
+ self
57
+ end
58
+
59
+ # merge other rules with self
60
+ def merge other
61
+ raise "must be a #{self.class.name}" unless self.class === other
62
+ err = Fiddle::Pointer[0]
63
+ rv = Wrapper.msc_rules_merge rules_ptr, other.rules_ptr, err.ref
64
+ @rule_count += rv
65
+ raise Error, err.to_s if rv < 0
66
+ self
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,10 @@
1
+ module Rodsec
2
+ module StringPointers
3
+ EMPTY_STRING = String.new.freeze
4
+
5
+ def strptr str
6
+ # nil often causes ModSecurity to segfault
7
+ str || EMPTY_STRING
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,168 @@
1
+ require_relative 'wrapper'
2
+ require_relative 'string_pointers'
3
+
4
+ module Rodsec
5
+ class Transaction
6
+ # txn_log_tag must be convertible to a string. Defaults to self.object_id.to_s
7
+ # it shows up in as the first argument passed to Modsec's log_blk.
8
+ def initialize msc, ruleset, txn_log_tag: nil
9
+ raise Error, "msc must be a #{Modsec}" unless Modsec === msc
10
+ raise Error, "ruleset must be a #{RuleSet}" unless RuleSet === ruleset
11
+
12
+ @msc, @ruleset = msc, ruleset
13
+ @txn_log_tag = Fiddle::Pointer[(txn_log_tag || object_id).to_s]
14
+ @txn_ptr = Wrapper.msc_new_transaction msc.msc_ptr, ruleset.rules_ptr, @txn_log_tag
15
+ @txn_ptr.free = Wrapper['msc_transaction_cleanup']
16
+ end
17
+
18
+ attr_reader :txn_ptr
19
+
20
+ attr_reader :msc, :ruleset
21
+
22
+ include StringPointers
23
+
24
+ # Raise an Intervention(ModSecurityIntervention) if necessary, or return self.
25
+ #
26
+ # ModSecurity will only populate the intervention structure if it detects
27
+ # something 'disruptive' in the SecRules.
28
+ protected def intervention!
29
+ # Check for Intervention
30
+ msi = Wrapper::ModSecurityIntervention.new Wrapper.msc_new_intervention
31
+ rv = Wrapper.msc_intervention txn_ptr, msi
32
+ raise Intervention, msi if rv > 0
33
+ self
34
+ end
35
+
36
+ ##################################
37
+ # Phase CONNECTION / SecRules 0
38
+ # check for intervention afterwards
39
+ def connection! client_host, client_port, server_host, server_port
40
+ rv = Wrapper.msc_process_connection \
41
+ txn_ptr,
42
+ (strptr client_host), (Integer client_port),
43
+ (strptr server_host), (Integer server_port)
44
+
45
+ rv == 1 or raise Error, "msc_process_connection failed for #{[client_host, client_port, server_host, server_port].inspect}"
46
+
47
+ intervention!
48
+ end
49
+
50
+ ##################################
51
+ # Phase URI / 1.5
52
+ # check for intervention afterwards
53
+ # verb is GET POST etc
54
+ # http_version is '1.1', '1.2' etc
55
+ def uri! uri, verb, http_version
56
+ rv = Wrapper.msc_process_uri txn_ptr, (strptr uri), (strptr verb), (strptr http_version)
57
+ rv == 1 or raise Error "msc_process_uri failed for #{[uri, verb, http_version].inspect}"
58
+
59
+ intervention!
60
+ end
61
+
62
+ ##################################
63
+ # Phase REQUEST_HEADERS. SecRules 1
64
+ def request_headers! header_hash
65
+ errors = header_hash.each_with_object [] do |(key, val), errors|
66
+ key = key.to_s; val = val.to_s
67
+ rv = Wrapper.msc_add_n_request_header txn_ptr, (strptr key), key.bytesize, (strptr val), val.bytesize
68
+ rv == 1 or errors << "msc_add_n_request_header failed adding #{[key,val].inspect}"
69
+ end
70
+
71
+ raise Error errors if errors.any?
72
+
73
+ rv = Wrapper.msc_process_request_headers txn_ptr
74
+ rv == 1 or raise "msc_process_request_headers failed"
75
+
76
+ intervention!
77
+ end
78
+
79
+ protected def enum_of_body body
80
+ case
81
+ when NilClass === body
82
+ ['']
83
+ when String === body
84
+ [body]
85
+ when body.respond_to?(:each)
86
+ body
87
+ else
88
+ raise "dunno about #{body}"
89
+ end
90
+ end
91
+
92
+ ##################################
93
+ # Phase REQUEST_BODY. SecRules 2
94
+ #
95
+ # body can be a String, or an Enumerable of strings
96
+ def request_body! body
97
+ enum_of_body(body).each do |body_part|
98
+ body_part = body_part.to_s
99
+ rv = Wrapper.msc_append_request_body txn_ptr, (strptr body_part), body_part.bytesize
100
+ rv == 1 or raise Error, "msc_append_request_body failed"
101
+ end
102
+
103
+ # This MUST be called, otherwise rules aren't triggered.
104
+ rv = Wrapper.msc_process_request_body txn_ptr
105
+ rv == 1 or raise Error, "msc_process_request_body failed"
106
+
107
+ intervention!
108
+ end
109
+
110
+ # This is probably only used when appending a body in chunks. We don't use it.
111
+ # extern 'size_t msc_get_request_body_length(Transaction *transaction)'
112
+
113
+ ##################################
114
+ # Phase RESPONSE_HEADERS. SecRules 3
115
+ # http_status_code is one of the 200, 401, 404 etc codes
116
+ # http_with_version seems to be things like 'HTTP 1.2', not entirely sure.
117
+ def response_headers! http_status_code = 200, http_with_version = 'HTTP 1.1', header_hash
118
+ errors = header_hash.each_with_object [] do |(key, val), errors|
119
+ key = key.to_s; val = val.to_s
120
+ rv = Wrapper.msc_add_n_response_header txn_ptr, (strptr key), key.bytesize, (strptr val), val.bytesize
121
+ rv == 1 or errors << "msc_add_n_response_header failed adding #{[key,val].inspect}"
122
+ end
123
+
124
+ raise Error, errors if errors.any?
125
+
126
+ rv = Wrapper.msc_process_response_headers txn_ptr, (Integer http_status_code), (strptr http_with_version)
127
+ rv == 1 or raise "msc_process_response_headers failed"
128
+
129
+ intervention!
130
+ end
131
+
132
+ # Called after msc_process_response_headers "to inform a new response code"
133
+ # Not mandatory. Not sure what it means really. Maybe it affects the intervention values?
134
+ # extern 'int msc_update_status_code(Transaction *transaction, int status)'
135
+
136
+ ##################################
137
+ # Phase RESPONSE_BODY. SecRules 4
138
+ #
139
+ # body can be a String, or an Enumerable of strings
140
+ def response_body! body
141
+ enum_of_body(body).each do |body_part|
142
+ body_part = body_part.to_s
143
+ rv = Wrapper.msc_append_response_body txn_ptr, (strptr body_part), body_part.bytesize
144
+ rv == 1 or raise Error, 'msc_append_response_body failed'
145
+ end
146
+
147
+ # This MUST be called, otherwise rules aren't triggered
148
+ rv = Wrapper.msc_process_response_body txn_ptr
149
+ rv == 1 or raise Error, "msc_process_response_body failed"
150
+
151
+ intervention!
152
+ end
153
+
154
+ # Needed if ModSecurity modifies the outgoing body. We don't make use of that.
155
+ # extern 'const char *msc_get_response_body(Transaction *transaction)'
156
+ # extern 'size_t msc_get_response_body_length(Transaction *transaction)'
157
+
158
+ ##################################
159
+ # Phase LOGGING. SecRules 5.
160
+ # just logs all information. Response can be sent prior to this, or concurrently.
161
+ def logging
162
+ rv = Wrapper.msc_process_logging txn_ptr
163
+ rv == 1 or raise 'msc_process_logging failed'
164
+
165
+ self
166
+ end
167
+ end
168
+ end