ArtikelSäkerhet

Mass assignment i AI-kod: klienten sätter sin egen roll

Senast granskad:

Be en kodassistent om en rutt som uppdaterar en order, och du kan få kod som sparar hela request-kroppen. Då kan klienten skicka fält som formuläret aldrig visade: roll, ägare och pris. Här är en körbar Node-rutt med syntetisk data, en tillåtelselista och tester där legitima uppdateringar körs tillsammans med angreppen.

Publicerad

Ett transportband med fyra lådor, tre grå och en grön, leder fram till ett högt grått torn med tre luckor, två av dem grönt upplysta. Från de lysande luckorna går två gröna linjer med varsin grön låda till ett stort skåp, medan en grå låda har fallit till golvet nedanför bandet.
Det som saknar öppen lucka når aldrig skåpet. I rutten är det fältlistan som har den rollen.
Granskningsbeslut

Godkänn inte en skrivande rutt där klientens kropp går direkt till datalagret. Kräv en egen fältlista per operation, kontroll av värdena och tester som jämför hela raden efter anropet. Listan svarar på vilka fält klienten får skriva. Vilken rad klienten får röra är en annan kontroll, och den behövs också.

Formuläret visar två fält, rutten tar emot alla

OWASP:s Cheat Sheet om mass assignment beskriver felet så här: ramverk binder HTTP-parametrar automatiskt till variabler eller objekt, och en angripare kan då skapa eller skriva över sådant som utvecklaren aldrig avsåg. CWE-915 beskriver samma svaghet bredare, som indata som anger flera attribut på ett objekt utan ordentlig kontroll av vilka som får ändras. I OWASP API Security Top 10 2023 ligger det tidigare namnet Mass Assignment under API3:2023: en endpoint är sårbar om användaren kan ändra, lägga till eller ta bort värdet på en känslig egenskap som användaren inte ska komma åt.

Angriparen behöver inte läsa din kod. OWASP skriver att fältnamn kan gissas eller upptäckas utan tillgång till källkoden. GitHub skrev i mars 2012 att en användare via formuläret för publika nycklar lagt till sin nyckel hos organisationen rails, och angav grundorsaken som bristande kontroll av inkommande formulärparametrar, alltså mass assignment.

Koden nedan är konstruerad för artikeln och säger ingenting om hur ofta en viss modell skriver så. Den använder bara Node.js inbyggda moduler och är ett granskningsexempel, ingen färdig produktionslösning: inloggningen är en testrubrik, datalagret är två Map och kroppens storlek är inte begränsad. Rutterna är desamma före och efter. Spara filen som app.mjs:

// app.mjs: samma rutter före och efter. Bara valjFalt byts ut.
import http from 'node:http';

const KATALOG = { testvara: 1200, provvara: 300 };

export function skapaData() {
  return {
    anvandare: new Map([
      ['u1', { id: 'u1', namn: 'Testperson Ett', epost: 'ett@example.test', roll: 'kund' }],
      ['u2', { id: 'u2', namn: 'Testperson Två', epost: 'tva@example.test', roll: 'kund' }],
      ['a1', { id: 'a1', namn: 'Testadmin', epost: 'admin@example.test', roll: 'admin' }],
    ]),
    ordrar: new Map([
      ['o1', { id: 'o1', agareId: 'u1', artikel: 'testvara', antal: 1, pris: 1200,
               status: 'ny', leveransadress: 'Provgatan 1' }],
    ]),
  };
}

async function lasKropp(req) {
  let text = '';
  for await (const bit of req) text += bit;
  return JSON.parse(text);
}

export function skapaApp(data, valjFalt) {
  let nastaId = 2;
  return http.createServer(async (req, res) => {
    const svara = (status, kropp) => {
      res.writeHead(status, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(kropp));
    };
    try {
      // Syntetisk inloggning för provet. Här ska din riktiga session in.
      const jag = data.anvandare.get(req.headers['x-testanvandare']);
      if (!jag) return svara(401, { fel: 'Inte inloggad' });

      const vag = new URL(req.url, 'http://localhost').pathname;
      const kropp = await lasKropp(req);
      const nekat = () => svara(400, { fel: 'Otillåtna fält eller värden' });

      if (req.method === 'PATCH' && vag === '/api/profil') {
        const valda = valjFalt('profil', kropp);
        if (!valda) return nekat();
        Object.assign(jag, valda);
        return svara(200, jag);
      }

      if (req.method === 'POST' && vag === '/api/ordrar') {
        const valda = valjFalt('orderSkapa', kropp);
        if (!valda) return nekat();
        if (!Object.hasOwn(KATALOG, valda.artikel)) return nekat();
        const order = { id: `o${nastaId++}`, agareId: jag.id, status: 'ny', antal: 1,
                        pris: KATALOG[valda.artikel], ...valda };
        data.ordrar.set(order.id, order);
        return svara(201, order);
      }

      const orderId = vag.match(/^\/api\/ordrar\/([^/]+)$/)?.[1];
      if (req.method === 'PATCH' && orderId) {
        const order = data.ordrar.get(orderId);
        // Objektnivå: får du röra just den här raden?
        if (!order || order.agareId !== jag.id) return svara(404, { fel: 'Hittades inte' });
        // Fältnivå: vilka fält på raden får du skriva?
        const valda = valjFalt('orderAndra', kropp);
        if (!valda) return nekat();
        Object.assign(order, valda);
        return svara(200, order);
      }

      const malId = vag.match(/^\/api\/admin\/anvandare\/([^/]+)$/)?.[1];
      if (req.method === 'PATCH' && malId) {
        if (jag.roll !== 'admin') return svara(403, { fel: 'Saknar behörighet' });
        const mal = data.anvandare.get(malId);
        if (!mal) return svara(404, { fel: 'Hittades inte' });
        const valda = valjFalt('adminAnvandare', kropp);
        if (!valda) return nekat();
        Object.assign(mal, valda);
        return svara(200, mal);
      }

      return svara(404, { fel: 'Hittades inte' });
    } catch (err) {
      return svara(err instanceof SyntaxError ? 400 : 500, { fel: 'Kunde inte behandla anropet' });
    }
  });
}

Den osäkra varianten är en funktion som släpper igenom allt, vilket gör att Object.assign(order, valda) beter sig som Object.assign(order, req.body). Spara den som before.mjs:

// Före: allt klienten skickar går vidare till skrivningen.
export function valjFalt(operation, kropp) {
  return kropp;
}

Vad som gick igenom i körningen

Vi körde samma HTTP-anrop mot varje variant och läste sedan raden i datalagret. Spärrlistan och den riktiga tillåtelselistan kommer längre ner.

AnropFöreSpärrlistaEfter
Legitimt: kunden ändrar namn200, skrivs200, skrivs200, skrivs
Legitimt: adress och antal på egen order200, skrivs200, skrivs200, skrivs
Legitimt: ny order med artikel och antal201, pris 300 från servern201, samma201, samma
Legitimt: admin sätter roll på annan användare200, skrivs200, skrivs inte200, skrivs
roll: admin på egen profil200, går igenom200, fältet tas bort400
namn och roll i samma anrop200, båda: går igenom200, bara namnet skrivs400, inget skrivs
roll: admin, sedan adminrutten mot annan kund200 och 200, går igenom200 och 403400 och 403
agareId på egen order200, går igenom200, fältet tas bort400
pris: 1 på egen order200, går igenom200, fältet tas bort400
Ny order med pris, agareId, status201, alla tre: går igenom201, status: går igenom400, ingen order
status: betald på egen order200, går igenom200, går igenom400
antal: -5200, går igenom200, går igenom400
leveransadress som objekt200, går igenom200, går igenom400
__proto__ med arAdmin200, går igenom200, går igenom400
Tillåtet fält på någon annans order404404404

Före rättningen räckte två anrop för att en vanlig kund skulle göra en annan kund till admin: det första satte den egna rollen, det andra gick genom adminrutten och klarade dess rollkontroll. Adminrutten var korrekt skriven, men den litar på ett fält som profilrutten lät klienten skriva. Det är den typ av följdverkan som OWASP menar när API3:2023 nämner att obehörig åtkomst till egenskaper under vissa omständigheter kan leda till utökade rättigheter.

Fältnivå och objektnivå är två kontroller

agareId-anropet ovan klarar ägarkontrollen, eftersom raden faktiskt är kundens. Fältet flyttas sedan till en annan ägare. Det omvända provet gjorde vi med den rättade fältlistan och ägarkontrollen bortskriven: PATCH /api/ordrar/o1 från fel kund med ett tillåtet fält gav 200, och adressen på någon annans order skrevs över. Med ägarkontrollen kvar blev svaret 404.

Den ena kontrollen ersätter alltså inte den andra, och OWASP säger samma sak: tillåtelselistor och dedikerade indataobjekt begränsar skrivbara egenskaper men ersätter inte behörighet för det begärda objektet och operationen. Vilken rad som får läsas och ändras är ämnet i ägarkontroll i AI-genererad kod. SQL-injektion i AI-genererad kod gäller i sin tur hur ett värde tolkas av databasen, inte vilket fält det får hamna i.

En lista per operation, med kontroll av värdena

OWASP förordar dedikerade indataobjekt med bara de fält anroparen får ändra, eller en uttrycklig tillåtelselista när objektet har fler egenskaper. Här är det en lista, med samma funktionsnamn och argument som before.mjs. Spara den som after.mjs:

// Efter: varje operation har en egen lista över fält och godkända värden.
const kortText = (v) => typeof v === 'string' && v.length >= 1 && v.length <= 200;
const epost = (v) => kortText(v) && v.includes('@');
const antal = (v) => Number.isInteger(v) && v >= 1 && v <= 10;
const roll = (v) => v === 'kund' || v === 'admin';

const TILLATNA = {
  profil: { namn: kortText, epost },
  orderSkapa: { artikel: kortText, antal, leveransadress: kortText },
  orderAndra: { antal, leveransadress: kortText },
  adminAnvandare: { roll },
};

export function valjFalt(operation, kropp) {
  if (kropp === null || typeof kropp !== 'object' || Array.isArray(kropp)) return null;
  const regler = TILLATNA[operation];
  const valda = {};
  for (const [namn, varde] of Object.entries(kropp)) {
    if (!Object.hasOwn(regler, namn) || !regler[namn](varde)) return null;
    valda[namn] = varde;
  }
  return valda;
}

Fyra val i koden bär var sitt fall från körningen:

Spärrlistan räknar bara upp det någon kom ihåg

Den vanliga lappningen är att plocka bort de fält som oroar. Spara den som sparrlista.mjs om du vill prova:

// Spärrlista: ser rimlig ut i en diff, men räknar bara upp det någon kom ihåg.
export function valjFalt(operation, kropp) {
  const { roll, agareId, pris, id, ...rest } = kropp;
  return rest;
}

Tabellen visar utfallet: de uppräknade fälten stoppades, men status gick igenom eftersom ingen skrev upp det. När vi lade till kolumnen rabatt på ordern kunde klienten skriva den direkt, medan after.mjs nekade den utan ändring i koden. Listan är också global, så adminrutten tappade roll och svarade 200 utan att skriva något. OWASP skriver att ett nytt eller förbisett känsligt fält förblir skrivbart med en spärrlista som enda skydd, och CWE-915 råder dig att om möjligt föredra tillåtelselistor framför spärrlistor.

__proto__ syns inte i svaret

JSON.parse('{"__proto__":{"arAdmin":true}}') gav en egen nyckel med namnet __proto__. MDN beskriver att Object.assign använder [[Set]] på målet och att det alltså tilldelar egenskaper i stället för att definiera nya. I körningen bytte tilldelningen prototyp på målobjektet: rad.arAdmin blev true, medan Object.keys och JSON.stringify inte visade fältet. Svaret såg alltså oförändrat ut. Med spridning, { ...rad, ...kropp }, blev __proto__ i stället en vanlig egen nyckel utan arAdmin på raden.

Provet ändrade bara det enskilda objektet: ({}).arAdmin förblev undefined, så det här är inte en förgiftning av den gemensamma prototypen. OWASP:s Cheat Sheet om prototype pollution behandlar det större problemet och föreslår bland annat Map, Set och Object.create(null).

Ett separat prov visade också en lucka i vårt första test. assert.deepEqual från node:assert/strict passerade i v24.13.0 trots olika prototyp och samma constructor, fast Node.js dokumentation för v24 skriver att prototyper jämförs med ===. Samma jämförelse fällde en rad med null-prototyp. Vi har inte utrett orsaken, så testet nedan kontrollerar prototypen på en egen rad.

Testet jämför raden, inte svaret

API3:2023 påpekar att en angripare kan behöva titta på sidoeffekter när egenskapen inte kommer tillbaka i svaret. Det gäller testet också: ett svar kan se rätt ut medan raden ändrats. Därför läser testet datalagret och jämför hela raden, inte bara det angripna fältet. Lägg filen bredvid de andra och kör node --test falt.test.mjs. Byt importen till ./before.mjs för att se den gamla varianten falla:

// Kör med: node --test falt.test.mjs
import test from 'node:test';
import assert from 'node:assert/strict';
import { skapaApp, skapaData } from './app.mjs';
import { valjFalt } from './after.mjs';

async function starta(t) {
  const data = skapaData();
  const app = skapaApp(data, valjFalt);
  await new Promise((klar) => app.listen(0, '127.0.0.1', klar));
  t.after(() => { app.closeAllConnections(); app.close(); });
  const bas = `http://127.0.0.1:${app.address().port}`;
  const anrop = (metod, vag, anvandare, kropp) => fetch(bas + vag, {
    method: metod,
    headers: { 'x-testanvandare': anvandare, 'Content-Type': 'application/json' },
    body: typeof kropp === 'string' ? kropp : JSON.stringify(kropp),
  });
  return { data, anrop };
}

// Jämför hela raden i datalagret, inte bara svaret eller det angripna fältet.
async function nekasOchRadenStar(t, vag, anvandare, kropp, rad) {
  const { data, anrop } = await starta(t);
  const fore = structuredClone(rad(data));
  const svar = await anrop('PATCH', vag, anvandare, kropp);
  assert.equal(svar.status, 400);
  assert.deepEqual(rad(data), fore);
  assert.equal(Object.getPrototypeOf(rad(data)), Object.prototype);
}

const profil = (data) => data.anvandare.get('u1');
const order = (data) => data.ordrar.get('o1');

test('legitimt: kunden ändrar sitt namn', async (t) => {
  const { data, anrop } = await starta(t);
  const svar = await anrop('PATCH', '/api/profil', 'u1', { namn: 'Nytt Namn' });
  assert.equal(svar.status, 200);
  assert.equal(profil(data).namn, 'Nytt Namn');
  assert.equal(profil(data).roll, 'kund');
});

test('legitimt: kunden ändrar adress och antal på sin order', async (t) => {
  const { data, anrop } = await starta(t);
  const svar = await anrop('PATCH', '/api/ordrar/o1', 'u1', { leveransadress: 'Provgatan 2', antal: 3 });
  assert.equal(svar.status, 200);
  assert.deepEqual(order(data), { id: 'o1', agareId: 'u1', artikel: 'testvara', antal: 3, pris: 1200,
    status: 'ny', leveransadress: 'Provgatan 2' });
});

test('legitimt: ny order får pris och ägare av servern', async (t) => {
  const { anrop } = await starta(t);
  const svar = await anrop('POST', '/api/ordrar', 'u2', { artikel: 'provvara', antal: 2 });
  assert.equal(svar.status, 201);
  const skapad = await svar.json();
  assert.equal(skapad.pris, 300);
  assert.equal(skapad.agareId, 'u2');
});

test('legitimt: admin ändrar roll på egen rutt', async (t) => {
  const { data, anrop } = await starta(t);
  const svar = await anrop('PATCH', '/api/admin/anvandare/u2', 'a1', { roll: 'admin' });
  assert.equal(svar.status, 200);
  assert.equal(data.anvandare.get('u2').roll, 'admin');
});

test('angrepp: kunden sätter sin roll', (t) =>
  nekasOchRadenStar(t, '/api/profil', 'u1', { roll: 'admin' }, profil));

test('angrepp: rollen göms i en legitim uppdatering', (t) =>
  nekasOchRadenStar(t, '/api/profil', 'u1', { namn: 'Nytt Namn', roll: 'admin' }, profil));

test('angrepp: kunden flyttar orderns ägare', (t) =>
  nekasOchRadenStar(t, '/api/ordrar/o1', 'u1', { agareId: 'u2' }, order));

test('angrepp: kunden sätter priset', (t) =>
  nekasOchRadenStar(t, '/api/ordrar/o1', 'u1', { pris: 1 }, order));

test('angrepp: priset göms i en legitim uppdatering', (t) =>
  nekasOchRadenStar(t, '/api/ordrar/o1', 'u1', { leveransadress: 'Provgatan 2', pris: 1 }, order));

test('angrepp: negativt antal på ett tillåtet fält', (t) =>
  nekasOchRadenStar(t, '/api/ordrar/o1', 'u1', { antal: -5 }, order));

test('angrepp: objekt i ett textfält', (t) =>
  nekasOchRadenStar(t, '/api/ordrar/o1', 'u1', { leveransadress: { pris: 1 } }, order));

test('angrepp: prototypen via __proto__', (t) =>
  nekasOchRadenStar(t, '/api/profil', 'u1', '{"__proto__":{"arAdmin":true}}', profil));

test('angrepp: ny order med eget pris och annan ägare', async (t) => {
  const { data, anrop } = await starta(t);
  const antalFore = data.ordrar.size;
  const svar = await anrop('POST', '/api/ordrar', 'u2',
    { artikel: 'testvara', pris: 1, agareId: 'u1', status: 'betald' });
  assert.equal(svar.status, 400);
  assert.equal(data.ordrar.size, antalFore);
});

// Listan över kolumner tas ur raden, så en ny kolumn blir ett nytt angrepp.
for (const falt of Object.keys(skapaData().ordrar.get('o1'))) {
  if (['antal', 'leveransadress'].includes(falt)) continue;
  test(`vakt: orderfältet ${falt} går inte att skriva`, (t) =>
    nekasOchRadenStar(t, '/api/ordrar/o1', 'u1', { [falt]: 'x' }, order));
}

test('objektnivå: tillåtet fält på någon annans order', async (t) => {
  const { data, anrop } = await starta(t);
  const fore = structuredClone(order(data));
  const svar = await anrop('PATCH', '/api/ordrar/o1', 'u2', { leveransadress: 'Annan adress' });
  assert.equal(svar.status, 404);
  assert.deepEqual(order(data), fore);
});

Legitima uppdateringar ligger i samma fil som angreppen, så att en rättning som nekar allt också faller. Vaktslingan tar kolumnerna ur raden och försöker skriva varje fält som inte är undantaget. Ett fält som läggs till på raden blir alltså automatiskt ett nytt försök, och ett fält som ska bli skrivbart måste föras in både i listan och i testet. I ett riktigt projekt bör kolumnerna läsas ur schemat, annars ser vakten bara det som fixturen råkar innehålla.

Faktiskt utfört prov: den 6 november 2026 kördes koden med Node.js v24.13.0 på Windows 11, med syntetiska data och en server som bara lyssnade på 127.0.0.1. Med after.mjs gick 19 av 19 tester igenom. Med before.mjs gick 5 igenom och 14 föll: de fyra legitima och objektnivåtestet höll, alla angrepp och vakter föll. Med sparrlista.mjs gick 4 igenom och 15 föll, men testerna kräver 400, så siffran blandar fält som gick igenom med fält som togs bort under 200. Läs tabellen för den varianten. Med kolumnen rabatt tillagd blev det 20 av 20 med after.mjs, medan vakten för rabatt föll med spärrlistan. Godkända prov visar att just dessa försök stoppades, inte att alla varianter är uteslutna.

Sök kandidaterna i diffen

Fråga först vad som händer med req.body i varje skrivande rutt. Mönstret nedan söker efter de former vi provade, inte efter alla som finns:

grep -rnE '(assign\(.*|\.\.\.|data: *|create\(|[Uu]pdate\(.*|new [A-Za-z]+\()req\.body' src

Mot en syntetisk fixtur hittade mönstret alla sex raderna med Object.assign, spridning, data: req.body, create, findByIdAndUpdate och new Order. Det teg om ett namngivet fält och en pick med lista, vilket var rätt. Det missade också två rader där kroppen först lagts i en variabel, const indata = req.body och const { body } = req. En tom träfflista är alltså inget friskintyg, och varje träff är en kandidat att läsa, inte en dom. Du kan ställa samma fråga till kodassistenten: vilka fält kan den här rutten skriva, och vem får skriva dem? Svaret är en utgångspunkt. Testerna avgör. Resten av granskningen finns i säkerhetsgranskningen av en AI-genererad kodbas och säkerhetsguiden.

Det provet inte visar

FAQ

Vad är mass assignment?

Mass assignment betyder att indata från klienten binds till ett objekt utan kontroll av vilka fält den får ändra. Klienten kan då skicka fält som roll, ägare eller pris, trots att formuläret aldrig visar dem.

Vad är skillnaden mellan mass assignment och en saknad ägarkontroll?

Mass assignment gäller vilka fält på en rad klienten får skriva. Ägarkontrollen gäller vilken rad klienten får röra. I vårt prov stoppade ingen av dem det den andra ska stoppa, så rutten behöver båda.

Räcker en spärrlista med känsliga fält?

Nej. I vårt prov stoppade spärrlistan de fält den räknade upp, men status, negativt antal och __proto__ gick igenom. OWASP och CWE förordar tillåtelselistor framför spärrlistor.

Ska rutten neka anropet eller bara ta bort okända fält?

Båda stoppade fälten i vårt prov. Att ta bort dem tyst gav 200 utan ändring, så klienten såg ett lyckat svar. Vi nekar med 400, vilket i gengäld stoppar en klient som skickar hela objektet tillbaka.

Källor och metod

Källorna kontrollerades 6 november 2026: OWASP Mass Assignment Cheat Sheet för definition, gissade fältnamn, nästlade objekt, tillåtelselistor och gränsen mot behörighet; OWASP API3:2023 för sårbarhetsvillkoret, sidoeffekter och förebyggande råd; CWE-915 för svagheten och rådet om tillåtelselistor; GitHubs inlägg från mars 2012 för exemplet; MDN om Object.assign för [[Set]]; OWASP Prototype Pollution Prevention Cheat Sheet för skyddsförslagen; Node.js assert-dokumentation för v24 för jämförelsereglerna. Koden och provet är AI-kreatörs egna, och koden i artikeln är den som kördes. Varianterna utan egen kodruta, filterlistan, in-listan och körningen utan ägarkontroll, ingick i samma provmatris. Artikeln gör inget påstående om hur ofta AI-verktyg skriver sådan här kod.