logo

pleroma

My custom branche(s) on git.pleroma.social/pleroma/pleroma git clone https://anongit.hacktivis.me/git/pleroma.git/
commit: b89070a6ad2704f4bc061c22e099f662655c3e6f
parent 0f5ac7e86dd36b80016a90fe8aca581a4275b71e
Author: Lain Soykaf <lain@lain.com>
Date:   Thu, 27 Feb 2025 15:30:20 +0400

SafeZip: Add tests.

Diffstat:

Atest/pleroma/safe_zip_test.exs496+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 496 insertions(+), 0 deletions(-)

diff --git a/test/pleroma/safe_zip_test.exs b/test/pleroma/safe_zip_test.exs @@ -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