Secure software development using Python

Claudio Salazar

About me

  • 8 years developing in Python
  • Expertise in:
    • web scraping
    • vulnerability research
    • secure software development

Agenda

  1. Designing a plugin system
  2. Testing to ensure security guarantees

Designing a plugin system

Requirements

  • Plugins are Python files
  • Prevent access to application's code (RCE)
  • Prevent access to network resources (SSRF)
  • Prevent side-effects of plugin executions (data)
  • Prevent exhausting system resources (DoS)
  • Prevent to run forever (DoS)
  • Prevent side-effects of the solution

Solution

We're going to use a docker container through Docker SDK for Python

Solution

import docker

client = docker.from_env()
input_including_plugin_code = ...

container = client.containers.run(
    "my-image", 
    input_including_plugin_code,
    detach=True,
)
  • Prevent access to application's code
  • Prevent side-effects of plugin executions 

Solution

...

container = client.containers.run(
    "my-image",
    input_including_plugin_code,
    detach=True,
    network_disabled=True,
)

Prevent access to network resources

Solution

...

container = client.containers.run(
    "my-image", 
    input_including_plugin_code,
    detach=True,
    network_disabled=True,
    nano_cpus=25 * 10**7,
    mem_limit="128m",
)

Prevent exhausting system resources

Solution

import requests

...

MAX_SECONDS_TO_RUN = 10
try:
    container.wait(timeout=MAX_SECONDS_TO_RUN)
except requests.exceptions.ConnectionError:
    container.stop(timeout=0)

Prevent to run forever (DoS)

Solution

import requests

...

MAX_SECONDS_TO_RUN = 10
try:
    container.wait(timeout=MAX_SECONDS_TO_RUN)
except requests.exceptions.ConnectionError:
    container.stop(timeout=0)

# process container output
...  
  
container.remove()

Prevent side-effects of the solution

Testing to ensure security guarantees

Let's create an app where

a user have orders

Defining an Order model

from django.contrib.auth import get_user_model
from django.db import models


class Order(models.Model):
    price = models.IntegerField()
    user = models.ForeignKey(
      get_user_model(), 
      on_delete=models.CASCADE
    )
    secret_code = models.CharField(max_length=20)

Django settings

REST_FRAMEWORK = {
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticated',
    ]
}

Defining the endpoint

from rest_framework import serializers, viewsets

from .models import Order


class OrderSerializer(serializers.ModelSerializer):
    class Meta:
        model = Order
        fields = ["id", "price"]


class OrderViewSet(viewsets.ReadOnlyModelViewSet):
    def get_queryset(self):
        return Order.objects.filter(user=self.request.user)

    serializer_class = OrderSerializer

Current solution

  • Only authenticated users can access it
  • Order's secret_code is never exposed
  • Orders can be only listed and retrieved
  • Users can only access their own orders

Code changes over time?

Test #1

def test_reject_anonymous_request(api_client):
    orders_url = reverse("orders-list")
    res = api_client(anon=True).get(orders_url)

    assert res.status_code == status.HTTP_403_FORBIDDEN

Test the defaults that you rely on

6 months later

diff --git a/settings.py b/settings.py
index d41ee09..0aa5d51 100644
--- a/settings.py
+++ b/settings.py
@@ -1,5 +1,5 @@
 REST_FRAMEWORK = {
     "DEFAULT_PERMISSION_CLASSES": [
-        "rest_framework.permissions.IsAuthenticated",
+        "rest_framework.permissions.AllowAny",
     ]
 }

A new decision changes the default policy

Test #2

def test_secret_code_is_not_returned(api_client):
    user = UserFactory.create()
    order = OrderFactory.create(user=user)

    orders_url = reverse("orders-detail", args=(order.id,))
    res = api_client(user).get(orders_url)

    assert "secret_code" not in res.json()

Test you're not exposing sensitive data

8 months later

diff --git a/demo/orders/views.py b/demo/orders/views.py
index f42cec0..38e473d 100644
--- a/demo/orders/views.py
+++ b/demo/orders/views.py
@@ -6,7 +6,7 @@ from .models import Order
 class OrderSerializer(serializers.ModelSerializer):
     class Meta:
         model = Order
-        fields = ["id", "price"]
+        fields = "__all__"

There are more fields to expose

Test #3

def test_only_list_retrieve(api_client):
    user = UserFactory.create()
    orders_url = reverse("orders-list")
    res = api_client(user).options(orders_url)

    assert res._headers["allow"][1] == "GET, HEAD, OPTIONS"

Test you only expose desired methods

1 year later

diff --git a/demo/orders/views.py b/demo/orders/views.py
index f42cec0..a4b9da0 100644
--- a/demo/orders/views.py
+++ b/demo/orders/views.py
@@ -9,7 +9,7 @@ class OrderSerializer(serializers.ModelSerializer):
         fields = ["id", "price"]
 
 
-class OrderViewSet(viewsets.ReadOnlyModelViewSet):
+class OrderViewSet(viewsets.ModelViewSet):
     def get_queryset(self):
         return Order.objects.filter(user=self.request.user)

Let's get ModelViewSet for every endpoint

Test #4

def test_user_cant_access_another_user_order(api_client):
    user_1 = UserFactory.create()
    user_2 = UserFactory.create()
    order = OrderFactory.create(user=user_1)

    orders_url = reverse("orders-detail", args=(order.id,))
    res = api_client(user_2).get(orders_url)

    assert res.status_code == status.HTTP_404_NOT_FOUND

Test that everything works as expected

IDOR

More cases? Parametrize

@pytest.mark.parametrize(
    "username,authenticated,status_code",
    [
        ("anon", False, 403),
        ("user_1", True, 404),
        ("user_from_other_group", True, 404),
        ...,
    ],
)
def test_access_to_resource(username, authenticated, status_code):
    user = get_user(username, authenticated)
    res = api_client(user).get("/endpoint")

    assert res.status_code == status_code

The goal

Q & A

Gracias!

Made with Slides.com