rodsec 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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