Correct PayPal IPN handling with Rails

While working with different PayPal payment APIs, I have read a lot of tutorials, library code and documentation about the given matter. While there are several quite competent implementation examples out there, the amount of obviously buggy and straight up bad implementations strike me. This applies mainly to IPN (Instant Payment Notification) handling code – mostly it’s just plain awful.

Working with the Adaptive Payments API gem adaptive_pay last week was the final drop for me, so I decided to share my experience (and opinion) how to handle these messages, without leaving your application exploitable.

The Problem

The single biggest mistake a lot of the tutorials and libraries do is to approach the IPN messages really really naive. I’ll give an example from the aforementioned gems documentation.

def ipn_callback
  callback = AdaptivePay::Callback.new params

  if callback.completed?
    # payment has been processed, now mark order as paid etc
  else
    # payment failed
  end
end

To clarify why this is bad, I will also show the code inside AdaptivePay::Callback

module AdaptivePay
  class Callback

    def initialize(params)
      @params = params
    end

    def method_missing(name, *args)
      @params[name.to_s] || super
    end

    def completed?
      payment_status == "Completed"
    end

  end
end

Yes, that’s it. Really. Still not convinced it’s bad? I’ll give you a real life example then.

Lets say you have a webpage where you can purchase items.
When you purchase the item you will be redirected to the PayPal site to complete the transaction. Upon completion, you will be redirected back to the site to see the purchase you just made.

At the same time PayPal will process your transaction and send an IPN message to the URL you have configured. In our case this would be /items/1/purchases/1/ipn.
So what happens now is your application will receive the notification, check if the response contained an attribute named “payment_status” and if it’s value is “Completed”.

This means, given I know the URL for the IPN handler, I could just post a random request with params “payment_status=Complete”, and the purchase would be marked as valid, without even going to the PayPal site.

Although some people might argue that the URL is not so obvious or that this is a pseudo-problem, I consider it a serious security issue. Especially because we are dealing with other peoples money here.

It would be like saying “Hey, I left the door open. But that’s not a problem, no one will discover it anyhow”. That sounds like complete nonsense, right?

The Solution

No if the people writing these tutorials and stuff would have spent some time reading the PayPal documentation (which is admittedly not an easy task, as it’s so spread out and sometimes even really hard to find), they would know that PayPal actually has a “service” to check if the received IPN is valid.

So armed with the knowledge about IPN verification mechanisms, I extended the AdaptivePay::Callback class to include a valid? method.

class Callback

  def initialize(params, raw_post)
    @params = params
    @raw = raw_post
  end

  def valid?
    uri = URI.parse(AdaptivePay::Interface.new.base_page_url + '/webscr?cmd=_notify-validate')

    http = Net::HTTP.new(uri.host, uri.port)
    http.open_timeout = 60
    http.read_timeout = 60
    http.verify_mode = OpenSSL::SSL::VERIFY_NONE
    http.use_ssl = true
    response = http.post(uri.request_uri, @raw,
                         'Content-Length' => "#{@raw.size}",
                         'User-Agent' => "My custom user agent"
                       ).body

    raise StandardError.new("Faulty paypal result: #{response}") unless ["VERIFIED", "INVALID"].include?(response)
    raise StandardError.new("Invalid IPN: #{response}") unless response == "VERIFIED"

    true
  end

  def completed?
    status == "COMPLETED"
  end

end

This code just takes the request.raw_post and submits it back to PayPal, which will then respond with either "VERIFIED" for valid IPNs and "INVALID" for invalid IPNs.
Thats really all there is to it – it’s really easy to implement and still people just tend not to do it, be it from ignorance or just lack of experience.

In case you were wondering why I have modified the completed? method – the stock method just did not work for me, the IPN message contained no attributes named “payment_status”.

So now you can write your controller action handling IPN messages like this:

def ipn
  paypal_response = AdaptivePay::Callback.new(params, request.raw_post)

  if paypal_response.completed? && paypal_response.valid?
    # mark your payment as complete and make them unicorns happy!
  end
end

The Notes

In addition to verifying the IPN message itself, it would be nice (and suggested) to check if the amount transferred according to the IPN message matches the amount you expected it to have and that it’s in the correct currency. This prevents exploits where someone might pay just $1 instead of $100 and your code would still mark the payment as complete.

Another thing to remember is that IPN messages can be sent on failed payments also, so you always need to check the relevant attribute(s) to validate the status of the transaction.
I have seen examples where the IPN is verified and payment marked as complete just by accepting and verifying it. This allows evil ninjas to exploit your system by for example capturing and replicating an IPN message about an error.

And finally – always check if you have already processed the transaction referenced in the IPN message (indentified by tnx_id attribute).
As PayPal can sometimes send IPN messages about the same transaction several times, handling the same transaction twice would result in running the payment completion routines twice also.
So if your code for example sends out notifications to users, or even worse – ships the item to the buyer automatically – this would cause some painful side effect.
A good practice is to store the transaction id (along with all the attributes from IPN – thats what I do) in the database, and use that data to check if you already have handled the given transaction.

Storing this data also helps to implement Refund operations and other operations also – they are all referenced and tied via the transaction id. This allows you to have a full history about any transaction gone through your system.

Don’t be lazy and don’t be naive when handling payments in your application – your are dealing with other peoples money. You can shrug off potential loopholes by telling yourself that these are edge cases, until something actually happens and you have much bigger problems on your hands.

If you need help with implementing your payment system, or want to add one to your existing application then just contact us and lets chat about how we can help you. We have the experience and knowledge and we’re not shy to share it with you.

Tanel Suurhans
Tanel is an experienced Software Engineer with strong background in variety of technologies. He is extremely passionate about creating high quality software and constantly explores new technologies.

5 Comments

  • despai

    Really crazy that bug. Thanks

  • Charles Zhang

    Very helpful article. Thanks. Can’t believe other gems are not verifying that the transaction is actually valid.

  • Hubert

    But wait! Doesn’t this solution open and listen a web connection INSIDE a controller action? And while this is happening, the server worker is on the hold, and even under the risk of being time-outed? Shouldn’t the verification go somehow into a background process?

    • Tanel Suurhans

      Yes you are correct. But this is not an issue, because your app (and HTTP server for that matter) have multiple workers. Also, the verification needs to be done in the same request-response cycle, as you need to respond with the correct status to let PP know you have successfully received the IPN.
      The default time-out for a HTTP request is 30 seconds, so it’s not a problem even if the verification times out – plus how often do you receive IPN-s anyway?

    • Hubert

      The more, the better, of course :) Thanks for the clarification!

      It’s worth mentioning that for people who don’t use the adaptive_pay gem the first line of the `valid?` method should be:

      uri = URI.parse(‘https://www.paypal.com/cgi-bin/webscr?cmd=_notify-validate’)

      or

      uri = URI.parse(‘https://www.sandbox.paypal.com/cgi-bin/webscr?cmd=_notify-validate’)

      depending on the PayPal environment.

Liked this post?

There’s more where that came from. Follow us on Facebook, Twitter or subscribe to our RSS feed to get all the latest posts immediately.