Extract an archive a user uploaded with OneArchive, and see what its safe defaults refuse: a path that climbs out of the target, and an archive larger than you allow.
upload.tar holds one ordinary file and one entry named ../../home/user/.bashrc, the classic “zip slip”. It was made with Python’s tarfile, as an attacker would:
import tarfile, io with tarfile.open('upload.tar', 'w') as t: for name, data in [('readme.txt', b'hello\n'), ('../../home/user/.bashrc', b'echo pwned\n')]: i = tarfile.TarInfo(name); i.size = len(data); t.addfile(i, io.BytesIO(data))
using RecursiveAI.OneArchive; using RecursiveAI.OneCompress; // upload.tar came from a user: one good entry, and one whose name climbs out of the target. using (var archive = Archive.Open("upload.tar")) foreach (var e in archive.Entries) Console.WriteLine($"entry: {e.Name}"); try { Archive.Extract("upload.tar", "out"); } catch (ArchiveSafetyException e) { Console.WriteLine($"refused: rule {e.Rule}, entry {e.EntryName}"); } Console.WriteLine($"files in out/: {string.Join(", ", Directory.GetFiles("out", "*", SearchOption.AllDirectories))}"); // Opting in to Sanitize drops the ".." components and keeps the file inside the target. Archive.Extract("upload.tar", "clean", new ArchiveExtractOptions { UnsafePaths = UnsafePathHandling.Sanitize }); Console.WriteLine($"files in clean/: {string.Join(", ", Directory.GetFiles("clean", "*", SearchOption.AllDirectories).Order())}"); // Limits: a 1 KiB budget for the whole archive. Archive.Create("big.tar").Add(new ArchiveEntry { Name = "big.bin", Kind = ArchiveEntryKind.File, Size = 65536 }, new byte[65536]).Save(); try { using var big = Archive.Open("big.tar", new ArchiveOpenOptions { Limits = new ArchiveLimits { MaxTotalSize = 1024 } }); big.Extract("limited"); } catch (CompressionLimitException e) { Console.WriteLine($"limit: {e.LimitName}: {e.Message}"); }
entry: readme.txt entry: ../../home/user/.bashrc refused: rule path-traversal, entry ../../home/user/.bashrc files in out/: out/readme.txt files in clean/: clean/home/user/.bashrc, clean/readme.txt limit: MaxTotalSize: tar: MaxTotalSize limit exceeded: the input needs 65536 but the limit is 1024
What the defaults refuse
| Rule | Refused by default |
|---|---|
path-traversal | a name that leaves the target, .. included after \ is read as / |
absolute-path | a leading / or \, a UNC path, a drive letter |
link-escape | a symbolic link whose target is absolute or climbs above the target |
link-write-through | writing under a path component that is a symbolic link |
hardlink-target | a hard link to anything but a file extracted earlier from the same archive |
special-file | devices and FIFOs |
existing-file | a file that already exists |
Each is an ArchiveSafetyException with the rule and the entry name, and each can be loosened one at a time on ArchiveExtractOptions (UnsafePaths, Symlinks, HardLinks, SpecialFiles, Existing).
Good to know
- Entries before the bad one are already written when it is refused (
out/readme.txthere). Extract into a fresh directory and delete it on failure. - Limits are on
ArchiveOpenOptions.Limits:MaxTotalSize(1 GiB by default),MaxEntryCount(100,000),MaxPathLength,MaxPathDepth,MaxExpansionRatio(1000) andMaxWindowSize. They are checked as each header is read, before any data, and a hit is aCompressionLimitExceptionnaming the limit, never a truncated file. - setuid, setgid and sticky bits are dropped, and ownership isn’t restored.
All the rules are in archives and safety.