SafeZip: Add tests.

This commit is contained in:
Lain Soykaf 2025-02-27 15:30:20 +04:00
parent 0f5ac7e86d
commit b89070a6ad

View file

@ -0,0 +1,496 @@
defmodule Pleroma.SafeZipTest do
# Not making this async because it creates and deletes files
use ExUnit.Case
alias Pleroma.SafeZip
@fixtures_dir "test/fixtures"
@tmp_dir "test/zip_tmp"
setup do
# Ensure tmp directory exists
File.mkdir_p!(@tmp_dir)
on_exit(fn ->
# Clean up any files created during tests
File.rm_rf!(@tmp_dir)
File.mkdir_p!(@tmp_dir)
end)
:ok
end
describe "list_dir_file/1" do
test "lists files in a valid zip" do
{:ok, files} = SafeZip.list_dir_file(Path.join(@fixtures_dir, "emojis.zip"))
assert is_list(files)
assert length(files) > 0
end
test "returns an empty list for empty zip" do
{:ok, files} = SafeZip.list_dir_file(Path.join(@fixtures_dir, "empty.zip"))
assert files == []
end
test "returns error for non-existent file" do
assert {:error, _} = SafeZip.list_dir_file(Path.join(@fixtures_dir, "nonexistent.zip"))
end
test "only lists regular files, not directories" do
# Create a zip with both files and directories
zip_path = create_zip_with_directory()
# List files with SafeZip
{:ok, files} = SafeZip.list_dir_file(zip_path)
# Verify only regular files are listed, not directories
assert "file_in_dir/test_file.txt" in files
assert "root_file.txt" in files
# Directory entries should not be included in the list
refute "file_in_dir/" in files
end
end
describe "contains_all_data?/2" do
test "returns true when all files are in the archive" do
# For this test, we'll create our own zip file with known content
# to ensure we can test the contains_all_data? function properly
zip_path = create_zip_with_directory()
archive_data = File.read!(zip_path)
# Check if the archive contains the root file
# Note: The function expects charlists (Erlang strings) in the MapSet
assert SafeZip.contains_all_data?(archive_data, MapSet.new([~c"root_file.txt"]))
end
test "returns false when files are missing" do
archive_path = Path.join(@fixtures_dir, "emojis.zip")
archive_data = File.read!(archive_path)
# Create a MapSet with non-existent files
fset = MapSet.new([~c"nonexistent.txt"])
refute SafeZip.contains_all_data?(archive_data, fset)
end
test "returns false for invalid archive data" do
refute SafeZip.contains_all_data?("invalid data", MapSet.new([~c"file.txt"]))
end
test "only checks for regular files, not directories" do
# Create a zip with both files and directories
zip_path = create_zip_with_directory()
archive_data = File.read!(zip_path)
# Check if the archive contains a directory (should return false)
refute SafeZip.contains_all_data?(archive_data, MapSet.new([~c"file_in_dir/"]))
# For this test, we'll manually check if the file exists in the archive
# by extracting it and verifying it exists
extract_dir = Path.join(@tmp_dir, "extract_check")
File.mkdir_p!(extract_dir)
{:ok, files} = SafeZip.unzip_file(zip_path, extract_dir)
# Verify the root file was extracted
assert Enum.any?(files, fn file ->
Path.basename(file) == "root_file.txt"
end)
# Verify the file exists on disk
assert File.exists?(Path.join(extract_dir, "root_file.txt"))
end
end
describe "zip/4" do
test "creates a zip file on disk" do
# Create a test file
test_file_path = Path.join(@tmp_dir, "test_file.txt")
File.write!(test_file_path, "test content")
# Create a zip file
zip_path = Path.join(@tmp_dir, "test.zip")
assert {:ok, ^zip_path} = SafeZip.zip(zip_path, ["test_file.txt"], @tmp_dir, false)
# Verify the zip file exists
assert File.exists?(zip_path)
end
test "creates a zip file in memory" do
# Create a test file
test_file_path = Path.join(@tmp_dir, "test_file.txt")
File.write!(test_file_path, "test content")
# Create a zip file in memory
zip_name = Path.join(@tmp_dir, "test.zip")
assert {:ok, {^zip_name, zip_data}} =
SafeZip.zip(zip_name, ["test_file.txt"], @tmp_dir, true)
# Verify the zip data is binary
assert is_binary(zip_data)
end
test "returns error for unsafe paths" do
# Try to zip a file with path traversal
assert {:error, _} =
SafeZip.zip(
Path.join(@tmp_dir, "test.zip"),
["../fixtures/test.txt"],
@tmp_dir,
false
)
end
test "can create zip with directories" do
# Create a directory structure
dir_path = Path.join(@tmp_dir, "test_dir")
File.mkdir_p!(dir_path)
file_in_dir_path = Path.join(dir_path, "file_in_dir.txt")
File.write!(file_in_dir_path, "file in directory")
# Create a zip file
zip_path = Path.join(@tmp_dir, "dir_test.zip")
assert {:ok, ^zip_path} =
SafeZip.zip(
zip_path,
["test_dir/file_in_dir.txt"],
@tmp_dir,
false
)
# Verify the zip file exists
assert File.exists?(zip_path)
# Extract and verify the directory structure is preserved
extract_dir = Path.join(@tmp_dir, "extract")
{:ok, files} = SafeZip.unzip_file(zip_path, extract_dir)
# Check if the file path is in the list, accounting for possible full paths
assert Enum.any?(files, fn file ->
String.ends_with?(file, "file_in_dir.txt")
end)
# Verify the file exists in the expected location
assert File.exists?(Path.join([extract_dir, "test_dir", "file_in_dir.txt"]))
end
end
describe "unzip_file/3" do
test "extracts files from a zip archive" do
archive_path = Path.join(@fixtures_dir, "emojis.zip")
# Extract the archive
assert {:ok, files} = SafeZip.unzip_file(archive_path, @tmp_dir)
# Verify files were extracted
assert is_list(files)
assert length(files) > 0
# Verify at least one file exists
first_file = List.first(files)
# Simply check that the file exists in the tmp directory
assert File.exists?(Path.join(@tmp_dir, Path.basename(first_file)))
end
test "extracts specific files from a zip archive" do
archive_path = Path.join(@fixtures_dir, "emojis.zip")
# Get list of files in the archive
{:ok, all_files} = SafeZip.list_dir_file(archive_path)
file_to_extract = List.first(all_files)
# Extract only one file
assert {:ok, [extracted_file]} =
SafeZip.unzip_file(archive_path, @tmp_dir, [file_to_extract])
# Verify only the specified file was extracted
assert Path.basename(extracted_file) == Path.basename(file_to_extract)
# Check that the file exists in the tmp directory
assert File.exists?(Path.join(@tmp_dir, Path.basename(file_to_extract)))
end
test "returns error for invalid zip file" do
invalid_path = Path.join(@tmp_dir, "invalid.zip")
File.write!(invalid_path, "not a zip file")
assert {:error, _} = SafeZip.unzip_file(invalid_path, @tmp_dir)
end
test "creates directories when extracting files in subdirectories" do
# Create a zip with files in subdirectories
zip_path = create_zip_with_directory()
# Extract the archive
assert {:ok, files} = SafeZip.unzip_file(zip_path, @tmp_dir)
# Verify files were extracted - handle both relative and absolute paths
assert Enum.any?(files, fn file ->
Path.basename(file) == "test_file.txt" &&
String.contains?(file, "file_in_dir")
end)
assert Enum.any?(files, fn file ->
Path.basename(file) == "root_file.txt"
end)
# Verify directory was created
dir_path = Path.join(@tmp_dir, "file_in_dir")
assert File.exists?(dir_path)
assert File.dir?(dir_path)
# Verify file in directory was extracted
file_path = Path.join(dir_path, "test_file.txt")
assert File.exists?(file_path)
end
end
describe "unzip_data/3" do
test "extracts files from zip data" do
archive_path = Path.join(@fixtures_dir, "emojis.zip")
archive_data = File.read!(archive_path)
# Extract the archive from data
assert {:ok, files} = SafeZip.unzip_data(archive_data, @tmp_dir)
# Verify files were extracted
assert is_list(files)
assert length(files) > 0
# Verify at least one file exists
first_file = List.first(files)
# Simply check that the file exists in the tmp directory
assert File.exists?(Path.join(@tmp_dir, Path.basename(first_file)))
end
test "extracts specific files from zip data" do
archive_path = Path.join(@fixtures_dir, "emojis.zip")
archive_data = File.read!(archive_path)
# Get list of files in the archive
{:ok, all_files} = SafeZip.list_dir_file(archive_path)
file_to_extract = List.first(all_files)
# Extract only one file
assert {:ok, extracted_files} =
SafeZip.unzip_data(archive_data, @tmp_dir, [file_to_extract])
# Verify only the specified file was extracted
assert Enum.any?(extracted_files, fn path ->
Path.basename(path) == Path.basename(file_to_extract)
end)
# Simply check that the file exists in the tmp directory
assert File.exists?(Path.join(@tmp_dir, Path.basename(file_to_extract)))
end
test "returns error for invalid zip data" do
assert {:error, _} = SafeZip.unzip_data("not a zip file", @tmp_dir)
end
test "creates directories when extracting files in subdirectories from data" do
# Create a zip with files in subdirectories
zip_path = create_zip_with_directory()
archive_data = File.read!(zip_path)
# Extract the archive from data
assert {:ok, files} = SafeZip.unzip_data(archive_data, @tmp_dir)
# Verify files were extracted - handle both relative and absolute paths
assert Enum.any?(files, fn file ->
Path.basename(file) == "test_file.txt" &&
String.contains?(file, "file_in_dir")
end)
assert Enum.any?(files, fn file ->
Path.basename(file) == "root_file.txt"
end)
# Verify directory was created
dir_path = Path.join(@tmp_dir, "file_in_dir")
assert File.exists?(dir_path)
assert File.dir?(dir_path)
# Verify file in directory was extracted
file_path = Path.join(dir_path, "test_file.txt")
assert File.exists?(file_path)
end
end
# Security tests
describe "security checks" do
test "prevents path traversal in zip extraction" do
# Create a malicious zip file with path traversal
malicious_zip_path = create_malicious_zip_with_path_traversal()
# Try to extract it with SafeZip
assert {:error, _} = SafeZip.unzip_file(malicious_zip_path, @tmp_dir)
# Verify the file was not extracted outside the target directory
refute File.exists?(Path.join(Path.dirname(@tmp_dir), "traversal_attempt.txt"))
end
test "prevents directory traversal in zip listing" do
# Create a malicious zip file with path traversal
malicious_zip_path = create_malicious_zip_with_path_traversal()
# Try to list files with SafeZip
assert {:error, _} = SafeZip.list_dir_file(malicious_zip_path)
end
test "prevents path traversal in zip data extraction" do
# Create a malicious zip file with path traversal
malicious_zip_path = create_malicious_zip_with_path_traversal()
malicious_data = File.read!(malicious_zip_path)
# Try to extract it with SafeZip
assert {:error, _} = SafeZip.unzip_data(malicious_data, @tmp_dir)
# Verify the file was not extracted outside the target directory
refute File.exists?(Path.join(Path.dirname(@tmp_dir), "traversal_attempt.txt"))
end
test "handles zip bomb attempts" do
# Create a zip bomb (a zip with many files or large files)
zip_bomb_path = create_zip_bomb()
# The SafeZip module should handle this gracefully
# Either by successfully extracting it (if it's not too large)
# or by returning an error (if it detects a potential zip bomb)
result = SafeZip.unzip_file(zip_bomb_path, @tmp_dir)
case result do
{:ok, _} ->
# If it successfully extracts, make sure it didn't fill up the disk
# This is a simple check to ensure the extraction was controlled
assert File.exists?(@tmp_dir)
{:error, _} ->
# If it returns an error, that's also acceptable
# The important thing is that it doesn't crash or hang
assert true
end
end
test "handles deeply nested directory structures" do
# Create a zip with deeply nested directories
deep_nest_path = create_deeply_nested_zip()
# The SafeZip module should handle this gracefully
result = SafeZip.unzip_file(deep_nest_path, @tmp_dir)
case result do
{:ok, files} ->
# If it successfully extracts, verify the files were extracted
assert is_list(files)
assert length(files) > 0
{:error, _} ->
# If it returns an error, that's also acceptable
# The important thing is that it doesn't crash or hang
assert true
end
end
end
# Helper functions to create test fixtures
# Creates a zip file with a path traversal attempt
defp create_malicious_zip_with_path_traversal do
malicious_zip_path = Path.join(@tmp_dir, "path_traversal.zip")
# Create a file to include in the zip
test_file_path = Path.join(@tmp_dir, "test_file.txt")
File.write!(test_file_path, "malicious content")
# Use Erlang's zip module directly to create a zip with path traversal
{:ok, charlist_path} =
:zip.create(
String.to_charlist(malicious_zip_path),
[{String.to_charlist("../traversal_attempt.txt"), File.read!(test_file_path)}]
)
to_string(charlist_path)
end
# Creates a zip file with directory entries
defp create_zip_with_directory do
zip_path = Path.join(@tmp_dir, "with_directory.zip")
# Create files to include in the zip
root_file_path = Path.join(@tmp_dir, "root_file.txt")
File.write!(root_file_path, "root file content")
# Create a directory and a file in it
dir_path = Path.join(@tmp_dir, "file_in_dir")
File.mkdir_p!(dir_path)
file_in_dir_path = Path.join(dir_path, "test_file.txt")
File.write!(file_in_dir_path, "file in directory content")
# Use Erlang's zip module to create a zip with directory structure
{:ok, charlist_path} =
:zip.create(
String.to_charlist(zip_path),
[
{String.to_charlist("root_file.txt"), File.read!(root_file_path)},
{String.to_charlist("file_in_dir/test_file.txt"), File.read!(file_in_dir_path)}
]
)
to_string(charlist_path)
end
# Creates a zip bomb (a zip with many small files)
defp create_zip_bomb do
zip_path = Path.join(@tmp_dir, "zip_bomb.zip")
# Create a small file to duplicate many times
small_file_path = Path.join(@tmp_dir, "small_file.txt")
File.write!(small_file_path, String.duplicate("A", 100))
# Create a list of many files to include in the zip
file_entries =
for i <- 1..100 do
{String.to_charlist("file_#{i}.txt"), File.read!(small_file_path)}
end
# Use Erlang's zip module to create a zip with many files
{:ok, charlist_path} =
:zip.create(
String.to_charlist(zip_path),
file_entries
)
to_string(charlist_path)
end
# Creates a zip with deeply nested directories
defp create_deeply_nested_zip do
zip_path = Path.join(@tmp_dir, "deep_nest.zip")
# Create a file to include in the zip
file_content = "test content"
# Create a list of deeply nested files
file_entries =
for i <- 1..10 do
nested_path = Enum.reduce(1..i, "nested", fn j, acc -> "#{acc}/level_#{j}" end)
{String.to_charlist("#{nested_path}/file.txt"), file_content}
end
# Use Erlang's zip module to create a zip with deeply nested directories
{:ok, charlist_path} =
:zip.create(
String.to_charlist(zip_path),
file_entries
)
to_string(charlist_path)
end
end