12.1 dRuby’s Attitude Toward Security
dRuby is a mechanism to extend Ruby’s method invocation across processes or machines, and this applies to how it handles security, too. Here is dRuby’s strategy toward security:
-
No restriction on remote method invocation
-
Retains restriction related to method invocations (for example,
private
method) -
Uses
$SAFE
, which is the security model for Ruby
I initially explored the possibility of maintaining the safety by restricting method invocation from dRuby. However, I quickly realized that this approach is too difficult because of the dynamic features of Ruby, so I ended up using $SAFE
, the security model used in Ruby.
You need to be extra cautious with dRuby security, such as restricting access to the few trusted client machines or using dRuby only inside the trusted network. There are a few options to restrict client access, such as access control lists (ACLs) and Unix domain socket files.
Setting the Security Level with $SAFE
The Ruby security model consists of an object’s taint status and $SAFE
(safe level). $SAFE
looks like a global variable, but you can set it per thread.
$SAFE
takes a value between 0 and 4; the default is 0. The higher the number becomes, the more restrictive the security becomes. $SAFE
is inherited to the subthread of the parent, and you can only increase the $SAFE
value.
The state 0 doesn’t have any restriction, but it marks as “tainted” if an object comes from I/O, an environment variable, or a command-line argument. This means that all parameters from remote method invocations are tainted. You can check the status via the tainted?
method. You can use the taint
method to explicitly mark it as tainted and use untainted
methods to unmark it.
When $SAFE
is set to 1, certain dangerous operations with tainted objects become prohibited, such as executing an external command or calling the eval
method. When $SAFE
is set to 2, more operations become restricted.
With dRuby, you can specify $SAFE
on all method calls the server receives when starting DRbServer
. Pass the $SAFE
level to the third argument of DRb.start_service
as a :safe_level
hash value.
DRb.start_service(uri, front, { :safe_level => 1 }) |
You can also set the default $SAFE
with DRbServer.default_safe_level
. It becomes effective on any DRbServer
after setting default_safe_level
.
Here’s an example of setting $SAFE
to 2:
DRb::DRbServer.default_safe_level(2) |
Alternatively, you can set $SAFE
at the beginning of your script so that you can restrict all operations as well as remote method invocations.
$SAFE = 1 |
|
DRb.start_service(uri, front) |
It’s worth noting that you can’t use iterators when $SAFE
is set to 4, because an iterator is part of the I/O operation.
Let’s experiment. The first example is to set $SAFE
to 0. You can execute any code by sending the instance_eval
method.
% irb --prompt simple -r drb |
|
# [Terminal 1] |
|
>> DRb.start_service('druby://localhost:12345', {}) |
# [Terminal 2] |
|
% irb --prompt simple -r drb |
|
>> DRb.start_service |
|
>> ro = DRbObject.new_with_uri('druby://localhost:12345') |
|
>> ro.instance_eval('undef :instance_eval') |
First, undefine (undef
) the instance_eval
method of the ro
instance so that you can send the method to the remote. Then, call the remote instance_eval
.
>> ro.instance_eval('exit!') |
irb
at terminal 1 is terminated. Bummer!
Next, leave terminal 2 and retry the same operation in terminal 1, but set default_safe_level
to 1.
# [Terminal 1] |
|
% irb --prompt simple -r drb |
|
>> DRb::DRbServer.default_safe_level(1) |
|
>> DRb.start_service('druby://localhost:12345', {}) |
# [Terminal 2] |
|
>> ro.instance_eval('exit!') |
|
DRb::DRbConnError: connection closed |
|
...... |
The connection at terminal 2 is closed and instance_eval
fails. (It used to raise a SecurityError
in Ruby 1.8.) Looking good.
Let’s see whether the iterator works with a server of $SAFE
level 1.
>> ro[:one] = 1 |
|
>> ro[:one] = 1 |
|
=> 1 |
|
>> ro.each {|k,v| p [k, v]} |
|
[:one, 1] |
|
=> {:one=>1} |
Next, let’s make sure that the iterator fails with a server of $SAFE
4.
# [Terminal 1] |
|
>> DRb.start_service('druby://localhost:12346', {}, {:safe_level => 4}) |
# [Terminal 2] |
|
>> r4 = DRbObject.new_with_uri('druby://localhost:12346') |
|
>> r4[:four] = 4 |
|
DRb::DRbConnError: connection closed |
|
.... |
Hmmm, when $SAFE
is set to level 4, even untainted objects can’t be changed. Since the front
object on terminal 1 is not tainted, it gets disconnected. Let’s taint the object and try again.
# [Terminal 1] |
|
>> DRb.front.taint |
>> r4[:four] = 4 |
We were able to change Hash
. How about calling an iterator?
# [Terminal 2] |
|
>> r4.each {|k, v| p [k, v]} |
|
DRb::DRbConnError: DRb::DRbServerNotFound |
|
.... |
At $SAFE
4, you can’t use an iterator because it is restricted to call remote methods.
So far, you’ve seen how to configure Ruby’s $SAFE
mode. However, you may have a situation where you want to grant full access to a certain client. In the next section, you’ll see how to set a security policy per client.
Setting IP-Level Security with ACL
With ACL
, you can allow or prohibit client access per IP address.
require 'drb/acl' |
|
|
|
acl = ACL.new(%w(deny all |
|
allow 192.168.0.0/24 |
|
allow localhost)) |
|
DRb.install_acl(acl) |
|
|
|
DRb.start_service(uri, front) |
|
... |
First, you generate an ACL
object; then, you install it using DRb.install_acl
.
Or, you can specify in the third argument of a Hash
as a :tcp_acl
key.
acl = ACL.new(%w(deny all |
|
allow 192.168.0.0/24 |
|
allow localhost)) |
|
DRb.start_service(uri, front, {:tcp_acl => acl}) |
Create the ACL
object by passing an array combining either the deny
or allow
keyword followed by the hostname or by IP addresses. It matches anything if you specify wildcards such as *
or all
. By default, ACL
looks up in the order of allow
and then deny
and then allows anything if nothing matches. You can reverse it by specifying the ACL::ALLOW_DENY
constant in the second argument of the ACL
constructor. This denies any access unless specified in the ACL list.
The preceding example denies all entries except 192.168.0.0/24
and localhost
.
Setting File-Level Security with a Unix Domain Socket
If you use Unix-origin operating systems, such as Linux POSIX, BSD, or Mac OS X, you can use a Unix domain socket. With a Unix domain socket, you can allow or deny access per client user ID by using file ownership.
To set up a Unix domain socket ownership or configuration, you need to specify it in the third argument of DRb.start_service
.
-
:UNIXFileOwner
-
Specifies Unix file ownership
-
:UNIXFileGroup
-
Specifies Unix file group ownership
-
:UNIXFileMode
-
Specifies file mode using numbers
Let’s try access control using file mode.
# [Terminal 1] |
|
% irb --prompt simple -r drb |
|
>> DRb.start_service('drbunix:/tmp/drb000', {}, {:UNIXFileMode => 0600}) |
We just set permission to be accessible only by the file owner.
Let’s try accessing now from the same user’s terminal.
# [Terminal 2] |
|
% irb --prompt simple -r drb |
|
>> DRb.start_service |
|
>> ro = DRbObject.new_with_uri('drbunix:/tmp/drb000') |
|
>> ro.keys |
|
=> [] |
Next, let’s access from a different user’s terminal. To simulate this, we use the sudo
command, but you can do the same by login
via a different user.
# [Terminal 3] |
|
% sudo -u foo irb --prompt simple -r drb |
|
>> DRb.start_service |
|
>> ro = DRbObject.new_with_uri('drbunix:/tmp/drb000') |
|
>> ro.keys |
|
DRb::DRbConnError: drbunix:/tmp/drb000 |
|
- #<Errno::EACCES: Permission denied - /tmp/drb000> |
|
.... |
You should receive an Errno::EACCES
exception, and the method invocation should fail. You protected access from another user by setting ownership to 0600
, which means only the owner can read and write. The preceding example showed that you can control access per user level by using Unix domain socket and file permission.