16

I'm trying to figure out how to obfuscate the ids of my records in rails.

For example: a typical path might look like http://domain/records/1, so it's pretty easy for people to deduce how much traffic the site is getting if they just create a new record.

One solution that I've used is to hash the id with a salt, but since I'm not sure whether that function is bijective, I end up storing it in another column in my database and double check for uniqueness.

Another option I was thinking about was generating a random hash and storing that as another column. If it isn't unique ... just generate another one.

What's the best way of doing this?

Cyrus
  • 3,687
  • 5
  • 35
  • 67

6 Answers6

27

You could use the built-in OpenSSL library to encrypt and decrypt your identifiers, that way you would only need to overwrite to_param on your models. You'll also need to use Base64 to convert the encrypted data into plain text. I would stick this in a module so it can be reused:

require 'openssl'
require 'base64'

module Obfuscate
  def self.included(base)
    base.extend self
  end

  def cipher
    OpenSSL::Cipher::Cipher.new('aes-256-cbc')
  end

  def cipher_key
    'blah!'
  end

  def decrypt(value)
    c = cipher.decrypt
    c.key = Digest::SHA256.digest(cipher_key)
    c.update(Base64.decode64(value.to_s)) + c.final
  end

  def encrypt(value)
    c = cipher.encrypt
    c.key = Digest::SHA256.digest(cipher_key)
    Base64.encode64(c.update(value.to_s) + c.final)
  end
end

So now your models would need to look something like this:

class MyModel < ActiveRecord::Base
  include Obfuscate

  def to_param
    encrypt id
  end
end

Then in your controller when you need to find a record by the encrypted id, you would use something like this:

MyModel.find MyModel.decrypt(params[:id])

If you're looking to encrypt/decrypt ids without storing them in the database, this is probably the easiest way to go.

siannopollo
  • 1,464
  • 11
  • 24
  • 1
    This is awesome :) Is there any way to get rid of the extra "==" at the end of the encrypted string? – Cyrus Feb 16 '12 at 08:35
  • That's part of the Base64 encryption so it is needed to decrypt the id. You could remove it in the `encrypt` method and add it back in the `decrypt` method, but that might be more prone to error (as I'm not sure if Base64 *always* includes the `==` at the end of the string). – siannopollo Feb 16 '12 at 15:03
  • 2
    Base64 pads out with `=` if the number of bits being encoded doesn't match up precisely. There will be zero, one, or two of them depending on the length of the input. – tadman Feb 16 '12 at 15:53
  • 5
    This could produce an id with a '/' in it that would break your routes. (and probably some other combinations of characters). Use `urlsafe_encode64` and `urlsafe_decode64` instead – Zac M Nov 08 '13 at 14:46
  • This solution worked for me. I just added a simple modification so that it has more scalability. Within model User.rb, I provided action, def self.find(id);; id=decrypt(id);; super;; end;; Then, we just use User.find(params[:id]) as usual. P/S: I cannot enter for new line, so that I use (;;) instead on the code above. – datnt Dec 16 '13 at 03:53
  • @datnt What do you mean you provided an action. I'm trying to make this work inside my models rb file. – Anthony Jul 11 '19 at 22:15
  • 1
    @Anthony I think he just means he overrode the `find` method in his User class. – siannopollo Jul 12 '19 at 23:51
  • @Anthony: siannopollo is right. I override method "find" for class User.rb. Just open your model file (e.g: User.rb), and define a new method just as I mentioned in the previous comment. – datnt Jul 13 '19 at 09:45
6

Instead of numeric ids, use some kind of friendly url or human readable slug. There are lots of tools to choose from in this department. Not only are they more friendly to your users, but well chosen slugs can give a nice advantage with search engines.

Mori
  • 27,279
  • 10
  • 68
  • 73
5

Here's a gem that keeps it numeric, requires no database migrations, and no routing changes: https://github.com/namick/obfuscate_id


I've found that this gem doesn't work in concert with some other gems, notably paper_trail. This is because of the way it replaces the find method, and paper_trail causes find to be called with the actual record id.

So I've been using the gem's "scatter_swap" functionality, but not the rest of it. Here's the model:

require 'obfuscate_id/scatter_swap'

class Page < ActiveRecord::Base
  # This is a random number that, if changed, will invalidate all existing URLs. Don't change it!
  @@obfuscate_spin = # random number here, which is essentially the encryption key

  ##
  # Generate URL parameter to be used in the URL as the "id"
  def to_param
    # Use the obfuscate_id gem's class to "spin" the id into something obfuscated
    spun_id = ScatterSwap.hash(self.id, @@obfuscate_spin)

    # Throw any additional attributes in here that are to be included in the URL.
    "#{spun_id} #{name}".parameterize
  end

  def self.find_by_slug!(slug)
    spun_id = slug[/^[0-9]+/]
    begin
      find_by_id! ScatterSwap.reverse_hash(spun_id, @@obfuscate_spin)
    rescue ActiveRecord::RecordNotFound => e
      raise ActiveRecord::RecordNotFound, "Couldn't find matching Page."
    end
  end
end

And in the controller:

class PagesController < InheritedResources::Base
  # Find the page using its URL slug
  before_filter :find_page, except: [:index, :create, :new]

  def find_page
    @page = Page.find_by_slug! params[:id]

    # If the URL doesn't match exactly, and this is a GET.
    # We'll redirect to the new, correct URL, but if this is a non-GET, let's let them finish their request instead.
    if params[:id] != @page.to_param && request.get?
      redirect_to url_for({ id: @page.to_param }), status: 301
    end
  end
end

As an alternative to the redirection that takes place there, you could simply include a canonical URL in the page. The redirection has the bug of ignoring any query parameters in the URL. This was not a problem for my project, as I didn't have any. But a canonical URL would be better.

Robin Daugherty
  • 7,115
  • 4
  • 45
  • 59
4

After reading through @siannopollo's post, I created a Gem based on the idea of his post (but with some improvements): https://github.com/pencil/encrypted_id

Community
  • 1
  • 1
pencil
  • 1,165
  • 8
  • 20
4

It's pretty easy to generate unique random identifiers for your records either using a randomized string generator or a simple call to Digest::SHA1.hexdigest which produces reasonably random and cryptographically unique results.

For instance, you can create a secondary column called ident or unique_id that stores your public identifiers. You can then over-write to_param to use this instead:

class MyModel < ActiveRecord::Base
  before_create :assign_ident

  def self.from_param(ident)
    find_by_ident(ident)
  end

  def to_param
    self.ident
  end

protected
  def assign_ident
    self.ident = Digest::SHA1.hexdigest(SecureRandom.random_number(1<<256).to_s)
  end
end

Theoretically there is a chance of collision on SHA1 but the odds are so astronomically low you're more liable to have a software crash because of a memory error or hardware malfunction. You can test this to see if it suits your needs by generating a few billion identities to see if they ever collide, which they shouldn't. A 256-bit random number should provide a sufficient amount of data for the SHA1 algorithm to chew on.

tadman
  • 208,517
  • 23
  • 234
  • 262
0

Just because it hasn't been mentioned here: You could simply use UUIDs (wikipedia article)

There are multiple ways of using UUID as primary keys in Rails, depending on your Rails version and database engine. It's easy to find.

Just as a possibility, in case you depend too much on your existing integer primary key, you can also just add a UUID to your table and make your model use it automatically when it comes to generating URLs by overwriting Model#to_param more details in the docs

Robin
  • 866
  • 7
  • 19