The dRuby Book

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:

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.