← Todas las noticias
security4 min de lectura

The zip file your import feature extracts gets to pick its own destination

Por Seaworthy · 28 de agosto de 2026

Pile of paper files and documents waiting to be sorted

Photo by Alexander Grey on Unsplash

A feature that lets users upload a zipped project and extracts it server-side is a handful of lines with a library like unzipper or adm-zip:

const AdmZip = require('adm-zip')

app.post('/api/import', upload.single('archive'), async (req, res) => {
  const zip = new AdmZip(req.file.path)
  zip.extractAllTo('/var/data/imports', true)
  res.json({ status: 'imported' })
})

It extracts a normal zip file exactly where you'd expect. It also extracts an abnormal one wherever the archive's own file names tell it to, including outside /var/data/imports entirely.

The archive controls its own destination unless you stop it

A zip archive doesn't just store file contents, it stores a file name (technically a path) for every entry, and that path is whatever the person who created the archive put there. Most extraction libraries, by default, join that stored path onto your target directory and write there without checking whether the result is still inside the directory you intended. An entry named report.pdf extracts to /var/data/imports/report.pdf, unremarkable. An entry named ../../../../etc/cron.d/malicious extracts to wherever that relative path resolves to on your filesystem, which is not inside /var/data/imports at all, and the extraction library has no reason to object, because from its point of view it's just following the path it was given.

This is called zip slip, and it's not a theoretical edge case, it's been found in extraction code across a wide range of popular libraries and applications, precisely because "join the entry name onto the target directory" is the natural, obvious way to write an extractor, and checking that the joined result doesn't escape the target directory is an extra step that has to be added deliberately rather than falling out of the straightforward implementation.

The severity depends entirely on what the attacker can reach with a write. Overwriting an application config file can change how your app behaves on its next restart. Writing into a directory your process treats as executable, a cron directory, a systemd unit directory, a webroot that serves whatever's in it, can turn a file write into code execution. Even without any of that, overwriting arbitrary files the process has permission to touch is a real problem on its own.

Validate the resolved path stays inside the target directory

The fix is the same shape regardless of which library you're using: resolve each entry's destination path to an absolute path, and refuse to write anywhere that isn't still inside your intended output directory.

const path = require('path')
const AdmZip = require('adm-zip')

function safeExtract(zipPath, targetDir) {
  const zip = new AdmZip(zipPath)
  const resolvedTarget = path.resolve(targetDir)

  for (const entry of zip.getEntries()) {
    const destination = path.resolve(resolvedTarget, entry.entryName)

    if (!destination.startsWith(resolvedTarget + path.sep)) {
      throw new Error(`Blocked path traversal attempt: ${entry.entryName}`)
    }
  }

  zip.extractAllTo(resolvedTarget, true) // safe now that every entry has been checked
}

path.resolve collapses .. segments into the real absolute path each entry would land at, which is what makes the startsWith check meaningful, checking the raw entry name string for .. isn't sufficient on its own, since a path can still escape the target directory through encoding tricks or symlink components that a plain string check won't catch, while resolving the path first strips all of that ambiguity out before you compare it.

Some libraries handle this for you if you ask. extract-zip and newer versions of a few other packages include path traversal checks by default, or accept an option to enable one, which is worth checking for before writing your own validation from scratch. Whichever library you're using, confirming it actually protects against this, rather than assuming it does because the maintainers surely thought of it, is worth the five minutes it takes.

# a quick way to see what an archive's entries would resolve to before trusting any extraction code
unzip -l suspicious.zip

If any listed entry starts with ../ or an absolute path, that archive is worth treating as hostile.

Why the naive extractor is the one that ships

"Extract this zip file to this directory" describes exactly what zip.extractAllTo(dir, true) does, correctly, for every well-formed archive an ordinary user would create. The vulnerability only exists for the specific, deliberately malicious archive that a real user has no reason to construct, which is exactly the category of input that doesn't show up in normal testing and doesn't get considered when the feature request is phrased as "let users upload a zip." The extraction library did what it was told; the entry names it was told to use were never validated as staying inside the box.

Seaworthy flags archive extraction calls that write entries to a target directory without validating that each resolved entry path stays inside it, since the vulnerable and safe versions of this code differ by exactly the check most extraction snippets skip.

Este artículo fue generado por IA y resume fuentes públicamente disponibles.

De los creadores de Seaworthy

Seaworthy analiza tu repositorio en busca de los problemas que se tratan en artículos como este.

Brechas de seguridad, secretos expuestos y configuraciones erróneas detectados antes de desplegar. Gratis, sin cuenta necesaria.

$ npx seaworthycode