Database UUID primaire sleutels van je webapplicatie verbergen
Maak het anderen niet gemakkelijk om te spelen met de gegevens die je webapplicatie blootstelt.
Als je een webapplicatie maakt, moet je heel voorzichtig zijn dat je niet te veel informatie vrijgeeft. Als je (auto-increment) Integer IDs gebruikt in je database, dan geef je waarschijnlijk al te veel prijs. Enkele voorbeelden. Een Integer user_id maakt het makkelijk om te raden hoeveel nieuwe inschrijvingen je elke dag ontvangt. Met een Integer order_id kun je gemakkelijk raden hoeveel bestellingen je elke dag ontvangt.
Bovendien kunnen bezoekers proberen deze waarden te verlagen of te verhogen in URLs of formulieren. Als je niet over de juiste logica beschikt, kunnen ze eerder geregistreerde user's en eerdere bestellingen zien.
Veel van deze problemen verdwijnen als UUID4s als primaire sleutels worden gebruikt. Dit maakt het onmogelijk om eerdere waarden te raden. Maar je moet je nog steeds wapenen tegen brute force raden.
Maar zelfs UUID4s willen we soms niet blootgeven. Er zijn veel methoden om je IDs te verbergen, hier presenteer ik er nog een. De aanname is dat je UUID4s al gebruikt als primaire sleutels. Merk op dat er hier niet echt gemakkelijke oplossingen zijn.
Zoals altijd doe ik dit op Ubuntu 22.04.
Hoe het werkt
De aanname is dat je UUID4s al gebruikt voor primaire sleutels (en foreign keys).
Codering
Voordat we de gegevens van de server naar de client sturen, coderen we de IDs in de gegevens:
- We vervangen alle primaire sleutel UUID4s waarden, de 'from_ids', door nieuwe UUID4s waarden, de 'to_ids'.
- Deze oorspronkelijke en nieuwe UUIDs worden opgeslagen in een nieuw gecreëerde "from_to_record".
- Deze 'from_to_record' wordt naar de database geschreven.
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>,
...
},
}
Opmerking(en):
- Bij elke codering wordt een nieuwe 'from_to_record' aangemaakt.
- from_to_records' veranderen nooit, ze worden eenmalig aangemaakt en blijven geldig tot ze verlopen.
- We slaan 'from_id_to_ids' niet op, maar 'to_id_from_ids'. De reden hiervoor is dat we 'from_to_records' alleen gebruiken om een 'from_id' op te zoeken uit een 'to_id'.
- Hier tonen we een enkele 'from_to_record' met een JSONB kolomtype om meerdere paren op te slaan. In plaats daarvan kun je natuurlijk ook meerdere records hebben.
- Een 'user_account_id' verbindt een record met een specifieke user.
Decoderen
Wanneer we gegevens van de client ontvangen:
- Eerst halen we niet-verlopen 'from_to_records' uit de database met behulp van de velden 'user_account_id' en 'created_on'.
- Vervolgens vervangen we met deze records de 'to_ids' in de gegevens van de klant door de 'from_ids'.
Voor- en nadelen
Zoals gezegd, welke methode je ook wilt gebruiken, je moet extra werk doen. Hier zijn enkele voor- en nadelen van deze methode:
Voordelen:
- Rechttoe rechtaan vervanging.
- Bijna geen wijzigingen bij de client.
- Het genereren van nieuwe UUID4s is een geoptimaliseerde functie.
- We raken de originele records niet aan.
- Eenvoudige tijdsbeperkingen bij gebruik van een aanmaakdatum/time-out.
Nadelen:
- Vereist een database.
- Niet erg snel.
Voorbeeld: Flask toepassing met een formulier
Het, zeer beperkte, voorbeeld hieronder laat zien hoe dit werkt. In het voorbeeld kunnen we leden opsommen en bewerken zonder de werkelijke primaire sleutels bloot te leggen.
We gebruiken Flask-Caching (FileSystemCache) als database voor de leden en voor de 'from_to_records'. Normaal gesproken zou je een echt databasesysteem gebruiken voor de leden en iets als Redis voor de 'from_to_records'.
Maak een virtual environment en dan:
pip install flask
pip install Flask-Caching
Er zijn drie klassen:
- 'IdFromTo'
Wordt gebruikt om 'from_to_records' op te halen en op te slaan en om de 'from_ids' te vertalen naar 'to_ids' en omgekeerd. - 'Db'
Databasevragen - DbWrapper'
Onze nieuwe methoden voor het afhandelen van 'from_ids' en 'to_ids' in aanvragen.
De projectboom:
.
├── project
│ ├── app
│ │ └── factory.py
│ └── run.py
Hier zijn de twee bestanden:
# 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
Ga om uit te voeren naar de projectmap en typ:
python run.py
Wijs vervolgens je browser op:
127.0.0.1:5050
Samenvatting
Er zijn veel manieren om je database IDs te verbergen. De gepresenteerde oplossing is voor een database die UUID4 primaire sleutels gebruikt.
Voordat de gegevens naar de klant worden verzonden, worden de primaire UUID4s waarden vervangen door nieuwe UUID4s en worden de oorspronkelijke en vervangende waarden opgeslagen. Wanneer we gegevens van de client ontvangen, laden we de originele en vervangende waarden en vervangen we ze. Als we te lang wachten, verlopen de waarden en moeten we opnieuw beginnen.
Er zijn slechts minimale wijzigingen nodig in de client.
Links / credits
Hiding, obfuscating or encrypting database IDs
https://bhch.github.io/posts/2021/07/hiding-obfuscating-or-encrypting-database-ids
Recent
- Database UUID primaire sleutels van je webapplicatie verbergen
- Don't Repeat Yourself (DRY) met Jinja2
- SQLAlchemy, PostgreSQL, maximum aantal rijen per user
- Toon de waarden in SQLAlchemy dynamische filters
- Veilige gegevensoverdracht met Public Key versleuteling en pyNaCl
- rqlite: een alternatief voor SQLite met hoge beschikbaarheid en distributed
Meest bekeken
- Met behulp van Python's pyOpenSSL om SSL-certificaten die van een host zijn gedownload te controleren
- Gebruik van UUIDs in plaats van Integer Autoincrement Primary Keys met SQLAlchemy en MariaDb
- Maak verbinding met een dienst op een Docker host vanaf een Docker container
- PyInstaller en Cython gebruiken om een Python executable te maken
- SQLAlchemy: Gebruik van Cascade Deletes om verwante objecten te verwijderen
- Flask RESTful API verzoekparametervalidatie met Marshmallow-schema's