Building Your Startup: Increasing Security

This tutorial is part of the Building Your Startup With PHP series on СodeHolder Tuts+. In this series, I’m guiding you through launching a startup from concept to reality using my Meeting Planner app as a real-life example.

Building Your Startup: Increasing Security

Every step along the way, I’ll release the Meeting Planner code as open-source examples you can learn from. I’ll also address startup-related business issues as they arise.

Building Your Startup: IncreasingSecurity

In the prior episode, I covered primarily web server security and access control. In today’s episode, I’ll discuss additional safeguards that I added to Meeting Planner. Since all the code is written in the Yii2 Framework for PHP, I was able to leverage the framework for a number of these fortifications. If you’d like to learn more about Yii2, check out our parallel series Programming With Yii2.

You can try out Meeting Planner right now by scheduling your first meeting. Feel free to post feedback about your experience in the comments below. I’m also open to new feature ideas and topic suggestions for future tutorials.

Building Increased Security

Implementing the various levels of security for Meeting Planner will take several episodes. Now that the server is more robustly configured, I want to guide you through other areas of security for the application code.

Protecting Keys and Codes

Obviously, it’s important to keep authenticating keys away from hackers, but it’s also pretty easy to publish them to GitHub. Stories are told of accidental check-ins of files with a service password or API key.

To prevent this in Yii, I keep an external .ini file outside of the code tree. This gets loaded at the top of /frontend/config/main.php and is used for any component configuration that’s necessary:

<?php
$config = parse_ini_file('/var/secure/meetme.ini', true);

$params = array_merge(
    require(__DIR__ . '/../../common/config/params.php'),
    require(__DIR__ . '/../../common/config/params-local.php'),
    require(__DIR__ . '/params.php'),
    require(__DIR__ . '/params-local.php')
);

return [
    'id' => 'mp-frontend',
    'name' => 'Meeting Planner',
    'basePath' => dirname(__DIR__),
    'bootstrap' => ['log'],
    'controllerNamespace' => 'frontendcontrollers',
    'components' => [
      'authClientCollection' => [
              'class' => 'yiiauthclientCollection',
              'clients' => [
                  'facebook' => [
                      'class' => 'yiiauthclientclientsFacebook',
                      'clientId' => $config['oauth_fb_id'],
                      'clientSecret' => $config['oauth_fb_secret'],
                  ],

In the example above, you can see the Facebook API secrets loaded from the initialization file.

The format of the initialization file is a fairly straightforward one:

mysql_host="localhost"
mysql_un="xxxxxxxxxxxxxxxxxxx"
mysql_db="xxxxxxxxxxxxxxxxxxx"
mysql_pwd="xxxxxxxxxxxxxxxxxxx"
mailgun_user = "[email protected]"
mailgun_pwd = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
mailgun_api_key="key-9p-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
mailgun_api_url="https://api.mailgun.net/v2"
mailgun_public_key="pubkey-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
oauth_fb_id="1xxxxxxxxxxxxxxxxxxx3"
oauth_fb_secret="bcxxxxxxxxxxxxxxxxxxxda"

Yii2 encourages you to place some of these settings in the /environments directory, especially when the settings vary between development and production.

Thus, it’s important that your .gitignore file exclude the local versions of these files:

#local environment files
/environments/prod/common/config/main-local.php
/environments/prod/frontend/config/main-local.php
/frontend/config/params-local.php
/frontend/config/main-local.php

Here’s an example of one of my local parameter files, /frontend/config/params-local.php:

<?php
return [
  'ga' => 'UA-xxxxxxxxxx-12',
  'urlPrefix' => '',
  'google_maps_key' => 'AIzzzzzz1111222222xxxxxxQ',
];

I could probably spend even more time better organizing these.

Blocking Bad Signups

Building Your Startup: IncreasingSecurity

For the alpha release, I sent out updates in waves. And, in the early stages of Meeting Planner, there were a larger number of bad emails than I expected. Mailgun made it easy to identify the bounces and failures:

$badEmails=[ '', [email protected]', [email protected]', [email protected]',
 [email protected]', [email protected]', [email protected]', [email protected]',
 [email protected]', [email protected]', [email protected] a', [email protected]', [email protected]', [email protected]',
 [email protected]', [email protected]', [email protected]', [email protected] biz', [email protected]',
 [email protected]', [email protected]', [email protected]', [email protected]',
 [email protected]', [email protected] hu', [email protected]', 'a. [email protected]', [email protected]',
 [email protected]', [email protected]', [email protected] mike', [email protected]',
 [email protected]', [email protected]', [email protected] qwe', [email protected]', [email protected]',
 [email protected]', [email protected]', [email protected]', [email protected] vo', [email protected]',
 [email protected]', 'oi. [email protected]', 'loi. [email protected]', [email protected]', [email protected]',
 [email protected]', [email protected]', [email protected]', [email protected]', [email protected]',
 [email protected]', 'risitesh. [email protected]', [email protected]', [email protected]', [email protected]',
 [email protected]', [email protected]', [email protected]', [email protected]', [email protected]',
 [email protected]', [email protected]', [email protected]', [email protected]', [email protected]',
 [email protected]', [email protected]', [email protected]', [email protected]', [email protected] fes',
 [email protected]', [email protected]', [email protected]', [email protected]',
 [email protected]', [email protected]', [email protected]', [email protected]', [email protected]',
 [email protected]', [email protected]', [email protected]', [email protected]', [email protected]',
 [email protected]', 'endri. azizi. [email protected]', [email protected]', [email protected]',
 [email protected]', 'rob. test. [email protected]', [email protected] com', 'Agung. [email protected]',
 [email protected]', [email protected]', [email protected] fl', [email protected]', ];

Most of these are likely from the gap in time when Meeting Planner was new and sat idle—during my brain tumor treatment and surgery.

More recently, by adding the social logins, I’ve made signing up for Meeting Planner quite easy, but spam sign-ups are still possible. I wanted to make it more difficult for people to register with bad emails.

Fortunately, Yii offers a couple of features that support this.

Captcha

Yii2 now offers a built-in captcha. So, anyone signing up with the old school email and password method has to enter a captcha. You can see the captcha field below:

<p>Or, fill out the following fields to register manually:</p>
        <div class="col-lg-5">
            <?php $form = ActiveForm::begin(['id' => 'form-signup']); ?>
                <?= $form->field($model, 'username') ?>
                <?=
                $form->field($model, 'email', ['errorOptions' => ['class' => 'help-block' ,'encode' => false]])->textInput() ?>
                <?= $form->field($model, 'password')->passwordInput() ?>
                <?= $form->field($model, 'captcha')->widget(yiicaptchaCaptcha::classname(), [
                      // configure additional widget properties here
                  ]) ?>
                <div class="form-group">
                    <?= Html::submitButton('Signup', ['class' => 'btn btn-primary', 'name' => 'signup-button']) ?>
                </div>
            <?php ActiveForm::end(); ?>
        </div>

Then, compliance with the captcha is added as a rule for the SignupForm model:

<?php
namespace frontendmodels;

use commonmodelsUser;
use yiibaseModel;
use Yii;
use yiihelpersHtml;
use yiivalidatorsEmailValidator;

/**
 * Signup form
 */
class SignupForm extends Model
{
    public $username;
    public $email;
    public $password;
    public $captcha;

    /**
     * @inheritdoc
     */
    public function rules()
    {
        return [
            ['username', 'filter', 'filter' => 'trim'],
            ['username', 'required'],
            ['username', 'unique', 'targetClass' => 'commonmodelsUser', 'message' => 'This username has already been taken.'],
            ['username', 'string', 'min' => 2, 'max' => 255],
            ['email', 'filter', 'filter' => 'trim'],
            ['email', 'required'],
            ['email', 'email', 'checkDNS'=>true, 'enableIDN'=>true],
            ['email', 'unique', 'targetClass' => 'commonmodelsUser', 'message' => 'This email address has already been taken. '.Html::a('Looking for your password?', ['site/request-password-reset'])],
            ['password', 'required'],
            ['password', 'string', 'min' => 6],
            ['captcha', 'required'],
            ['captcha', 'captcha'],
        ];
    }

If people don’t enter the correct captcha response, they can’t sign up. This makes automated registration difficult for spammers.

CheckDNS

I also wanted to minimize registration with a fake email address. Yii’s checkDNS validation actually looks for a valid MX record based on the email address’s domain:

['email', 'email', 'checkDNS'=>true, 'enableIDN'=>true],

So, for example, if I mistyped gmail.com as gmal.com, checkDNS returns false. There is no registered MX record for gmal.com. Similarly, there is none for spambotolympics9922.com.

Ultimately, security is an iterative process. There’s always more to do.

Limiting Abusive Actions

Next, I wanted to add common limits to the number of actions people could perform, to limit abuse and to keep the application from becoming unwieldy.

Meeting Creation

To prevent people from creating lots of empty meetings, I created a findEmptyMeeting which looks for an empty meeting and reuses it when someone tries to create a new one:

public function actionCreate()
    {
        // prevent creation of numerous empty meetings
        $meeting_id = Meeting::findEmptyMeeting(Yii::$app->user->getId());
        //echo $meeting_id;exit;
        if ($meeting_id===false) {
        // otherwise, create a new meeting
          $model = new Meeting();
          $model->owner_id= Yii::$app->user->getId();
          $model->sequence_id = 0;
          $model->meeting_type = 0;
          $model->save();
          $model->initializeMeetingSetting($model->id,$model->owner_id);
          $meeting_id = $model->id;
        }
        $this->redirect(['view', 'id' => $meeting_id]);
    }

In other words, if a user goes to create a new meeting 1,700 times, they will always be presented with the first empty meeting they created.

Limiting Frequency of Actions

I also created a commonly structured withinLimit method for reuse around the application which could prevent too many actions in too short a time. The example below checks that no more than n number of meetings have been created in the last hour and last day:

public static function withinLimit($user_id,$minutes_ago = 180) {
      // how many meetings created by this user in past $minutes_ago
      $cnt = Meeting::find()
        ->where(['owner_id'=>$user_id])
        ->andWhere('created_at>'.(time()-($minutes_ago*60)))
        ->count();
      if ($cnt >= Meeting::NEAR_LIMIT ) {
        return false;
      }
      // check in last DAY_LIMIT
      $cnt = Meeting::find()
        ->where(['owner_id'=>$user_id])
        ->andWhere('created_at>'.(time()-(24*3600)))
        ->count();
      if ($cnt >= Meeting::DAY_LIMIT ) {
          return false;
      }
      return true;        
    }

Anytime someone tries to create a meeting, we check withinLimit to see if they can. If not, we show the flash error message:

public function actionCreate()
    {
        if (!Meeting::withinLimit(Yii::$app->user->getId())) {
          Yii::$app->getSession()->setFlash('error', Yii::t('frontend','Sorry, there are limits on how quickly you can create meetings. Visit support if you need assistance.'));
          return $this->redirect(['index']);
        }
    

Limiting the Number of Actions

I also wanted to limit the overall number of actions. For example, each meeting participant can only add seven meeting date times per meeting. Within MeetingTime.php, I set MEETING_LIMIT, so it can be changed later:

  const MEETING_LIMIT = 7;

Then, MeetingTime::withinLimit() checks to make sure that no more than seven times have been suggested by any user:

public static function withinLimit($meeting_id) {
      // how many meetingtimes added to this meeting
      $cnt = MeetingTime::find()
        ->where(['meeting_id'=>$meeting_id])
        ->count();
        // per user limit option: ->where(['suggested_by'=>$user_id])
      if ($cnt >= MeetingTime::MEETING_LIMIT ) {
        return false;
      }
      return true;
    }

When they go to create a MeetingTime, the controller create method checks the limits:

public function actionCreate($meeting_id)
    {
      if (!MeetingTime::withinLimit($meeting_id)) {
        Yii::$app->getSession()->setFlash('error', Yii::t('frontend','Sorry, you have reached the maximum number of date times per meeting. Contact support if you need additional help or want to offer feedback.'));
        return $this->redirect(['/meeting/view', 'id' => $meeting_id]);
      }
    

Securing CRON Jobs

Finally today, I wanted to secure access to remote cron jobs. There are some interesting approaches described out on the interwebs. For now, I’m checking that the $_SERVER['REMOTE_ADDR'] (the requesting IP address) is the same server as the hosting $_SERVER['SERVER_ADDR'], the local IP address.  $_SERVER['REMOTE_ADDR'] is safe to use for security—in other words, I’ve read that it can’t be spoofed.

  // only cron jobs and admins can run this controller's actions
    public function beforeAction($action)
    {
      // your custom code here, if you want the code to run before action filters,
      // which are triggered on the [[EVENT_BEFORE_ACTION]] event, e.g. PageCache or AccessControl
      if (!parent::beforeAction($action)) {
          return false;
      }
      // other custom code here
      if (( $_SERVER['REMOTE_ADDR'] == $_SERVER['SERVER_ADDR'] ) ||
          (!Yii::$app->user->isGuest && commonmodelsUser::findOne(Yii::$app->user->getId())->isAdmin()))
       {
         return true;
       }
      return false; // or false to not run the action
    }

For my own testing, I also allow a logged-in administrator to run cron jobs.

Eventually, I may also add a password to my cron jobs and move them to command-line operations.

Looking Ahead

I’ve accomplished a lot of security improvements over the past two episodes, but there’s still more to do. On my shortlist is deeper review of access security, especially via AJAX, IP address tracking and blocking, and carefully filtering all user input.

Again, what are you waiting for? Schedule your first meeting, and share your feedback in the comments. I’d also appreciate your comments on security issues.

As always, you can watch for upcoming tutorials in the Building Your Startup With PHP series or follow me @reifman. There are a few more big features coming up.

Related Links