DEV Community

Cover image for How to Build a Secure Web App with Strapi Policies
Theodore Kelechukwu Onyejiaku
Theodore Kelechukwu Onyejiaku

Posted on • Originally published at strapi.io

How to Build a Secure Web App with Strapi Policies

How to Build a Secure Web App with Strapi Policies

Outline

  • Introduction
  • Overview of Policies
  • Scenario
  • Implementing Consent Policy
  • Implementing Policy for Rate Limiting
  • Implementing Policy for IP Addresses
  • Implementing Input Validation Policy
  • Account Lockout Policy
  • Implementing Authorization and Authentication Policy
  • Github Code
  • Conclusion
  • Resources

Introduction

In this article, we will explore how to build a secure web application using the power of Strapi policies. With Strapi you can build and secure your web application using customizable policies including caching, rate limiting, data validation, authentication and authorization, ip whitelisting and much more. By the end of the article, you will have a solid understanding of how to build a secure web application with Strapi policies.

The primary focus of this article is the API security of a web application.

Overview of Policies

Policies are sets of rules, guidelines, or predefined actions that govern various aspects of behavior, access, and decision-making within a system, organization, or application.

Policies are functions that execute specific logic on each request before it reaches the controller. They are mostly used for securing business logic.

They are mostly for read-only operations. They are used to perform checks to ensure that the rules or specified guidelines are met.

When creating a Strapi Policy , a true or false is returned. The latter is for when the policy is met and the former is when the policy is not met. We could also throw a policy error in place of the false .

Let us head on to create some policies that will help secure an API.

Scenario

The project of this article is a location based application. A user can create locations that are interesting and fun to visit so others can see it.

Using this idea, let us implement some policies.

Implementing Consent Policy

A consent policy helps ensure that individuals have given informed and explicit consent before their data is collected, processed, or used in any way.

In a case where the user hasn’t consented, as shown below, they won’t be able to make requests to the locations route.

001-policies-nextjs.png

Head over to the policies folder inside the src folder and create a file called consent.js . Add the following code:

// ./src/policies/consent.js
const { errors } = require("@strapi/utils");
const { PolicyError } = errors;

module.exports = async (policyContext, config, { strapi }) => {
  if (policyContext.state.user.agreeToPolicy) {
    return true;
  } else {
    const user = policyContext.state.user.username;
    throw new PolicyError(
      `Hi ${user}, Please agree to terms and conditions to continue.`,
      {
        policy: "consent-policy",
      }
    );
  }
};
Enter fullscreen mode Exit fullscreen mode

In the policy file above, we checked if the user jane_doe has agreed to the terms and conditions of our application using the property agreeToPolicy. If true, they are allowed to access a particular route; otherwise, a content policy error is thrown.

Now add this as a global policy to the location route.


// ./src/api/location/routes/location.js
module.exports = createCoreRouter("api::location.location", {
  config: {
    find: {
      middlewares: [
        ...
      ],
      policies: [
        "global::consent",
        "global::account-locked",
        "ip",
        "rate-limit",
      ],
    },
    create: {
      policies: ["location-input"],
    },
  },
});

Enter fullscreen mode Exit fullscreen mode

Now, when jane_doe makes a request to the location route, she gets this response:

002-policies-nextjs.png

Implementing Policy For Rate Limiting

Here, we will implement a policy to check if a user has subscribed to a plan. With this policy we will add a middleware that will handle the number of requests a user can make in a day.

"If you provide an API client that doesn't include rate limiting, you don't really have an API client. You've got an exception generator with a remote timer." – Richard Schneeman

Locate the location folder in the ./src/api/ folder and create a folder named policies. Inside this new folder, create a file named rate-limit.js and add the following code:

// ./src/api/location/policies/rate-limit.js
const { errors } = require("@strapi/utils");
const { PolicyError } = errors;

module.exports = async (policyContext, config, { strapi }) => {
  if (policyContext.state.user.isSubscribed) {
    return true;
  } else {
    const user = policyContext.state.user.username;
    throw new PolicyError(`Hi ${user} Please subscribe to view locations`, {
      policy: "rate-limit",
    });
  }
};
Enter fullscreen mode Exit fullscreen mode

In order for the policy to work, we need to add it to the locations route middleware that handles the request to get locations. Inside the location folder, locate the location.js file inside the routes folder and add the following code:

// ./src/api/location/routes/location.js
"use strict";

/**
 * location router
 */
const { createCoreRouter } = require("@strapi/strapi").factories;
const { rateLimit } = require("express-rate-limit");

// rate limiting
const fiveReqLimits = rateLimit({
  windowMs: 24 * 60 * 60 * 1000, // 1 day,
  max: 5, // 5 requests,
  // get ip address of user since it is required
  keyGenerator: (request, response) => {
    let ip = strapi.requestContext.get().request.ip;
    return ip;
  },
  handler: async (request, response, next) => {
    const ctx = strapi.requestContext.get();
    ctx.status = 403;
    ctx.body = {
      message:
        "You have exhausted your 5 requests for the day. Please check back tomorrow.",
      policy: "rate-limit",
    };
  },
});

const tenReqLimits = rateLimit({
  windowMs: 24 * 60 * 60 * 1000, // 1 day,
  max: 10, // 10 requests,
  handler: async (request, response, next) => {
    const ctx = strapi.requestContext.get();
    ctx.status = 403;
    ctx.body = {
      message:
        "You have exhausted your 10 requests for the day. Please check back tomorrow.",
      policy: "rate-limit",
    };
  },
});

module.exports = createCoreRouter("api::location.location", {
  config: {
    find: {
      middlewares: [
        async (ctx, next) => {
          const ip = ctx.request.ip;
          if (ctx.state.user.plan === "basic") {
            fiveReqLimits(ctx.req, ctx.res, (error) => {
              if (error) {
                ctx.status = 500;
                ctx.body = { error: error.message };
              }
            });
          } else {
            tenReqLimits(ctx.req, ctx.res, (error) => {
              if (error) {
                ctx.status = 500;
                ctx.body = { error: error.message };
              }
            });
          }
          await next();
        },
      ],
      policies: ["rate-limit"],
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

In the code above, we added a middleware that will handle the find method of the /locations route. This means that this middleware handles the GET HTTP request to the /locations route. If the user is on the basic plan, we invoke the rate limit for five requests a day. Else, we invoke the rate limit of ten request per day. And we included the rate-limit policy in line 68.

NOTE: The name of our policy file rate-limit.js should be the same as the name of our policy rate-limit that we added.

Using the following data:

003-policies-nextjs.png

As we can see, user1 is on the free plan with the unSubscribed field as false. user2 and user3 are both subscribed and on the basic and standard plans.

For the basic plan, a user can only make requests 5 times a day. Unlike the standard plan which is 10 times a day.

When user1 makes request to get random locations, they get a policy error that they need to subscribe.

003-5-policies-nextjs.png

When user2 makes a request to get random locations, their rate limit decreases by 1.

004-policies-nextjs.png

As we can see, on the first request, their number of requests, X-RateLimit-Limit, is reduced to 4, which is the X-RateLimit-Remaining.

When user3 makes a request to get random locations:

005-policies-nextjs.png

Similar to user2, user3 will only have 9 requests remaining.

When the maximum request limit has been reached, a policy error is thrown, as shown below:

006-policies-nextjs.png

Implementing Policy for IP Addresses

With a Strapi Policy, we can whitelist or backlist one or more IP addresses. This is to protect and filter out illegitimate or malicious IP addresses from your website. filter out illegitimate or malicious IP addresses.

Locate the policies folder and create a new policy file called ip.js. Inside this file, add the code below:

// ./src/api/location/policies/rate-limit.js
const { errors } = require("@strapi/utils");
const { PolicyError } = errors;
const ip = require("koa-ip");

// ip addresses to blacklist
const blackList = ["77.88.99.1", "88.77.99.1", "127.0.0.1"];

module.exports = async (policyContext, config, { strapi }) => {
  const ip = policyContext.request.ip;
  if (blackList.indexOf(ip) > -1) {
    const user = policyContext.state.user.username;
    throw new PolicyError(
      `Hi ${user} Please this resource is not available to you`,
      {
        policy: "ip-blacklist",
      },
    );
  } else {
    return true;

};
Enter fullscreen mode Exit fullscreen mode

In the code above, we created an array blacklist that will hold the IP addresses we want to block. Then we checked if the IP of the user, using policyContext.request.ip , making the request is any of the IPs. If it is in the blacklist, we throw a policy error, else the request will go through.

Now locate inside the location.js file inside the ./src/api/location/routes/ folder and add the ip policy to it. Ensure that it is before the rate-limit policy. This is so that it is first checked before the rate-limit policy is called.

policies: ["ip", "rate-limit"]
Enter fullscreen mode Exit fullscreen mode

When a user with any of the blacklisted IPs make a request, they get the following:

007-policies-nextjs.png

Implementing Input Validation Policy

Though we could use the beforeCreate / beforeUpdate methods we could also set up policy for input validation.

We will implement a policy that validates inputs from the request body are valid before reaching our controller using joi validation package.

NOTE: Strapi can handle this validations on its own. Yet a policy can be added before Strapi validates the inputs.

Create another policy called location-input.js inside the policies folder in the path ./src/api/location/ . Add the following code:

// ./src/api/location/policies/location-input.js
const { errors } = require("@strapi/utils");
const { PolicyError } = errors;
const Joi = require("joi");

const schema = Joi.object({
  name: Joi.string().min(3).max(30).required(),

  address: Joi.string().min(5).max(100).required(),
});

module.exports = async (policyContext, config, { strapi }) => {
  const { data } = policyContext.request.body;
  const validationError = schema.validate(data)?.error?.details[0]?.message;
  if (validationError) {
    throw new PolicyError(validationError, {
      policy: "location-creation",
    });
  }
  return true;
};
Enter fullscreen mode Exit fullscreen mode

In the code above, we created a schema that specifies the validations for the name and address fields. These fields are needed to create a new location in our location model. We validated the body of the request using this schema. If there is a validation error, we throw a policy error with the validation error message.

Next, we add this policy to the create function of the location route configuration.

// ./src/api/location/routes/location.js
  ...
  module.exports = createCoreRouter("api::location.location", {
    config: {
      find: {
        ...
        // policies
        policies: [],
      },
      create: {
        policies: ["location-input"],
      },
    },
  });
Enter fullscreen mode Exit fullscreen mode

Sending the name field as a number instead of a string.

008-policies-nextjs.png

009-policies-nextjs.png

This will throw the policy error:
When the input is correct and because we set the name field to be unique in Strapi, it will throw an error because we tried to create a new location with a name that already exists.

010-policies-nextjs.png

Account Lockout Policy

Here, we will implement the policy to check if a user’s account is blocked until after some time. We will be using a 7 day period of lockout. If a user violates the terms and conditions of the application, their access to resources will be revoked for 7 days.

This policy will be a global policy. For this reason, inside the src folder, create a new folder policies . Inside this new folder, create a file called account-locked.js and add the following code:

// ./src/policies/account-locked.js
const { errors } = require("@strapi/utils");
const { PolicyError } = errors;
const { DateTime } = require("luxon");

module.exports = async (policyContext, config, { strapi }) => {
  const dateOfLockout = DateTime.fromISO(
    policyContext.state.user?.dateOfLockout
  );
  const luxonDateTime = DateTime.now();

  const daysDifference = luxonDateTime
    .diff(dateOfLockout, ["days"])
    .toObject()?.days;

  // if lockout period has been exceeded
  if (daysDifference > 7) {
    return true;
  } else {
    let daysRemaining = dateOfLockout.day + 7 - luxonDateTime.day;
    const user = policyContext.state.user.username;

    throw new PolicyError(
      `Hi ${user}. Your account has been blocked. Check back in ${daysRemaining.toFixed()} days time`,
      {
        policy: "account-locked",
      }
    );
  }
};
Enter fullscreen mode Exit fullscreen mode

In the code above, we made use of the luxon NPM package for our date formatter. We got the date when the user was locked out and the current date and time. We checked if the expiration period has elapsed. And made sure that they can now make requests, otherwise we throw the user a Policy error by adding the number of days remaining for the user’s account to be unlocked.

Next, we add this policy to the location route as a global policy.

// ./src/api/location/routes/location.js
...
module.exports = createCoreRouter("api::location.location", {
  config: {
    find: {
      middlewares: [
        ...
      ],
      policies: ["global::account-locked", "ip", "rate-limit"],
    },
    create: {
      policies: ["location-input"],
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

When we a user’s account is blocked they get the following response as shown below:

011-policies-nextjs.png

Implementing Authorization and Authentication Policy

In this scenario, we have to create a policy that allows only a user with the field isAdmin to modify the location route.

Take a look at the user ted below whose isAdmin field is set to false. This means he is not an admin.

012-policies-nextjs.png

Now, locate the policies folder in the path ./src/api/location and create a file called is-admin . Inside this new file, add the code below:

// path: ./src/api/location/policies/is-admin.js
const { errors } = require("@strapi/utils");
const { PolicyError } = errors;

module.exports = async (policyContext, config, { strapi }) => {
  // check if user is admin
  if (policyContext.state.user.isAdmin) {
    // Go to controller's action.
    return true;
  }
  // if not admin block request
  const user = policyContext.state.user.username;
  throw new PolicyError(`Hi ${user}, you are not allowed to update this data`, {
    policy: "admin-policy",
  });
};
Enter fullscreen mode Exit fullscreen mode

Locate the location route file and add this policy to the update function controller.

// ./src/api/location/routes/location.js
module.exports = createCoreRouter("api::location.location", {
  config: {
    find: {
      middlewares: [
        ...
      ],
      policies: [
        "global::consent",
        "global::account-locked",
        "ip",
        "rate-limit",
      ],
    },
    create: {
      policies: ["location-input"],
    },
    update: {
      policies: ["is-admin"],
    },
  },
});

Enter fullscreen mode Exit fullscreen mode

Next, the user ted , who is not an admin, wants to update the address of the location with id as 1 below:

013-policies-nextjs.png

014-policies-nextjs.png

Now when ted makes a request, they get the following:
As we can see above, they are forbidden to make this request. When the user is an admin the request can successfully be executed as shown below:

015-policies-nextjs.png

016-policies-nextjs.png

Github Code

The complete code for this article can be found here: https://github.com/Theodore-Kelechukwu-Onyejiaku/strapi-policy-usecases

Conclusion

There are lots of ways to ensure API and web security and Strapi Policies help in achieving this. With Strapi Policies, we have been able to demonstrate the use of policy for authorization, rate limiting, IP filtering, input validation, account lockout, and so on to ensure web security of our applications. Join the Strapi Discord community for more.

Resources

Top comments (0)