How to secure individual Symfony AJAX api routes without using API Platform

I am in the process of updating this article entirely. Please stay tuned.

Creating the Symfony route is easy. Checking if the request was sent by AJAX is again easy. But what stops a mischievous hacker from hitting that endpoint and trying to get a list of used emails or something else with a script?

What if you have routes that you want to access with AJAX without API Platform? Maybe your project leader won’t let you use it. Maybe you don’t want to use it. What ever reason you still need to secure your endpoints to some extent. Api platform adds lots of great useful features though so you may want to check it out after reading this article.

For me, some parts of my app are too complex to be dealt with as objects rehydrated etc. My app checks permissions in many places to  see if the user has rights etc. and does creative things with the data, another reason I don’t use Doctrine all the time either. Doctrine and API platform can work together to make most simple actions… even more simple, which can speed up development of simple apps.

With Symfony, standard forms created with the Form Component, your forms are CSRF protected. But, when you are sending an AJAX request to an endpoint without a form how do you protect it?

There is probably some Symfony approved way I am not aware of. There is also more than one way to do this. One is cheap the other is expensive. The cheap I’ll cover first, the expensive I’ll cover eventually.

The cheap I call cheap because it is fast and easy and I’ll describe it below. The expensive I call expensive because it involves more steps + calculations = more cpu = more time etc it may not be much but it is still more. The second way is using JWT’s or JSON Web tokens, which is what API platform does anyways. They are quite complex, take more time to setup and are a little slower than the quick and easy way. JWT’s also increase your bandwidth usage and cause other issues.

The quick and easy I use for API endpoints that do simple things that need to  be as quick as possible, like an endpoint that returns a list of hashtags for a hint list in a form for example. JWT’s take more time to implement and are slower as they require more steps than just checking if two secret strings match.

If you send the whole form you can use a different procedure and use the CSRF string stored in the form. I’ll try to cover that too, somewhere below.

The cheap way

For simple situations where you need to randomly access a route you can do something similar to the CSRF form protection by generating a unique string and saving in a Session cookie and to the page/form.

Where you save the string in the page is up to you, but it should be a hidden element. This element needs a unique ID in the page so that you can access it with Javascript. A hidden input element in a form works great, otherwise use a hidden span element.(use css to hide the element).

When you need to make a request to the route you use javascript to get the value you hid in the element. Make sure it is just the unique string that you fetch not the entire element html or this wont work. Include this string with the data you are sending to the route. You can also store the value in a Secure cookie with HttpOnly set. The way you choose is up to you.

Inside your route fetch the unique string that you sent in your AJAX. Then try to fetch the same unique string from your session cookies. If the string exists and matches process the request.

There are tricks you can try to use with the header like checking the users browser agent. But that is useless as it can be easily spoofed by a good hacker using something like Curl.

This unique string trick isn’t 100% hacker proof. But it makes it a hell of a lot harder.  More on CSRF attacks here.

NOTE

If you are using the Symfony forms with CSRF activated then you can use Javascript to fetch the value of the nonce hidden in the _token input element. However, if your code will make multiple ajax requests, then you might want to create the custom hidden field and generate a new unique string each time and replace it in the custom field.

Step #1 create the field

To create the field add it in the FormType definition like this. The entire class is too long so I’ll show just the add section.


->add('ajaxString', HiddenType::class, [
                'mapped' => false,
                'attr' => ['class' => 'hidden-field', 'value' => $secretString]
            ])

Notice mapped is false so that I don’t get errors.

Step #2 Build the form

Now you build the form inside the Template for the form. Mine looks like this.


{{ form_start(registrationForm) }}
        {{ form_errors(registrationForm) }}
        {{ form_row(registrationForm.email) }}
        {{ form_row(registrationForm.emailMatch) }}
        {{ form_row(registrationForm.plainPassword) }}
        {{ form_row(registrationForm.passwordMatch) }}
        {{ form_row(registrationForm.userAlias) }}
        {{ form_row(registrationForm.ajaxString, { 'id': 'ajaxString'}) }}
        {{ form_row(registrationForm.agreeTerms) }}

        <div class="d-flex justify-content-center">
            <button type="submit" class="btn btn-lg btn-success">Register</button>
        </div>

        {{ form_end(registrationForm) }}

Notice how I have the id : ajaxString line. This is currently the only way to change the ID of a form field in Symfony see How to change the id for a form input in Symfony 5+

Step #3 add initial value

Inside the controller you must add the initial value for the field and store it in a session cookie.

For this I am using a simple class which generates semi random/unique strings. This doesn’t need to be super top notch secure, it is just to make sure the request is coming from a form my app built.

To access the Session Cookie in Symfony 5.3+ you must now use RequestStack instead of Session or SessionInterface for some odd reason. It just makes it more obscure and harder to figure out how to get to sessions.


$session = $this->requestStack->getCurrentRequest()->getSession();
        $secretString = RandomStringGenerator::lowercaseUppercaseNumberString(32);
        $session->set('secretString', $secretString);

 

To check the value in the Controller route endpoint I do like this.


$secretString = $request->query->get('secretString');
        $secretString = DataSanitizer::sanitizeString($secretString);
        $string = $this->requestStack->getCurrentRequest()->get('secretString');

        if ($request->isXmlHttpRequest() && $secretString === $string) {

Note that secretString is the value sent by the AJAX request. This was the value I hid in the form field to use for this purpose.
The other line

$string=$this->requestStack->getCurrentRequest()->get(‘secretString’);

gets the value I stored in the Session Cookie. Then the if statement makes sure the two values match before processing the request. If the two strings match we know that my app built the form, added the string and my Javascript copied the string and sent it to my server. This prevents people from randomly hitting your route endpoints.

&& $secretString === $string

Using the CSRF

I have discovered another way to go about this. Instead of creating your own random string, you can use the CSRF that Symfony already created for this form.

The expensive way

I’ll have to fill in all of the details later for this, it almost needs to be it’s own article. It is expensive because of the time it takes to figure out and implement. Using JWT (json web tokens) with Symfony. Here is a good article I found helpful about the subject. I don’t have time to write one right now.

Links

Here is a good link to Symfony Casts about API Platform. There are many symfony casts here to learn more. I was going to post each but this link contains all of them with pretty pictures and descriptions. LOL

More about CSRF in symfony forms here in the documentation.


Posted

in

,

by

Comments

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.

%d