ArtikelSäkerhet

Sökvägstraversering i AI-kod: håll filer i rätt mapp

Senast granskad:

En filrutt från en kodassistent kan se ut att ha en kataloggräns och ändå släppa igenom en mapp bredvid. Följ filnamnet från anropet till filöppningen, byt prefixjämförelsen mot en kontroll av var målet ligger och provkör gränsen med syntetiska filer.

Publicerad

Ett långt glasskåp på svart metallbas med dokumentikoner på en grönt lysande linje som slutar vid en grön bock. Bredvid leder en ramp till ett tomt glasskåp, men vägen spärras av en lucka med ett grönt kryss.
Filerna stannar på sin sida av luckan. I koden är det kontrollen framför filöppningen som ska göra samma sak.
Granskningsbeslut

Godkänn inte en filrutt för att den normaliserar sökvägen eller jämför början av strängen. Kräv att kontrollen bedömer det slutliga målet i förhållande till katalogen, efter sista avkodningen och med samma sökvägsregler som där filen öppnas. Det är fortfarande en kontroll av text: symboliska länkar, ändringar mellan kontroll och läsning och behörighet är separata frågor.

Följ filnamnet från anropet till filöppningen

Sökvägstraversering betyder enligt OWASP:s sida om Path Traversal att någon försöker nå filer och kataloger utanför webbroten genom att ändra variabler som pekar på filer, med ../ och varianter av det eller med absoluta sökvägar. OWASP påpekar också att åtkomsten begränsas av operativsystemets åtkomstkontroll. Här tillämpar vi samma tanke på en katalog som en rutt ska hålla sig inom.

Be en kodassistent om ”en rutt som läser en fil från uploads” och du kan få något i stil med koden nedan. Exemplet är konstruerat för artikeln och säger ingenting om hur ofta en viss modell skriver så. Det använder bara Node.js inbyggda moduler och är ett granskningsexempel, ingen färdig produktionslösning:

import http from 'node:http';
import { readFile } from 'node:fs/promises';

// Rutten är densamma före och efter: bara kontrollen byts ut.
export function createFileServer(root, resolveInRoot, OutsideRootError) {
  return http.createServer(async (req, res) => {
    try {
      // new URL kastar vid en ogiltig adress och ska därför ligga inom try.
      // searchParams procentavkodar värdet en gång
      const requested = new URL(req.url, 'http://localhost').searchParams.get('file');
      if (!requested) {
        res.writeHead(400).end();
        return;
      }
      const target = resolveInRoot(root, requested); // kontrollen
      const body = await readFile(target);           // filöppningen
      res.writeHead(200, { 'Content-Type': 'application/octet-stream' }).end(body);
    } catch (err) {
      res.writeHead(err instanceof OutsideRootError ? 403 : 404).end();
    }
  });
}

Granska rutten i tre steg. Var kommer värdet ifrån och var avkodas det? Vilken kontroll passerar det? Vilket värde går till den funktion som öppnar filen? Det ska vara exakt det värde som kontrollerades. Felsvaren innehåller inte heller någon sökväg, eftersom OWASP noterar att felinformation från servern gör det lättare att gissa rätt plats.

Prefixjämförelsen släpper in syskonmappen

Här är kontrollen som ser rimlig ut i en diff. Den räknar ut den fullständiga sökvägen och kontrollerar att den börjar med katalogen. Spara den som before.mjs:

import path from 'node:path';

export class OutsideRootError extends Error {}

// Före: prefixjämförelse av två strängar
export function resolveInRoot(root, requested, p = path) {
  const base = p.resolve(root);
  const target = p.resolve(base, requested);
  if (!target.startsWith(base)) {
    throw new OutsideRootError(requested);
  }
  return target;
}

Funktionen stoppar det vanliga fallet: ../hemlig.txt blir /data/hemlig.txt och nekas. Men startsWith jämför tecken, inte sökvägssegment. Med roten /data/uploads blir ../uploads-evil/hemlig.txt till /data/uploads-evil/hemlig.txt, och den strängen börjar med roten. Samma sak gäller om värdet skickas som absolut sökväg, eftersom Node.js path-dokumentation beskriver att path.resolve bearbetar segmenten från höger till vänster tills en absolut sökväg finns. Dokumentationens exempel är att path.resolve('/foo/bar', '/tmp/file/') ger /tmp/file. Basen försvinner alltså.

Normaliseringen är inte felet, men den räcker inte. path.resolve normaliserar resultatet, och här är det normaliserade resultatet just det som passerar. Node.js varnar uttryckligen för att path.isAbsolute inte är säker för att hindra traversering, och skriver att ersättningar i path.normalize, som att ta bort ..-segment, kan ändra hur systemet tolkar sökvägen. OWASP listar normalisering som det du gör om du är tvungen att använda indata i fil-API:er, efter råd om att i första hand arbeta utan sådan indata. Vår slutsats, inte källornas ordalydelse: normalisering förbereder en kontroll men är ingen kontroll.

Fråga hur målet ligger i förhållande till katalogen

Rättningen har samma namn, argument och omslutning. Bara villkoret är ändrat. Spara den som after.mjs:

import path from 'node:path';

export class OutsideRootError extends Error {}

// Efter: fråga hur målet ligger i förhållande till katalogen
export function resolveInRoot(root, requested, p = path) {
  const base = p.resolve(root);
  const target = p.resolve(base, requested);
  const rel = p.relative(base, target);
  if (rel === '' || rel === '..' || rel.startsWith('..' + p.sep) || p.isAbsolute(rel)) {
    throw new OutsideRootError(requested);
  }
  return target;
}

Varje del av villkoret bär ett fall från vår körning:

Kontrollen beror på plattformen. Node.js dokumenterar att node:path som standard följer operativsystemet och att path.posix och path.win32 ger respektive regler. Med path.posix godkändes ..\hemlig.txt som ett märkligt men lokalt filnamn, medan path.win32 nekade motsvarande sökväg. Ett grönt test med POSIX-regler säger alltså inget om en server på Windows. Windows-fallet rapport.txt:strom passerade också kontrollen med path.win32. Node.js fs-dokumentation skriver att ett filnamn med kolon under NTFS öppnar en filsystemström, och vi öppnade ingen sådan i provet. Vill du inte ha det beteendet behövs en egen spärr, till exempel en tillåtelselista över namn.

En vanlig lappning är target.startsWith(base + p.sep). I körningen stoppade den syskonmappen, men den nekade rapport.txt när roten var / och nekade c:\DATA\UPLOADS\rapport.txt med path.win32, där path.relative godkände sökvägen. Felen går åt det säkra hållet, så en sådan lappning behöver inte stoppa en granskning. Vi föredrar ändå path.relative, eftersom den uttrycker frågan direkt.

Provkör gränsen med syntetiska fall

Testet nedan rör inga filer och kräver bara Node.js. Lägg det bredvid after.mjs och kör node --test kataloggrans.test.mjs. Byt importen till ./before.mjs för att se den gamla kontrollen falla:

// Kör med: node --test kataloggrans.test.mjs
import test from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import { resolveInRoot, OutsideRootError } from './after.mjs';

const root = '/data/uploads';
const p = path.posix; // POSIX-regler även när testet körs på Windows

test('godkänd fil', () => {
  assert.equal(resolveInRoot(root, 'rapport.txt', p), '/data/uploads/rapport.txt');
});

test('mapp vars namn börjar med två punkter', () => {
  assert.equal(resolveInRoot(root, '..foo/rapport.txt', p), '/data/uploads/..foo/rapport.txt');
});

for (const requested of [
  '../hemlig.txt',
  '/data/hemlig.txt',
  '../uploads-evil/hemlig.txt',
  '/data/uploads-evil/hemlig.txt',
]) {
  test(`nekas: ${requested}`, () => {
    assert.throws(() => resolveInRoot(root, requested, p), OutsideRootError);
  });
}

Faktiskt utfört prov: den 5 november 2026 kördes koden med Node.js v24.13.0 på Windows 11, i en tillfällig mapp med syntetiska filer och en server som bara lyssnade på 127.0.0.1. Med after.mjs gick alla sex testerna igenom. Med before.mjs gick fyra igenom och två föll, båda med syskonmappen. Samma rutt kördes sedan mot riktiga filer, med dessa HTTP-svar före respektive efter rättningen:

Provet är litet. Riktiga filer lästes bara på Windows. POSIX-fallen är prov av sökvägstexten, inte av ett Linux-filsystem, och dokumentationen vi läste gäller Node.js v26.13.0 medan körningen använde v24.13.0. Kör därför testerna på samma plattform och Node-version som din server.

Kontrollera efter sista avkodningen

OWASP listar kodade varianter som ..%2f och %252e%252e%255c och noterar att webbcontainrar avkodar procentkodade värden en nivå. I vår rutt gör searchParams den avkodningen, så ..%2Fhemlig.txt kom fram som ../hemlig.txt och nekades. Det dubbelkodade värdet kom fram som %2e%2e%2fhemlig.txt, godkändes som ett konstigt filnamn inne i katalogen och gav 404.

Felet uppstår om ett senare steg avkodar igen. När vi avkodade den redan kontrollerade sökvägen en gång till lästes filen utanför katalogen i vår sandlåda. Vår tumregel är därför: avkoda en gång, kontrollera sedan, och låt inget lager ändra värdet före filöppningen. Sök i diffen efter decodeURIComponent mellan kontrollen och readFile.

Nullbyte är ett liknande fall. OWASP beskriver hur %00 i många system kan avsluta filnamnet och ta sig förbi validering. I vår körning godkände den lexikala kontrollen rapport.txt\0.pdf, men readFile kastade ERR_INVALID_ARG_VALUE. Det gäller den här Node-versionen och säger inget om andra språk eller bibliotek som öppnar filer.

Det kontrollen inte täcker

Symboliska länkar. path.relative ser bara text, så en länk inne i katalogen kan leda ut. På vår Windows-dator nekades vanliga fillänkar med EPERM, så fillänkar kunde inte provas. Mappfallet provades med en junction, vilket är en mapplänk som enligt fs-dokumentationen bara kan peka på kataloger. Enbart lexikal kontroll läste då filen utanför. Enligt dokumentationen bestämmer fsPromises.realpath sökvägens faktiska plats, och för fs.realpath beskrivs det som att den kanoniska sökvägen räknas fram genom att lösa upp ., .. och symboliska länkar. Då kan du kontrollera där länken faktiskt slutar:

import { readFile, realpath } from 'node:fs/promises';
import { resolveInRoot } from './after.mjs';

// Följer länkar först och kontrollerar sedan var filen faktiskt ligger.
export async function readFollowingLinks(root, requested) {
  const target = resolveInRoot(root, requested);
  const realRoot = await realpath(root);
  const realTarget = await realpath(target);
  resolveInRoot(realRoot, realTarget); // kastar om länken leder ut
  return readFile(realTarget);
}

I körningen nekade den mapplänken och läste den vanliga filen. Dokumentationen påpekar att en kanonisk sökväg inte behöver vara unik, eftersom hårda länkar och bind mounts kan visa samma fil på flera sökvägar. Sådana fall provades inte.

Kapplöpning (TOCTOU). Mellan kontroll och läsning kan filsystemet ändras. Vi visade det genom att i testet styra ordningen: efter en godkänd kontroll med realpath byttes mappen mot en mapplänk, och läsningen hämtade filen utanför. Node.js beskriver samma typ av problem för fs.access: en kontroll före öppning ger en kapplöpning, eftersom andra processer kan ändra filens tillstånd mellan anropen. Exempelkoden löser inte detta. Vår bedömning är att risken först och främst minskar om ingen obetrodd process kan skriva i katalogen. Flaggan O_NOFOLLOW ska enligt dokumentationen få öppningen att misslyckas om sökvägen är en symbolisk länk, men konstanten fanns inte i vår Windows-körning, och dokumentationen varnar för att alla konstanter inte finns på alla system. Enligt Linux man-sida för open(2) gäller flaggan dessutom bara sista sökvägsdelen. Länkar tidigare i sökvägen följs ändå, så flaggan stoppar inte ett mappbyte som det i vårt test.

Åtkomstkontroll. En godkänd sökväg visar var filen ligger, inte vem som får läsa den. Om kunders filer delar katalog kan ?file=annan-kunds-faktura.pdf klara gränsen och ändå vara fel. Det är ämnet i ägarkontroll i AI-genererad kod. Uppladdningssidan av samma filflöde, med typ, innehåll, storlek och lagringsnamn, står i filuppladdning i AI-kod. Fjärrsökvägar på Windows är ett eget ämne: se NT-prefixet som gick förbi UNC-kontrollen.

Bäst är att slippa sökvägen från användaren

OWASP:s råd börjar i andra änden: arbeta helst utan användarindata i filsystemsanrop, använd index i stället för delar av filnamn, godta bara kända värden i stället för att sanera och låt inte användaren styra hela sökvägen. Sidan nämner också chroot-miljöer och kodåtkomstpolicyer för att begränsa var filer kan hämtas. Fråga därför först i granskningen om klienten behöver skicka ett filnamn alls. Kan rutten ta ett id som slås upp i databasen, finns ingen fri text kvar att traversera. Då återstår behörighetsfrågan. Resten av granskningen samlas i säkerhetsguiden.

FAQ

Räcker det att normalisera sökvägen?

Nej. Normalisering tar bort punktsegment men säger inget om var resultatet hamnar. I vårt prov var /data/uploads-evil/hemlig.txt redan normaliserad och passerade ändå prefixkontrollen. Du behöver en uttrycklig gränskontroll efteråt.

Varför räcker inte startsWith för att kontrollera en katalog?

startsWith jämför tecken, inte sökvägssegment. Strängen /data/uploads-evil börjar med /data/uploads, så syskonmappen godkänns. path.relative visar i stället hur målet ligger i förhållande till katalogen.

Skyddar path.relative mot symboliska länkar?

Nej. I vårt prov på Windows läste en lexikalt godkänd sökväg en fil utanför katalogen via en mapplänk. En kontroll med realpath stoppade det fallet, men inte ett byte mellan kontroll och läsning.

Betyder en godkänd sökväg att användaren får läsa filen?

Nej. Kataloggränsen svarar på var filen ligger, inte på vem som får läsa den. Behörigheten för den enskilda filen måste kontrolleras separat.

Källor och metod

Källorna kontrollerades 5 november 2026: OWASP Path Traversal för definitionen, kodade varianter, nullbyte, felinformation och skyddsråden; Node.js path-dokumentation (v26.13.0) för path.resolve, path.relative, path.normalize, path.isAbsolute, path.posix och path.win32; Node.js fs-dokumentation (v26.13.0) för realpath, fs.access, junctions, O_NOFOLLOW och kolon i NTFS-filnamn; Linux man-sida för open(2) för räckvidden hos O_NOFOLLOW. Formuleringarna om isAbsolute, fs.access och realpath finns även i dokumentationen för Node.js v24. Koden och provet är AI-kreatörs egna; koden i artikeln är den som kördes. Artikeln gör inget påstående om hur ofta AI-verktyg skriver sådan här kod.