angle-uparrow-clockwisearrow-counterclockwisearrow-down-uparrow-leftatcalendarcard-listchatcheckenvelopefolderhouseinfo-circlepencilpeoplepersonperson-fillperson-plusphoneplusquestion-circlesearchtagtrashx

Masquer les clés primaires de la base de données UUID de votre application web

Ne permettez pas à d'autres personnes de jouer avec les données que votre application web expose.

29 mars 2024
Dans Flask, Security
post main image
https://unsplash.com/@davidkristianto

Lorsque vous créez une application web, vous devez faire très attention à ne pas exposer trop d'informations. Si vous utilisez (auto-incrément) Integer IDs dans votre base de données, vous en exposez probablement déjà trop. Quelques exemples. Une Integer user_id permet de deviner facilement le nombre de nouveaux enregistrements que vous recevez chaque jour. Un Integer order_id permet de deviner le nombre de commandes que vous recevez chaque jour.

En outre, les visiteurs peuvent essayer de décrémenter ou d'incrémenter ces valeurs dans URLs ou dans des formulaires. Si vous ne disposez pas de la logique appropriée, ils peuvent être en mesure de voir les user précédemment enregistrés, ainsi que les commandes antérieures.

Bon nombre de ces problèmes disparaissent lorsque l'on utilise UUID4s comme clé primaire. Il est alors impossible de deviner les valeurs précédentes. Mais vous devez toujours vous prémunir contre les tentatives de devinettes par force brute.

Mais même les UUID4s ne doivent pas être dévoilées. Il existe de nombreuses méthodes pour cacher votre IDs, j'en présente une autre ici. L'hypothèse est que vous utilisez déjà UUID4s comme clés primaires. Notez qu'il n'y a pas vraiment de solutions faciles ici.

Comme toujours, je le fais sur Ubuntu 22.04.

Comment cela fonctionne-t-il ?

L'hypothèse est que vous utilisez déjà UUID4s pour les clés primaires (et les clés étrangères).

Encodage

Avant d'envoyer les données du serveur au client, nous encodons le IDs dans les données :

  • Nous remplaçons toutes les valeurs de la clé primaire UUID4s , 'from_ids', par de nouvelles valeurs UUID4s , 'to_ids'.
  • Cette valeur originale et la nouvelle valeur UUIDs sont stockées dans une nouvelle valeur "from_to_record".
  • Ce "from_to_record" est écrit dans la base de données.
from_to_record = {
    'id': <uuid4>,
    'user_account_id': <user_account_id>,
    'created_on': datetime.datetime.utcnow(),
    'to_id_from_ids': {
        <to_id1>: <from_id1>,
        <to_id2>: <from_id2>,
        ...
    },
}

Note(s) :

  • Un nouveau "from_to_record" est créé à chaque encodage.
  • Les 'from_to_records' ne changent jamais, ils sont créés une fois et restent valables jusqu'à leur expiration.
  • Nous ne stockons pas 'from_id_to_ids' mais 'to_id_from_ids'. La raison en est que nous utilisons 'from_to_records' uniquement pour rechercher un 'from_id' à partir d'un 'to_id'.
  • Ici, nous montrons un seul "from_to_record" avec un type de colonne JSONB pour stocker plusieurs paires. Il est bien sûr possible d'avoir plusieurs enregistrements à la place.
  • Un 'user_account_id' relie un enregistrement à un user spécifique.

Décodage

Lorsque nous recevons des données du client :

  • Tout d'abord, nous obtenons des 'from_to_records' non expirés de la base de données en utilisant les champs 'user_account_id' et 'created_on'.
  • Ensuite, à l'aide de ces enregistrements, nous remplaçons le 'to_ids' dans les données du client par le 'from_ids'.

Avantages et inconvénients

Comme nous l'avons déjà mentionné, quelle que soit la méthode que vous souhaitez utiliser, vous devez effectuer un travail supplémentaire. Voici quelques avantages et inconvénients de cette méthode :

Pour :

  • Remplacement simple.
  • Presque aucun changement chez le client.
  • La génération de nouveaux UUID4s est une fonction optimisée.
  • Nous ne touchons pas aux enregistrements originaux.
  • Restrictions temporelles faciles à mettre en place en utilisant une date de création ou un délai d'attente.

Inconvénients :

  • Nécessite une base de données.
  • Pas très rapide.

Exemple : Application Flask avec un formulaire

L'exemple très limité ci-dessous montre comment cela fonctionne. Dans cet exemple, nous pouvons lister et modifier les membres sans exposer les clés primaires réelles.

Nous utilisons Flask-Caching (FileSystemCache) comme base de données pour les membres et pour 'from_to_records'. En règle générale, vous utiliserez un système de base de données réel pour les membres et quelque chose comme Redis pour 'from_to_records'.

Créez un virtual environment et ensuite :

pip install flask
pip install Flask-Caching

Il existe trois classes :

  • 'IdFromTo'
    Utilisée pour récupérer et sauvegarder 'from_to_records' et pour traduire le 'from_ids' en 'to_ids', et vice-versa.
  • 'Db'
    Requêtes de la base de données
  • 'DbWrapper'
    Nos nouvelles méthodes gérant les 'from_ids' et 'to_ids' dans les requêtes.

L'arbre du projet :

.
├── project
│   ├── app
│   │   └── factory.py
│   └── run.py

Voici les deux fichiers :

# run.py
from app.factory import create_app

host = '127.0.0.1'
port = 5050

app = create_app()
app.config['SERVER_NAME'] = host + ':' + str(port)

if __name__ == '__main__':
    app.run(
        host=host,
        port=port,
        use_debugger=True,
        use_reloader=True,
    )
# factory.py
import datetime
import logging
import uuid
import os
import sys
from flask import current_app, Flask, redirect, render_template, request, url_for
from flask_caching import Cache

cache = Cache()

logging.basicConfig(
    format='%(asctime)s %(levelname)8s [%(filename)-15s%(funcName)15s():%(lineno)03s] %(message)s',
    level=logging.DEBUG,
)
logger = logging.getLogger()

# use string uuid
def get_uuid4():
    return str(uuid.uuid4())

class IdFromTo:
    def __init__(self, user_account_id):
        self.user_account_id = user_account_id
        self.expire_seconds = 30
        self.expired_on = datetime.datetime.utcnow() - datetime.timedelta(seconds=self.expire_seconds)
        self.from_to_records_loaded = False
        self.from_to_records = []
        self.from_ids_to_ids = {}

    def load_from_to_records(self):
        # filter here, we do not have a real database
        for from_to_record in cache.get('from_to_records') or []:
            if from_to_record['created_on'] < self.expired_on:
                logger.debug(f'expired, skipping from_to_record = {from_to_record} ...')
                continue
            if from_to_record['user_account_id'] != self.user_account_id:
                logger.debug(f'not a dataset of me, skipping from_to_record = {from_to_record} ...')
                continue
            self.from_to_records.append(from_to_record)
        from_to_records_len = len(self.from_to_records)
        logger.debug(f'from_to_records_len = {from_to_records_len}')

    # get from_id: match with previously saved from_to_records
    def get_from_id(self, to_id):
        if not self.from_to_records_loaded:
            self.load_from_to_records()
            self.from_to_records_loaded = True
        for from_to_record in self.from_to_records:
            to_id_from_ids = from_to_record['to_id_from_ids']
            if to_id in to_id_from_ids:
                return to_id_from_ids[to_id]
        logger.debug(f'not found in to_id_from_ids, to_id = {to_id}')
        return None

    # get to_id: create new/append to from_ids_to_ids
    def get_to_id(self, from_id):
        from_id = str(from_id)
        if from_id in self.from_ids_to_ids:
            # already created 
            logger.debug(f'use already created to_id_for from_id = {from_id}')
            return self.from_ids_to_ids[from_id]
        logger.debug(f'create new to_id_for from_id = {from_id}')
        to_id = get_uuid4()
        self.from_ids_to_ids[from_id] = to_id
        return to_id

    def save(self):
        # load, append, overwrite
        from_to_records = cache.get('from_to_records') or []
        # swap
        from_ids_to_ids_len = len(self.from_ids_to_ids)
        if from_ids_to_ids_len == 0:
            return
        to_id_from_ids = {}
        for from_id, to_id in self.from_ids_to_ids.items():
            to_id_from_ids[to_id] = from_id
        from_to_record = {
            'id': get_uuid4(),
            'user_account_id': self.user_account_id,
            'created_on': datetime.datetime.utcnow(),
            'to_id_from_ids': to_id_from_ids,
        }
        from_to_records.append(from_to_record)
        cache.set('from_to_records', from_to_records)

class Db:
    def __init__(self):
        # initial members
        self.members = [{
            'id': 'b5ff1840-38a8-44cc-8f54-730dcf0b1358',
            'name': 'John',
        },{
            'id': '27e14ff0-7620-4e17-9fa7-f491bab22c8a',
            'name': 'Jane',
        }]

    def get_members(self):
        members = cache.get('db_members')
        if members is None:
            # first time only
            cache.set('db_members', self.members)
        return cache.get('db_members')

    def get_member(self, member_id):
        for member in cache.get('db_members') or []:
            if member['id'] == member_id:
                return member
        return None

    def update_member(self, member_id, name):
        members = cache.get('db_members')
        for member in members:
            if member['id'] == member_id:
                member['name'] = name
                cache.set('db_members', members)
                return member
        return None

class DbWrapper:
    def __init__(self, user_account_id):
        self.user_account_id = user_account_id
        self.db = Db()

    def get_members(self):
        id_from_to = IdFromTo(self.user_account_id)
        members = []
        for member in self.db.get_members() or []:
            to_id = id_from_to.get_to_id(member['id'])
            members.append({
                'id': to_id,
                'name': member['name']
            })
        id_from_to.save()
        return members

    def get_member(self, to_id):
        id_from_to = IdFromTo(self.user_account_id)
        from_id = id_from_to.get_from_id(to_id)
        member = self.db.get_member(from_id)
        if member is None:
            return None
        return {
            'id': to_id,
            'name': member['name'],
        }

    def update_member(self, to_id, name):
        # do not swap change to_id
        id_from_to = IdFromTo(self.user_account_id)
        from_id = id_from_to.get_from_id(to_id)
        member = self.db.update_member(from_id, name)
        return {
            'id': to_id,
            'name': member['name'],
        }
    
def create_app():
    app = Flask(__name__, instance_relative_config=True)

    app.config.update({
        'CACHE_TYPE': 'FileSystemCache',
        'CACHE_DEFAULT_TIMEOUT': 3600,
        'CACHE_DIR': '.'
    })
    cache.init_app(app)

    user_account_id = '47742ae4-67bd-4164-b044-e893344c861c'

    db = DbWrapper(user_account_id)

    @app.route('/')
    def home():
        return redirect(url_for('members'))

    @app.route('/members', methods=['GET', 'POST'])
    def members():
        members = db.get_members()
        page_data_lines = ['Members:']
        for member in members:
            member_id = member['id']
            member_name = member['name']
            member_edit_url = url_for('member_edit', member_id=member_id)
            page_data_lines.append(f'<a href="{member_edit_url}">{member_name}</a> ({member_id})')
        return f"""{'<br>'.join(page_data_lines)}"""

    @app.route('/member/edit/<member_id>', methods=['GET', 'POST'])
    def member_edit(member_id):
        member = db.get_member(member_id)
        members_url = url_for('members')
        if member is None:
            return f"""Error: Expired?
                <a href="{members_url}">Start again</a>"""
        name = member['name']
        logger.debug(f'member = {member}')
        error = ''
        if request.method == 'POST':
            name = request.form.get('name').strip()
            if len(name) > 0:
                member = db.update_member(member_id, name)
                return redirect(url_for('members'))
            error = 'Error: enter a name'
        members_url = url_for('members')
        return f"""
            <a href="{members_url}">Members</a><br><br>
            Edit member:<br>
            <form method="post">
            <input type="text" name="name" value="{name}"><br>
            <input type="submit" name="button" value="Update">
            </form>
            {error}
            """

    return app

Pour les exécuter, allez dans le répertoire du projet et tapez :

python run.py

Pointez ensuite votre navigateur sur :

127.0.0.1:5050

Résumé

Il y a plusieurs façons de cacher votre base de données IDs. La solution présentée est pour une base de données qui utilise des clés primaires UUID4 .

Avant d'envoyer les données au client, les valeurs primaires UUID4s sont remplacées par de nouvelles UUID4s, et les valeurs d'origine et de remplacement sont stockées. Lorsque les données sont reçues du client, nous chargeons les valeurs d'origine et de remplacement et les remplaçons. Si nous attendons trop longtemps, les valeurs expirent et nous devons recommencer.

Le client n'a besoin que de changements minimes.

Liens / crédits

Hiding, obfuscating or encrypting database IDs
https://bhch.github.io/posts/2021/07/hiding-obfuscating-or-encrypting-database-ids

En savoir plus...

Flask Security

Laissez un commentaire

Commentez anonymement ou connectez-vous pour commenter.

Commentaires

Laissez une réponse

Répondez de manière anonyme ou connectez-vous pour répondre.