Categories
Web Development Web Security

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

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? 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.

If you send the whole form you can use a different procedure and use the CSRF string stored in the form.

However, 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.

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

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.