fix: reject archive entries that extract outside the output directory (#2720)

## Summary
`ManagedArchive.ExtractTarFromStream` joined each tar entry name directly onto the output directory, so an entry such as `../x`, `sub/../../x` or an absolute path was written outside it. The restore path (`AutoArchive` → `ExtractTarZstd`/`ExtractTarGz`/`ExtractTar`) feeds archives through this method.

Each entry now resolves to a full path and must stay under the output root (case-insensitive on Windows). An entry that escapes fails the extraction; the existing `catch` in each `Extract*` method logs it and returns `false`.

The input is the server's own backup archives, so this is hardening against a tampered or foreign archive, not something a player can reach.

## Tests
- `ExtractTar_EntryOutsideOutputDirectory_IsRejected` (`../escaped.txt`, `sub/../../escaped.txt`) and `ExtractTar_RootedEntry_IsRejected`. All three fail without the fix.
- All 18 `ManagedArchiveTests` pass.
This commit is contained in:
Kamron Batman 2026-10-18 20:06:23 -07:00 • committed by GitHub
parent 0f89870793
commit 078be52777
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 53 additions and 1 deletions

View file

@ -311,6 +311,50 @@ public class ManagedArchiveTests : IDisposable
Assert.Equal("Plain tar test data", File.ReadAllText(Path.Combine(extractDir, "plain.txt")));
}
[Theory]
[InlineData("../escaped.txt")]
[InlineData("sub/../../escaped.txt")]
public void ExtractTar_EntryOutsideOutputDirectory_IsRejected(string entryName)
{
var archivePath = Path.Combine(_testDir, "traversal.tar");
using (var fileStream = new FileStream(archivePath, FileMode.Create))
using (var tarWriter = new TarWriter(fileStream, TarEntryFormat.Pax, leaveOpen: true))
{
var entry = new PaxTarEntry(TarEntryType.RegularFile, entryName)
{
DataStream = new MemoryStream("escaped"u8.ToArray())
};
tarWriter.WriteEntry(entry);
}
var extractDir = Path.Combine(_testDir, "out", "extract");
Assert.False(ManagedArchive.ExtractTar(archivePath, extractDir));
Assert.False(File.Exists(Path.Combine(_testDir, "out", "escaped.txt")));
Assert.False(File.Exists(Path.Combine(_testDir, "escaped.txt")));
}
[Fact]
public void ExtractTar_RootedEntry_IsRejected()
{
var archivePath = Path.Combine(_testDir, "rooted.tar");
var target = Path.Combine(_testDir, "rooted-escaped.txt");
using (var fileStream = new FileStream(archivePath, FileMode.Create))
using (var tarWriter = new TarWriter(fileStream, TarEntryFormat.Pax, leaveOpen: true))
{
var entry = new PaxTarEntry(TarEntryType.RegularFile, target)
{
DataStream = new MemoryStream("escaped"u8.ToArray())
};
tarWriter.WriteEntry(entry);
}
Assert.False(ManagedArchive.ExtractTar(archivePath, Path.Combine(_testDir, "rooted-extract")));
Assert.False(File.Exists(target));
}
[Fact]
public void ExtractTarGz_InvalidFile_ReturnsFalse()
{