For the past few months I got caught up into puppet, monitoring and other devops stuff. Wether or not security is part of devops (honestly I don’t care as long as security is taken into mind when developing) I was also tasked with securing some backend systems for the company that I work for as part of ISO 27001 certification. One of them was a PHP based application which had a code base, so old, so spaghetti bolognese that it could wake you up in the middle of night soaking in sweat when thinking about its security design. Nevertheless it was a critical application, that couldn’t be merged easily to a new code base like Symfony or another tool. So how to improve its security?
If it was built in Symfony, I could easily setup a firewall, add NelmioSecurityBundle and maybe add a 2-factor bundle and get on with it. But yeah, it wasn’t. Maybe I could just go into the spaghetti and implement some meatballs with composer and make the best of it? Meh…. There will always be the risk of open holes, maybe a “require ‘check_login.php’;” is forgotten, or login method might be vulnerable from a good old SQL injection. So this was not an option.
The app was used publicly by some of our customers, so IP whitelisting was no option. It just needed public protection from vulnerability scanners, So I wanted it to be wrapped in a secure package. More like a portal. What if I wrapped the entire application into a secured Symfony environment? But that would incur another session_start from the old application. So it needed to more separated than that. And at that point the idea of a Symfony web proxy was born. The Symfony layer would handle all the security, after which every request is proxied to the old PHP application. So the requirements would be
- A symfony application @ my.symfony.proxy which functions as login portal
- After logging in, every request is ported to an another site. Something like https://my.symfony.proxy —> http://old_application_at_private_location:8080
Here’s how with Symfony 4.x (using flex) on PHP 7.2.
Setting it up
First we create a new project and install and setup some needed libraries. I mentioned them here below
1 2 3 4 5 6 7 8 9 |
composer create-project symfony/skeleton . composer require sec-checker # Enable vulnerability scan for packages composer require --dev profiler # Easy dev time composer require logger # Logs might come in handy composer require doctrine/orm doctrine/doctrine-bundle # Database access for users composer require form # Login form composer require security nelmio/security-bundle symfony/security # Security components composer require guzzlehttp/guzzle # Proxying requests composer require twig templating symfony/asset # Add templating |
Now that we installed everything we needed we can configure our application.
Security
Lets start with my favourite and most complex part of our application (now we’re still sharp). First we configure our security.yaml
Now comes the interesting part. I wanted to use the same user and password database provided by the old native PHP app. So I need to create an entity that respects the existing table, and add this as a user provider in the security.yaml.
1 2 3 4 5 |
providers: oldapp: entity: class: App\Entity\User property: username |
Because the old app uses plain md5 hashing for the password without any salt or iterations I needed to change the encoder to some lower standards, so it integrates seamlessly with the old standard. Obviously this is not recommended security. If this is your exact same case, and would like to enhance this security. Then I recommend you to add an extra salt column, and rehash the password on login with some salt and iterations with the default encoder. On the login event you only need to check if there is already salt or not to pick the right encoder.
1 2 3 4 5 |
encoders: App\Entity\User: algorithm: md5 encode_as_base64: false iterations: 0 |
Now we can configure the firewall, wich is somewhat standard. Change according to your needs. Don’t forget to name the right provider.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
main: anonymous: true pattern: ^/ form_login: provider: oldapp csrf_token_generator: security.csrf.token_manager login_path: login check_path: login always_use_default_target_path: true default_target_path: / logout: true switch_user: false logout_on_user_change: true |
Last but not least, add the access control
1 2 3 |
access_control: - { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: ^/, roles: ROLE_USER } |
User Entity
In my case I wanted to reuse an existing user database (within the old application), which is served under MySQL. So I created an entity which reflects this table to be able to reuse this data. This is how my entity looks like:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 |
/** * @ORM\Table(name="users") * @ORM\Entity() */ class User implements AdvancedUserInterface, EquatableInterface { /** * @ORM\Column(type="integer") * @ORM\Id * @ORM\GeneratedValue(strategy="AUTO") */ private $id; /** * @ORM\Column(type="string", length=25, unique=true) */ private $username; /** * @ORM\Column(type="string", length=64) */ private $password; /** * @ORM\Column(name="active", type="boolean") */ private $active; /** * @var string No salt used in poweradmin */ private $salt = ''; /** * @var array Just a simple role is enough for our case */ private $roles = ['ROLE_USER']; public function getRoles() { return $this->roles; } public function getPassword() { return $this->password; } public function getSalt() { return $this->salt; } public function getUsername() { return $this->username; } public function eraseCredentials() { // Nothing, just here to comply with interface } public function isEqualTo(UserInterface $user) { if (!$user instanceof User) { return false; } if ($this->password !== $user->getPassword()) { return false; } if ($this->salt !== $user->getSalt()) { return false; } if ($this->username !== $user->getUsername()) { return false; } return true; } public function isAccountNonExpired() { return true; } public function isAccountNonLocked() { return true; } public function isCredentialsNonExpired() { return true; } public function isEnabled() { return $this->active; } } |
Controllers
We need a few methods; first the login and logout method, which are pretty straight forward.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
public function login(AuthenticationUtils $authUtils) { // get the login error if there is one $error = $authUtils->getLastAuthenticationError(); // last username entered by the user $lastUsername = $authUtils->getLastUsername(); return $this->render('security/login.html.twig', array( 'last_username' => $lastUsername, 'error' => $error, )); } public function logout(Request $request, TokenStorageInterface $storage) { $storage->setToken(null); $request->getSession()->invalidate(); return $this->redirect($this->generateUrl('login')); } |
Then finally the core of this whole blog, a catchall controller. This method is called upon every other request (catchall magic is handled in the routing part further on). It just reads the current request, and passes everything within a curl request (Guzzle) to the old application that needs to be protected. In my case I placed the application in nginx under port 8080, which is shielded by a firewall and only accessible from localhost. Change the URL to your own “old application”
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
public function catchall(Request $request, TokenStorageInterface $storage) { // Catch logout action, to redirect to own logout function if ($request->query->get('logout') !== null) { return $this->redirect('logout'); } $client = new Client(); $baseURL = 'http://localhost:8080'; $cookieJar = unserialize($request->getSession()->get('appcookie')); $url = $baseURL . $request->getPathInfo(); $headerparams = $request->headers->all(); $formparams = $request->request->all(); $query = $request->query->all(); $result = $client->request($request->getMethod(), $url, [ 'headers' => $headerparams, 'form_params' => $formparams, 'query' => $query, 'cookies' => $cookieJar ]); return new Response($result->getBody(), $result->getStatusCode(), $result->getHeaders()); } |
So now we basically created a proxy application. Now you can serve every existing site with your own custom domain (don’t use this for bad stuff, mkay?).
Security listener
Underneath all of this, we still need to add a little more custom logic to take over the login process. Instead of logging in directly with the original login of the application, I wanted to use symfony login. This is done with a security listener. Be sure that you change the code to an actual working login.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
/** * @param InteractiveLoginEvent $event */ public function onSecurityInteractiveLogin(InteractiveLoginEvent $event) { // Login is succesfull, so retrieve password $password = $event->getRequest()->request->get('_password'); $username = $event->getRequest()->request->get('_username'); $loginUrl = getenv('LOGIN_URL'); // Pass it as a login request to underlying application to get the session cookie $cookieJar = new CookieJar(); $client = new Client(); $client->request('POST', $loginUrl, [ 'form_params' => [ 'query_string' => '', 'username' => $username, 'password' => $password, 'userlang' => 'en_EN', 'authenticate' => 'Go' ], 'cookies' => $cookieJar ]); // Store it into our own session $event->getRequest()->getSession()->set('appcookie', serialize($cookieJar)); } |
Routes
There are four routes needed for the controller. Besides the security related routes there is one route which is particularly interesting. This is the catchall route which should be named lastly to catch all other routes that are not matched by the fixed security related routes. This is done by providing a regex with just .* to match anything.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
login: path: /login defaults: { _controller: 'App\Controller\DefaultController::login' } logout: path: /logout defaults: { _controller: 'App\Controller\DefaultController:logout' } catchall: path: /{req} defaults: { _controller: 'App\Controller\DefaultController:catchall' } requirements: req: ".*" |
Templates
The template is a somewhat basic login template based on a simple twitter bootstrap theme. Placed under security/login.html.twig
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>{% block title %}Welcome!{% endblock %}</title> </head> <body> <div class="container"> <form action="{{ path('login') }}" method="post" class="form-signin"> <div class="form-signin-heading"> <h1>Please login</h1> </div> {% if error %} <p class="alert alert-danger">{{ error.messageKey|trans(error.messageData, 'security') }}</p> {% endif %} <input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}" /> <label for="username" class="">Username</label> <input type="text" id="username" name="_username" value="{{ last_username }}" class="form-control" /> <label for="password" class="">Password</label> <input type="password" id="password" name="_password" class="form-control"/> <button type="submit" class="btn btn-lg btn-primary btn-block">login</button> </form> </div> </body> </html> |
Wrapping it up
To conclude; it’s relatively easy to setup a web proxy with modern security standards to protect your old (irreplaceable) spaghetti app. With Symfony at its base, some configuration and the usage of the right libraries, we only need a few files with satisfactional result.
I created a repository which contains all of the above, which you can use out of the box. This is found at my github account @ https://github.com/markri/symfony_web_proxy