unicorn-lockdown 0.12.0 → 1.1.0

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.
@@ -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