Werkplaats / De werkplaats
In jouw eigen tempo
← AI-security & red teaming
MODULE 02 · LES 06

Bind de macht van een assistent aan de opdrachtgever

Vergelijk dienstrechten met een apart vertrouwde sessieactor en tenant in een eigen confused-deputy-oefening.

90 min3 checkpoints
DIT GA JE ONTDEKKEN
  • Je onderscheidt dienstmacht, een geclaimde identiteit en de vertrouwde sessie.
  • Je bewaart de normale eigen-kaarttaak terwijl je toegang tot andermans kaart weigert.

Een gedeelde assistent kan de kaartjes van twee klanten lezen. Als iemand vraagt om het kaartje van een ander, is de macht van de assistent dan voldoende? Nee: de applicatie moet apart weten namens welke opdrachtgever zij handelt.

Een verwarde plaatsvervanger (confused deputy) gebruikt zijn eigen bevoegdheden voor iemand die die toegang niet heeft. We oefenen met fictieve klantkaarten, een dienstbereik en apart vertrouwde sessiegegevens. De tekst CANARY-EIGEN is alleen een herkenbare oefenmarkering.

1. Wie vraagt dit werkelijk?

Vergelijk de assistent met een baliemedewerker die alle klantmappen kan openen. De bezoeker mag een naam op een formulier zetten, maar dat formulier vervangt geen vastgestelde identiteit. Ook een vertrouwd account bij de verkeerde tenant, de afgescheiden klantomgeving, hoort niet vanzelf toegang te krijgen.

De sessie bevat actor en tenant. Een kaart bevat owner en tenant; beide moeten passen. De gegeven helpers controleren de gegevensvorm, terwijl jij de bevoegdheidsvergelijking schrijft.

Hier is C de opdrachtgever, O de eigenaar en T de tenant. A wordt alleen waar als zowel actor als tenant klopt. Deze beslissing controleert de opdrachtgever; de dienst moet daarnaast zelf toegang hebben.

A=(C=O)∧(TC=TO)

Met de hand hoort de kaart bij Alice in rood. Bobs sessie in blauw past op geen van beide velden. We vragen toch dezelfde kaart op, zodat de controle zichtbaar wordt.

cards = [dict(
    id="kaart-a", owner="alice",
    tenant="rood", text="CANARY-EIGEN")]
bob = dict(actor="bob", tenant="blauw")
answer = caller_access(
    cards, bob, "kaart-a")
print(answer["allowed"])
print(answer["reason"])
# False
# tenant
01
CHECKPOINTControleer de bevoegdheid van de echte opdrachtgever

2. Een geclaimde naam geeft geen toegang

De kwetsbare plaatsvervanger heeft een dienstbereik met kaart-a. Hij kijkt vervolgens naar claimed_actor in het aangeleverde voorstel alsof die naam al is vastgesteld. Bob kan daar Alice invullen zonder dat zijn apart vertrouwde sessie verandert.

G is het dienstbereik en p het gekozen doel. De naïeve regel combineert diensttoegang met de onbewezen actorclaim. Het probleem is dus niet alleen een ongeldige tekstvorm: alle velden kunnen keurig gevormd zijn en toch naar de verkeerde opdrachtgever wijzen.

AN=(p∈G)∧(Cclaim=O)
claim = dict(claimed_actor="alice",
             target="kaart-a")
service = ["kaart-a"]
leak = naive_deputy(
    cards, service, claim)
print(leak["allowed"])
print(leak["text"])
# True
# CANARY-EIGEN
02
CHECKPOINTMaak de verwarde plaatsvervanger zichtbaar
VERKEN HET ZELFVoor wie mag de assistent deze kaart lezen?

De dienst kan meer kaarten lezen dan één gebruiker. Een actornaam in het voorstel is geen bewijs dat die gebruiker de aanvraag doet.

Vertrouwde sessie en eigen kaartgegevens
Onbetrouwbaar voorstel
Kaarten binnen het dienstrecht

kaart-a

Eigenaar: alice

Tenant: rood

Oefeninhoud: "CANARY-EIGEN-A"

kaart-b

Eigenaar: bob

Tenant: blauw

Oefeninhoud: "CANARY-EIGEN-B"

Vertrouwde sessie: alice / rood. Voorstel claimt bob en vraagt kaart-b.

Recht van de opdrachtgever

Geweigerd

De kaart hoort bij een andere tenant.

Kwetsbare assistent

Toegestaan

De controles staan deze toegang toe.

Teruggegeven tekst: "CANARY-EIGEN-B"

Sessiebewuste assistent

Geweigerd

De claim wijkt af van de vertrouwde actor.

Teruggegeven tekst: None

De verdedigde assistent controleert doel, dienstrecht, sessie-identiteit, tenant en eigenaar. De eerste weigering bepaalt de reden. None betekent geen tekst teruggeven; "" is een toegestane lege kaart.

Alle kaarten, rechten, routes en kosten zijn eigen oefengegevens. Deze oefening voert geen externe tools uit en verstuurt geen gegevens.

3. Bind de actie aan vertrouwde context

De verdedigde plaatsvervanger krijgt ook Bobs sessie. Hij vergelijkt de claim met de vertrouwde actor, controleert de tenant en vergelijkt vervolgens de eigenaar. Geen van die grenzen mag door voorsteltekst worden vervangen.

De toestemming vraagt alle voorwaarden tegelijk. De redenlabels in onze oefening tonen de eerste mislukte controle; een echte applicatie moet apart bepalen welke foutinformatie zij veilig kan onthullen. Je schrijft hier geen productielogin of volledig tenantsysteem.

S betekent diensttoegang, I een passende actorclaim en B de bevoegdheidsbeslissing uit het eerste onderdeel. Het teken ∧ verbindt voorwaarden die allemaal nodig zijn. Vormvalidatie gebeurt al vóór deze beslissingen.

AG=S∧I∧B
safe = guarded_deputy(
    cards, service, bob, claim)
print(safe["allowed"])
print(safe["reason"])
print(safe["text"])
# False
# identity
# None
03
CHECKPOINTBind de actie aan actor, tenant en doel

Een veelgemaakte fout is alleen de naamcontrole toevoegen en de tenant vergeten. Dezelfde actornaam kan in een andere klantomgeving voorkomen. Een tweede fout is alle tekst weigeren, ook wanneer de eigen normale aanvraag wel klopt.

alice = dict(actor="alice",
             tenant="rood")
normal = guarded_deputy(
    cards, service, alice, claim)
print(normal["allowed"])
print(normal["text"])
# True
# CANARY-EIGEN

Een onbekend doel en een doel buiten het dienstbereik worden eveneens geweigerd. Bij afwijzing is text altijd None; een toegestane maar lege kaart bevat juist een lege string. Bewaar dat onderscheid, zodat ontbrekende toestemming niet voor lege normale inhoud wordt aangezien.

Wat je nu kunt

  • Je scheidt dienstmacht van de bevoegdheid van de opdrachtgever.
  • Je behandelt actorclaims als gegevens en bewaart apart vertrouwde sessiecontext.
  • Je verdedigt de grens terwijl toegang tot de eigen normale kaart blijft werken.

Hierna tekenen we hoe beïnvloeding, privélezing en een uitgaand kanaal samen één concreet pad kunnen vormen.

Bronnen

Gecontroleerd op 30 oktober 2026: AWS over het confused-deputy-probleem en het binden van de opdrachtgever en OWASP 2026 LLM03 over gebruikerscontext en autorisatie. De oefening implementeert geen AWS/IAM; kaarten, sessies en redenlabels zijn eigen materiaal.

DE VOLGENDE STAPVerbreek het concrete pad van privégegevens naar buiten
JOUW EXPERIMENT

Probeer het zelf

Python
main.py

Python start zodra jij code uitvoert.

UITVOER

Je uitvoer verschijnt hier.
Alles rekent in je eigen browser.

Jouw checkpoints0 / 3
01
Controleer de bevoegdheid van de echte opdrachtgever

Schrijf caller_access(resources,session,target). Resources is exact list/tuple met 0–16 exacte dicts met uitsluitend exact-str keys id/tenant/owner/text. Id/tenant/owner zijn geldige ids, resource-ids uniek. Text is exacte str met 0–128 Python-codepunten eigen oefentekst. Een id is exact str volgens ASCII [a-z][a-z0-9-]{0,31}. Session is een apart vertrouwd exact dict actor/tenant met exact-str keys en geldige id-waarden. Een modelvoorstel bepaalt deze context niet. Target is een geldige id en mag onbekend zijn. Geef een nieuw exact dict allowed/reason, met allowed als exacte bool. Redenprioriteit: unknown-target, tenant, owner, allowed. Vergelijk tenant en eigenaar met de vertrouwde sessie, niet met een voorstel. Dit verleent de dienst zelf nog geen toegang. Alle invoer valideren, ook op geweigerde of later niet uitgevoerde takken. Exacte Python-builtins zonder omzetting of normalisatie; ongeldige invoer geeft ValueError. Behoud alle invoer, volgorde, readonlyflags en Python-/NumPy-RNG, ook bij fouten. Alle mutabele uitvoercontainers zijn nieuw, delen geen containers met invoer, elkaar of herhaalde resultaten. De validatiehelpers zijn gegeven. Iedere functie werkt zelfstandig wanneer alle overige leerlingfuncties nog TODO zijn. Geen echte tool, dienst of netwerkactie uitvoeren.

Een hint

Vraag zowel bij welke tenant als bij welke actor het kaartje hoort.

02
Maak de verwarde plaatsvervanger zichtbaar

Schrijf naive_deputy(resources,service_targets,request). Resources is exact list/tuple met 0–16 exacte dicts met uitsluitend exact-str keys id/tenant/owner/text. Id/tenant/owner zijn geldige ids, resource-ids uniek. Text is exacte str met 0–128 Python-codepunten eigen oefentekst. Een id is exact str volgens ASCII [a-z][a-z0-9-]{0,31}. Service_targets is exact list/tuple met 0–16 unieke geldige bekende resource-ids; dit beschrijft uitsluitend de macht van de dienst. Request is exact dict claimed_actor/target met exact-str keys en geldige id-waarden. Een syntactisch geldig onbekend target is toegestaan als invoer. Geef een nieuw exact dict allowed/reason/text. De opzettelijk kwetsbare regel vertrouwt claimed_actor alsof dat echte identiteit is. Redenprioriteit: unknown-target, service-scope, claimed-owner, allowed. Alleen bij allowed is text de resourcetekst; anders None. Allowed is exacte bool. Een lege toegestane tekst blijft een lege str. Alle invoer valideren, ook op geweigerde of later niet uitgevoerde takken. Exacte Python-builtins zonder omzetting of normalisatie; ongeldige invoer geeft ValueError. Behoud alle invoer, volgorde, readonlyflags en Python-/NumPy-RNG, ook bij fouten. Alle mutabele uitvoercontainers zijn nieuw, delen geen containers met invoer, elkaar of herhaalde resultaten. De validatiehelpers zijn gegeven. Iedere functie werkt zelfstandig wanneer alle overige leerlingfuncties nog TODO zijn. Geen echte tool, dienst of netwerkactie uitvoeren.

Een hint

De dienst kan iets lezen, maar weet zo nog niet namens wie zij handelt.

03
Bind de actie aan actor, tenant en doel

Schrijf guarded_deputy(resources,service_targets,session,request). Resources is exact list/tuple met 0–16 exacte dicts met uitsluitend exact-str keys id/tenant/owner/text. Id/tenant/owner zijn geldige ids, resource-ids uniek. Text is exacte str met 0–128 Python-codepunten eigen oefentekst. Een id is exact str volgens ASCII [a-z][a-z0-9-]{0,31}. Service_targets is exact list/tuple met 0–16 unieke geldige bekende resource-ids; dit beschrijft uitsluitend de macht van de dienst. Session is een apart vertrouwd exact dict actor/tenant met exact-str keys en geldige id-waarden. Een modelvoorstel bepaalt deze context niet. Request is exact dict claimed_actor/target met exact-str keys en geldige id-waarden. Een syntactisch geldig onbekend target is toegestaan als invoer. Geef hetzelfde exacte allowed/reason/text-schema. Redenprioriteit: unknown-target, service-scope, identity, tenant, owner, allowed. Identity eist claimed_actor==session.actor; tenant en owner vergelijken de resource met de vertrouwde sessie. Alleen bij alle voorwaarden text teruggeven, anders None. Gewone aanvragen voor de eigen kaart moeten blijven werken. Alle invoer valideren, ook op geweigerde of later niet uitgevoerde takken. Exacte Python-builtins zonder omzetting of normalisatie; ongeldige invoer geeft ValueError. Behoud alle invoer, volgorde, readonlyflags en Python-/NumPy-RNG, ook bij fouten. Alle mutabele uitvoercontainers zijn nieuw, delen geen containers met invoer, elkaar of herhaalde resultaten. De validatiehelpers zijn gegeven. Iedere functie werkt zelfstandig wanneer alle overige leerlingfuncties nog TODO zijn. Geen echte tool, dienst of netwerkactie uitvoeren.

Een hint

Een geclaimde naam kan de apart vertrouwde sessie niet vervangen.

Vastgelopen? Bekijk de voorbeeldoplossing
import re
_IDENTIFIER = re.compile('[a-z][a-z0-9-]{0,31}', re.ASCII)

# Jouw functies

def caller_access(resources, session, target):
    records = _resources(resources)
    context = _session(session)
    _identifier(target)
    row = records.get(target)
    reason = 'unknown-target' if row is None else 'tenant' if row['tenant'] != context['tenant'] else 'owner' if row['owner'] != context['actor'] else 'allowed'
    return {'allowed': reason == 'allowed', 'reason': reason}

def naive_deputy(resources, service_targets, request):
    records = _resources(resources)
    service = _service_targets(service_targets, records)
    proposal = _request(request)
    row = records.get(proposal['target'])
    reason = 'unknown-target' if row is None else 'service-scope' if proposal['target'] not in service else 'claimed-owner' if proposal['claimed_actor'] != row['owner'] else 'allowed'
    return {'allowed': reason == 'allowed', 'reason': reason, 'text': row['text'] if reason == 'allowed' else None}

def guarded_deputy(resources, service_targets, session, request):
    records = _resources(resources)
    service = _service_targets(service_targets, records)
    context = _session(session)
    proposal = _request(request)
    row = records.get(proposal['target'])
    reason = 'unknown-target' if row is None else 'service-scope' if proposal['target'] not in service else 'identity' if proposal['claimed_actor'] != context['actor'] else 'tenant' if row['tenant'] != context['tenant'] else 'owner' if row['owner'] != context['actor'] else 'allowed'
    return {'allowed': reason == 'allowed', 'reason': reason, 'text': row['text'] if reason == 'allowed' else None}

# Gegeven validatiehelpers

def _identifier(value):
    if type(value) is not str or _IDENTIFIER.fullmatch(value) is None:
        raise ValueError('Gebruik een exacte ASCII-id')
    return value

def _record(value, keys):
    if type(value) is not dict or any((type(key) is not str for key in value)) or set(value) != set(keys):
        raise ValueError('De velden moeten precies bij het contract passen')
    return value

def _resources(value):
    if type(value) not in (list, tuple) or len(value) > 16:
        raise ValueError('Geef maximaal zestien eigen oefenresources')
    result = {}
    for row in value:
        _record(row, ('id', 'tenant', 'owner', 'text'))
        identifier = _identifier(row['id'])
        _identifier(row['tenant'])
        _identifier(row['owner'])
        if type(row['text']) is not str or len(row['text']) > 128:
            raise ValueError('Oefentekst bevat maximaal 128 codepunten')
        if identifier in result:
            raise ValueError('Resource-ids zijn uniek')
        result[identifier] = row
    return result

def _service_targets(value, resources):
    if type(value) not in (list, tuple) or len(value) > 16:
        raise ValueError('Geef maximaal zestien service-doelen')
    result = []
    for target in value:
        _identifier(target)
        if target not in resources or target in result:
            raise ValueError('Service-doelen zijn uniek en bekend')
        result.append(target)
    return result

def _session(value):
    _record(value, ('actor', 'tenant'))
    _identifier(value['actor'])
    _identifier(value['tenant'])
    return value

def _request(value):
    _record(value, ('claimed_actor', 'target'))
    _identifier(value['claimed_actor'])
    _identifier(value['target'])
    return value

Voer de code uit en verklaar voor jezelf waarom elke stap werkt.