unicorn-lockdown 0.12.0 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,25 +1,38 @@
1
- # unicorn-lockdown is designed to be used with Unicorn's chroot support, and
2
- # handles:
1
+ # unicorn-lockdown is designed to handle fork+exec, unveil, and pledge support
2
+ # when using Unicorn, including:
3
+ # * restricting file system access using unveil
3
4
  # * pledging the app to restrict allowed syscalls at the appropriate point
4
- # * handling notifications of worker crashes
5
- # * forcing loading of some common autoloaded constants
6
- # * stripping path prefixes from the reloader in development mode
5
+ # * handling notifications of worker crashes (which are likely due to pledge
6
+ # violations)
7
7
 
8
8
  require 'pledge'
9
+ require 'unveil'
9
10
 
10
- # Loading single_byte encoding
11
+ # Load common encodings
11
12
  "\255".force_encoding('ISO8859-1').encode('UTF-8')
12
-
13
- # Load encodings
14
13
  ''.force_encoding('UTF-16LE')
15
14
  ''.force_encoding('UTF-16BE')
16
15
 
17
16
  class Unicorn::HttpServer
18
- # The file name in which to store request information. The
19
- # /var/www/requests folder is currently accessable only
20
- # to root.
17
+ # The file name in which to store request information.
18
+ # The /var/www/request-error-data/$app_name folder is accessable
19
+ # only to the user of the application.
21
20
  def request_filename(pid)
22
- "/var/www/requests/#{Unicorn.app_name}.#{pid}.txt"
21
+ "#{Unicorn.unicorn_lockdown_prefix}/www/request-error-data/#{Unicorn.app_name}/#{pid}.txt"
22
+ end
23
+
24
+ unless ENV['UNICORN_WORKER']
25
+ alias _original_spawn_missing_workers spawn_missing_workers
26
+
27
+ # This is the master process, set the master pledge before spawning
28
+ # workers, because spawning workers will also need to be done at runtime.
29
+ def spawn_missing_workers
30
+ if pledge = Unicorn.master_pledge
31
+ Unicorn.master_pledge = nil
32
+ Pledge.pledge(pledge, Unicorn.master_execpledge)
33
+ end
34
+ _original_spawn_missing_workers
35
+ end
23
36
  end
24
37
 
25
38
  # Override the process name for the unicorn processes, both master and
@@ -49,23 +62,32 @@ class << Unicorn
49
62
  # to enable programmers to debug and fix the issue.
50
63
  attr_accessor :request_logger
51
64
 
52
- # The user to run as. Also specifies the group to run as if group_name is not set.
53
- attr_accessor :user_name
65
+ # The pledge string to use for the master process's spawned processes by default.
66
+ attr_accessor :master_execpledge
54
67
 
55
- # The group name to run as. Can be an array of two strings, where the first string
56
- # is the primary group, and the second string is the group used for the log files.
57
- attr_accessor :group_name
68
+ # The pledge string to use for the master process.
69
+ attr_accessor :master_pledge
58
70
 
59
- # The pledge string to use.
71
+ # The pledge string to use for worker processes.
60
72
  attr_accessor :pledge
61
73
 
62
- # The address to email for crash and unhandled exception notifications
74
+ # The hash of unveil paths to use.
75
+ attr_accessor :unveil
76
+
77
+ # The hash of additional unveil paths to use if in the development environment.
78
+ attr_accessor :dev_unveil
79
+
80
+ # The address to email for crash and unhandled exception notifications.
63
81
  attr_accessor :email
64
82
 
83
+ # The prefix for unicorn lockdown files
84
+ attr_reader :unicorn_lockdown_prefix
85
+ Unicorn.instance_variable_set(:@unicorn_lockdown_prefix, ENV['UNICORN_LOCKDOWN_PREFIX'] || '/var')
86
+
65
87
  # Helper method to write request information to the request logger.
66
88
  # +email_message+ should be an email message including headers and body.
67
89
  # This should be called at the top of the Roda route block for the
68
- # application.
90
+ # application (or at some early point before processing in other web frameworks).
69
91
  def write_request(email_message)
70
92
  request_logger.seek(0, IO::SEEK_SET)
71
93
  request_logger.truncate(0)
@@ -73,28 +95,33 @@ class << Unicorn
73
95
  request_logger.fsync
74
96
  end
75
97
 
76
- # Helper method that sets up all necessary code for chroot/pledge support.
98
+ # Helper method that sets up all necessary code for unveil/pledge support.
77
99
  # This should be called inside the appropriate unicorn.conf file.
78
100
  # The configurator should be self in the top level scope of the
79
101
  # unicorn.conf file, and this takes options:
80
102
  #
81
103
  # Options:
82
- # :app :: The name of the application (required)
104
+ # :app (required) :: The name of the application
83
105
  # :email : The email to notify for worker crashes
84
- # :user :: The user to run as (required)
85
- # :group :: The group to run as (if not set, uses :user as the group).
86
- # Can be an array of two strings, where the first string is the primary
87
- # group, and the second string is the group used for the log files.
88
- # :pledge :: The string to use when pledging
106
+ # :pledge :: The string to use when pledging worker processes after loading the app
107
+ # :master_pledge :: The string to use when pledging the master process before
108
+ # spawning worker processes
109
+ # :master_execpledge :: The pledge string for processes spawned by the master
110
+ # process (i.e. worker processes before loading the app)
111
+ # :unveil :: A hash of unveil paths, passed to Pledge.unveil.
112
+ # :dev_unveil :: A hash of unveil paths to use in development, in addition
113
+ # to the ones in :unveil.
89
114
  def lockdown(configurator, opts)
90
115
  Unicorn.app_name = opts.fetch(:app)
91
- Unicorn.user_name = opts.fetch(:user)
92
- Unicorn.group_name = opts[:group] || opts[:user]
93
116
  Unicorn.email = opts[:email]
117
+ Unicorn.master_pledge = opts[:master_pledge]
118
+ Unicorn.master_execpledge = opts[:master_execpledge]
94
119
  Unicorn.pledge = opts[:pledge]
120
+ Unicorn.unveil = opts[:unveil]
121
+ Unicorn.dev_unveil = opts[:dev_unveil]
95
122
 
96
123
  configurator.instance_exec do
97
- listen "/var/www/sockets/#{Unicorn.app_name}.sock"
124
+ listen "#{Unicorn.unicorn_lockdown_prefix}/www/sockets/#{Unicorn.app_name}.sock"
98
125
 
99
126
  # Buffer all client bodies in memory. This assumes an Nginx limit of 10MB,
100
127
  # by using 11MB this ensures that client bodies are always buffered in
@@ -105,23 +132,25 @@ class << Unicorn
105
132
  # Run all worker processes with unique memory layouts
106
133
  worker_exec true
107
134
 
135
+ # :nocov:
108
136
  # Only change the log path if daemonizing.
109
137
  # Otherwise, continue to log to stdout/stderr.
110
138
  if Unicorn::Configurator::RACKUP[:daemonize]
111
- stdout_path "/var/log/unicorn/#{Unicorn.app_name}.log"
112
- stderr_path "/var/log/unicorn/#{Unicorn.app_name}.log"
139
+ log_path = "#{Unicorn.unicorn_lockdown_prefix}/log/unicorn/#{Unicorn.app_name}.log"
140
+ stdout_path log_path
141
+ stderr_path log_path
113
142
  end
143
+ # :nocov:
114
144
 
115
145
  after_fork do |server, worker|
116
146
  server.logger.info("worker=#{worker.nr} spawned pid=#{$$}")
117
147
 
118
- # Set the request logger for the worker process after forking. The
119
- # process is still root here, so it can open the file in write mode.
148
+ # Set the request logger for the worker process after forking.
120
149
  Unicorn.request_logger = File.open(server.request_filename($$), "wb")
121
150
  Unicorn.request_logger.sync = true
122
151
  end
123
152
 
124
- if wrap_app = Unicorn.email && ENV['RACK_ENV'] == 'production'
153
+ if wrap_app = Unicorn.email
125
154
  require 'rack/email_exceptions'
126
155
  end
127
156
 
@@ -137,55 +166,31 @@ class << Unicorn
137
166
  end
138
167
  end
139
168
 
140
- # Before chrooting, reference all constants that use autoload
141
- # that are probably needed at runtime. This must be done
142
- # before chrooting as attempting to load the constants after
143
- # chrooting will break things.
144
-
145
- # Start with rack, which uses autoload for all constants.
146
- # Most of rack's constants are not used at runtime, this
147
- # lists the ones most commonly needed.
148
- Rack::Multipart
149
- Rack::Multipart::Parser
150
- Rack::Multipart::Generator
151
- Rack::Multipart::UploadedFile
152
- Rack::Mime
153
- Rack::Auth::Digest::Params
154
-
155
- # In the development environment, reference all middleware
156
- # the unicorn will load by default, unless unicorn is
157
- # set to not load middleware by default.
158
- if ENV['RACK_ENV'] == 'development' && (!respond_to?(:set) || set[:default_middleware] != false)
159
- Rack::ContentLength
160
- Rack::CommonLogger
161
- Rack::Chunked
162
- Rack::Lint
163
- Rack::ShowExceptions
164
- Rack::TempfileReaper
169
+ unveil = if Unicorn.dev_unveil && ENV['RACK_ENV'] == 'development'
170
+ Unicorn.unveil.merge(Unicorn.dev_unveil)
171
+ else
172
+ Hash[Unicorn.unveil]
165
173
  end
166
174
 
167
- # If using the mail library, eagerly autoload all constants.
168
- # This costs about 9MB of memory, but the mail gem changes
169
- # their autoloaded constants on a regular basis, so it's
170
- # better to be safe than sorry.
171
- if defined?(Mail)
172
- Mail.eager_autoload!
173
- end
175
+ # Don't allow loading files in rack and mail gems if not using rubygems
176
+ if defined?(Gem) && Gem.respond_to?(:loaded_specs)
177
+ # Allow read access to the rack gem directory, as rack autoloads constants.
178
+ if defined?(Rack) && Gem.loaded_specs['rack']
179
+ unveil['rack'] = :gem
180
+ end
174
181
 
175
- # Strip path prefixes from the reloader. This is only
176
- # really need in development mode for code reloading to work.
177
- pwd = Dir.pwd
178
- Unreloader.strip_path_prefix(pwd) if defined?(Unreloader)
182
+ # If using the mail library, allow read access to the mail gem directory,
183
+ # as mail autoloads constants.
184
+ if defined?(Mail) && Gem.loaded_specs['mail']
185
+ unveil['mail'] = :gem
186
+ end
187
+ end
179
188
 
180
- # Drop privileges. This must be done after chrooting as
181
- # chrooting requires root privileges.
182
- worker.user(Unicorn.user_name, Unicorn.group_name, pwd)
189
+ # Restrict access to the file system based on the specified unveil.
190
+ Pledge.unveil(unveil)
183
191
 
184
- if Unicorn.pledge
185
- # Pledge after dropping privileges, because dropping
186
- # privileges requires a separate pledge.
187
- Pledge.pledge(Unicorn.pledge)
188
- end
192
+ # Pledge after unveiling, because unveiling requires a separate pledge.
193
+ Pledge.pledge(Unicorn.pledge)
189
194
  end
190
195
 
191
196
  # the last time there was a worker crash and the request information
@@ -227,33 +232,16 @@ class << Unicorn
227
232
  unless skip_email
228
233
  # If the request filename exists and the worker process crashed,
229
234
  # send a notification email.
230
- Process.waitpid(fork do
231
- # Load net/smtp early, before chrooting.
235
+ Process.waitpid(Process.fork do
236
+ # Load net/smtp early
232
237
  require 'net/smtp'
233
238
 
234
239
  # When setting the email, first get the contents of the email
235
240
  # from the request file.
236
241
  body = File.read(file)
237
242
 
238
- # Then get information from /etc and drop group privileges
239
- uid = Etc.getpwnam(Unicorn.user_name).uid
240
- group = Unicorn.group_name
241
- group = group.first if group.is_a?(Array)
242
- gid = Etc.getgrnam(group).gid
243
- if gid && Process.egid != gid
244
- Process.initgroups(Unicorn.user_name, gid)
245
- Process::GID.change_privilege(gid)
246
- end
247
-
248
- # Then chroot
249
- Dir.chroot(Dir.pwd)
250
- Dir.chdir('/')
251
-
252
- # Then drop user privileges
253
- Process.euid != uid and Process::UID.change_privilege(uid)
254
-
255
243
  # Then use a restrictive pledge
256
- Pledge.pledge('inet prot_exec')
244
+ Pledge.pledge(ENV['UNICORN_LOCKDOWN_WORKER_CRASH_PLEDGE'] || 'inet prot_exec')
257
245
 
258
246
  # If body empty, crash happened before a request was received,
259
247
  # try to at least provide the application name in this case.
@@ -261,8 +249,13 @@ class << Unicorn
261
249
  body = "Subject: [#{Unicorn.app_name}] Unicorn Worker Process Crash\r\n\r\nNo email content provided for app: #{Unicorn.app_name}"
262
250
  end
263
251
 
252
+ # :nocov:
253
+ # Don't verify localhost hostname, to avoid SSL errors raised in newer versions of net/smtp
254
+ smtp_params = Net::SMTP.method(:start).parameters.include?([:key, :tls_verify]) ? {tls_verify: false, tls_hostname: 'localhost'} : {}
255
+ # :nocov:
256
+
264
257
  # Finally send an email to localhost via SMTP.
265
- Net::SMTP.start('127.0.0.1'){|s| s.send_message(body, Unicorn.email, Unicorn.email)}
258
+ Net::SMTP.start('127.0.0.1', **smtp_params){|s| s.send_message(body, Unicorn.email, Unicorn.email)}
266
259
  end)
267
260
  end
268
261
  end
@@ -274,4 +267,3 @@ class << Unicorn
274
267
  end
275
268
  end
276
269
  end
277
-
data/lib/unveiler.rb ADDED
@@ -0,0 +1,42 @@
1
+ require 'pledge'
2
+ require 'unveil'
3
+
4
+ # Load encodings
5
+ "\255".force_encoding('ISO8859-1').encode('UTF-8')
6
+ ''.force_encoding('UTF-16LE')
7
+ ''.force_encoding('UTF-16BE')
8
+
9
+ # Don't run external diff program for failures
10
+ Minitest::Assertions.diff = false if defined?(Minitest::Assertions)
11
+
12
+ # Unveiler allows for testing programs using pledge and unveil.
13
+ module Unveiler
14
+ # Use Pledge.unveil to limit access to the file system based on the
15
+ # +unveil+ argument. Then pledge the process with the given +pledge+
16
+ # permissions. This will automatically unveil the rack and mail gems
17
+ # if they are loaded.
18
+ def self.pledge_and_unveil(pledge, unveil)
19
+ unveil = Hash[unveil]
20
+
21
+ if defined?(Gem) && Gem.respond_to?(:loaded_specs)
22
+ if defined?(Rack) && Gem.loaded_specs['rack']
23
+ unveil['rack'] = :gem
24
+ end
25
+ if defined?(Mail) && Gem.loaded_specs['mail']
26
+ unveil['mail'] = :gem
27
+ end
28
+ end
29
+
30
+ Pledge.unveil(unveil)
31
+
32
+ # :nocov:
33
+ if defined?(SimpleCov)
34
+ # :nocov:
35
+ # If running coverage tests, add necessary pledges for
36
+ # coverage testing to work.
37
+ pledge = (pledge.split + %w'rpath wpath cpath flock').uniq.join(' ')
38
+ end
39
+
40
+ Pledge.pledge(pledge)
41
+ end
42
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: unicorn-lockdown
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeremy Evans
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2019-04-29 00:00:00.000000000 Z
11
+ date: 2022-07-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: pledge
@@ -38,6 +38,62 @@ dependencies:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
40
  version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rack
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: mail
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: roda
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: minitest-global_expectations
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
41
97
  description:
42
98
  email: code@jeremyevans.net
43
99
  executables:
@@ -52,14 +108,20 @@ files:
52
108
  - bin/unicorn-lockdown-add
53
109
  - bin/unicorn-lockdown-setup
54
110
  - files/rc.unicorn
55
- - lib/chrooter.rb
111
+ - files/unicorn_lockdown_add.rb
112
+ - files/unicorn_lockdown_setup.rb
56
113
  - lib/rack/email_exceptions.rb
57
114
  - lib/roda/plugins/pg_disconnect.rb
58
115
  - lib/unicorn-lockdown.rb
116
+ - lib/unveiler.rb
59
117
  homepage: https://github.com/jeremyevans/unicorn-lockdown
60
118
  licenses:
61
119
  - MIT
62
- metadata: {}
120
+ metadata:
121
+ bug_tracker_uri: https://github.com/jeremyevans/unicorn-lockdown/issues
122
+ changelog_uri: https://github.com/jeremyevans/unicorn-lockdown/blob/master/CHANGELOG
123
+ mailing_list_uri: https://github.com/jeremyevans/unicorn-lockdown/discussions
124
+ source_code_uri: https://github.com/jeremyevans/unicorn-lockdown
63
125
  post_install_message:
64
126
  rdoc_options: []
65
127
  require_paths:
@@ -68,16 +130,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
68
130
  requirements:
69
131
  - - ">="
70
132
  - !ruby/object:Gem::Version
71
- version: '0'
133
+ version: 2.0.0
72
134
  required_rubygems_version: !ruby/object:Gem::Requirement
73
135
  requirements:
74
136
  - - ">="
75
137
  - !ruby/object:Gem::Version
76
138
  version: '0'
77
139
  requirements: []
78
- rubygems_version: 3.0.3
140
+ rubygems_version: 3.3.7
79
141
  signing_key:
80
142
  specification_version: 4
81
- summary: Helper library for running Unicorn with chroot/privdrop/fork+exec/pledge
82
- on OpenBSD
143
+ summary: Helper library for running Unicorn with fork+exec/unveil/pledge on OpenBSD
83
144
  test_files: []
data/lib/chrooter.rb DELETED
@@ -1,110 +0,0 @@
1
- require 'etc'
2
- require 'pledge'
3
-
4
- # Loading single_byte encoding
5
- "\255".force_encoding('ISO8859-1').encode('UTF-8')
6
-
7
- # Load encodings
8
- ''.force_encoding('UTF-16LE')
9
- ''.force_encoding('UTF-16BE')
10
-
11
- # Don't run external diff program for failures
12
- Minitest::Assertions.diff = false
13
-
14
- if defined?(SimpleCov) && Process.euid == 0
15
- # Prevent coverage testing in chroot mode. Chroot vs
16
- # non-chroot should not matter in terms of lines covered,
17
- # and running coverage tests in chroot mode will probable fail
18
- # when it comes time to write the coverage files.
19
- SimpleCov.at_exit{}
20
- raise "cannot run coverage testing in chroot mode"
21
- end
22
-
23
- # Chrooter allows for testing programs in both chroot and non
24
- # chroot modes.
25
- module Chrooter
26
- # If the current user is the super user, change to the given
27
- # user/group, chroot to the given directory, and pledge
28
- # the process with the given permissions (if given).
29
- #
30
- # If the current user is not the super user, freeze
31
- # $LOADED_FEATURES to more easily detect problems with
32
- # autoloaded constants, and just pledge the process with the
33
- # given permissions (if given).
34
- #
35
- # This will reference common autoloaded constants in the
36
- # rack and mail libraries if they are defined. Other
37
- # autoloaded constants should be referenced before calling
38
- # this method.
39
- #
40
- # In general this should be called inside an at_exit block
41
- # after loading minitest/autorun, so it will run after all
42
- # specs are loaded, but before running specs.
43
- def self.chroot(user, pledge=nil, group=user, dir=Dir.pwd)
44
- # Work around autoload issues in libraries.
45
- # autoload is problematic when chrooting because if the
46
- # constant is not referenced before chrooting, an
47
- # exception is raised if the constant is raised
48
- # after chrooting.
49
- #
50
- # The constants listed here are the autoloaded constants
51
- # known to be used by any applications. This list
52
- # may need to be updated when libraries are upgraded
53
- # and add new constants, or when applications start
54
- # using new features.
55
- if defined?(Rack)
56
- Rack::MockRequest if defined?(Rack::MockRequest)
57
- Rack::Auth::Digest::Params if defined?(Rack::Auth::Digest::Params)
58
- if defined?(Rack::Multipart)
59
- Rack::Multipart
60
- Rack::Multipart::Parser
61
- Rack::Multipart::Generator
62
- Rack::Multipart::UploadedFile
63
- end
64
- end
65
- if defined?(Mail)
66
- Mail::Address
67
- Mail::AddressList
68
- Mail::Parsers::AddressListsParser
69
- Mail::ContentTransferEncodingElement
70
- Mail::ContentDispositionElement
71
- Mail::MessageIdsElement
72
- Mail::MimeVersionElement
73
- Mail::OptionalField
74
- Mail::ContentTypeElement
75
- end
76
-
77
- if Process.euid == 0
78
- uid = Etc.getpwnam(user).uid
79
- gid = Etc.getgrnam(group).gid
80
- if Process.egid != gid
81
- Process.initgroups(user, gid)
82
- Process::GID.change_privilege(gid)
83
- end
84
- Dir.chroot(dir)
85
- Dir.chdir('/')
86
- Process.euid != uid and Process::UID.change_privilege(uid)
87
- puts "Chrooted to #{dir}, running as user #{user}"
88
- else
89
- # Load minitest plugins before freezing loaded features,
90
- # so they don't break.
91
- Minitest.load_plugins
92
-
93
- # Emulate chroot not working by freezing $LOADED_FEATURES
94
- # This allows to more easily catch bugs that only occur
95
- # when chrooted, such as referencing an autoloaded constant
96
- # that wasn't loaded before the chroot.
97
- $LOADED_FEATURES.freeze
98
- end
99
-
100
- unless defined?(SimpleCov)
101
- if pledge
102
- # If running coverage tests, don't run pledged as coverage
103
- # testing can require many additional permissions.
104
- Pledge.pledge(pledge)
105
- end
106
- end
107
-
108
- nil
109
- end
110
- end