Removes unreachable catch-all clauses, drops unused `require Logger` lines, adds pin operators to bitstring size patterns, reorders function heads for correct dispatch, and replaces deprecated LoggerBackends calls.
563 lines
16 KiB
Elixir
563 lines
16 KiB
Elixir
defmodule ToweropsWeb.Api.V1.MibController do
|
|
@moduledoc """
|
|
API controller for managing SNMP MIB files.
|
|
|
|
Allows uploading MIB files to the persistent storage for use with MIB name translation.
|
|
|
|
All endpoints require superuser API token authentication.
|
|
"""
|
|
|
|
use ToweropsWeb, :controller
|
|
|
|
require Logger
|
|
|
|
@mib_dir Application.compile_env(:towerops, :mib_dir, "/app/mibs")
|
|
|
|
# Archive bomb protection limits (superuser MIB upload)
|
|
@max_upload_bytes 100 * 1024 * 1024
|
|
@max_extracted_files 2000
|
|
@max_extracted_bytes 500 * 1024 * 1024
|
|
|
|
@doc """
|
|
Upload a MIB file or archive (.tar.gz, .zip) containing MIB files.
|
|
|
|
Requires superuser API token.
|
|
|
|
## Request
|
|
|
|
POST /api/v1/mibs
|
|
Content-Type: multipart/form-data
|
|
|
|
file: <mib_file>
|
|
vendor: <vendor_name> (optional)
|
|
|
|
## Response
|
|
|
|
201 Created
|
|
{
|
|
"status": "ok",
|
|
"message": "Successfully uploaded MIB file",
|
|
"vendor": "mikrotik",
|
|
"files_count": 15
|
|
}
|
|
"""
|
|
def upload(conn, params) do
|
|
with :ok <- require_superuser(conn) do
|
|
case params["file"] do
|
|
%Plug.Upload{} = upload ->
|
|
handle_upload_with_vendor(conn, upload, params)
|
|
|
|
_ ->
|
|
conn
|
|
|> put_status(:bad_request)
|
|
|> json(%{error: "Missing file parameter"})
|
|
end
|
|
end
|
|
end
|
|
|
|
defp handle_upload_with_vendor(conn, upload, params) do
|
|
vendor = params["vendor"] || "custom"
|
|
|
|
# Validate vendor name to prevent directory traversal
|
|
case validate_vendor_name(vendor) do
|
|
:ok ->
|
|
handle_upload(conn, upload, vendor)
|
|
|
|
{:error, reason} ->
|
|
conn
|
|
|> put_status(:bad_request)
|
|
|> json(%{error: reason})
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
List all available MIB vendors.
|
|
|
|
Requires superuser API token.
|
|
|
|
## Response
|
|
|
|
200 OK
|
|
{
|
|
"vendors": ["mikrotik", "cisco", "ubiquiti", ...],
|
|
"total_files": 1234
|
|
}
|
|
"""
|
|
def index(conn, _params) do
|
|
with :ok <- require_superuser(conn) do
|
|
case list_mib_vendors() do
|
|
{:ok, vendors, total_files} ->
|
|
json(conn, %{
|
|
vendors: vendors,
|
|
total_files: total_files
|
|
})
|
|
|
|
{:error, reason} ->
|
|
Logger.error("MIB vendor list failed: #{inspect(reason)}")
|
|
|
|
conn
|
|
|> put_status(:internal_server_error)
|
|
|> json(%{error: "Failed to list MIB vendors"})
|
|
end
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Delete MIB files for a specific vendor.
|
|
|
|
Requires superuser API token.
|
|
|
|
## Request
|
|
|
|
DELETE /api/v1/mibs/:vendor
|
|
|
|
## Response
|
|
|
|
200 OK
|
|
{
|
|
"status": "ok",
|
|
"message": "Deleted MIB files for vendor: mikrotik"
|
|
}
|
|
"""
|
|
def delete(conn, %{"vendor" => vendor}) do
|
|
with :ok <- require_superuser(conn) do
|
|
# Validate vendor name to prevent directory traversal
|
|
case validate_vendor_name(vendor) do
|
|
:ok ->
|
|
vendor_dir = Path.join(@mib_dir, vendor)
|
|
delete_vendor_mibs(conn, vendor, vendor_dir)
|
|
|
|
{:error, reason} ->
|
|
conn
|
|
|> put_status(:bad_request)
|
|
|> json(%{error: reason})
|
|
end
|
|
end
|
|
end
|
|
|
|
defp delete_vendor_mibs(conn, vendor, vendor_dir) do
|
|
if File.exists?(vendor_dir) do
|
|
remove_vendor_directory(conn, vendor, vendor_dir)
|
|
else
|
|
conn
|
|
|> put_status(:not_found)
|
|
|> json(%{error: "Vendor not found: #{vendor}"})
|
|
end
|
|
end
|
|
|
|
defp remove_vendor_directory(conn, vendor, vendor_dir) do
|
|
# Vendor directory is safe - constructed from validated vendor name and base MIB directory
|
|
# Validation ensures no path traversal characters (., /, \, :) are present
|
|
case File.rm_rf(vendor_dir) do
|
|
{:ok, _files} ->
|
|
Logger.info("Deleted MIB files for vendor: #{vendor}")
|
|
invalidate_mib_cache()
|
|
|
|
json(conn, %{
|
|
status: "ok",
|
|
message: "Deleted MIB files for vendor: #{vendor}"
|
|
})
|
|
|
|
{:error, reason, file} ->
|
|
Logger.error("Failed to delete MIB files for #{vendor}: #{file} - #{inspect(reason)}")
|
|
|
|
conn
|
|
|> put_status(:internal_server_error)
|
|
|> json(%{error: "Failed to delete MIB files"})
|
|
end
|
|
end
|
|
|
|
# Private functions
|
|
|
|
defp require_superuser(conn) do
|
|
user = conn.assigns[:current_user]
|
|
|
|
if !user || !user.is_superuser do
|
|
Logger.warning("MIB management attempted by non-superuser: #{inspect(user && user.email)}")
|
|
|
|
conn
|
|
|> put_status(:forbidden)
|
|
|> json(%{error: "Superuser access required. Only API tokens created by superusers can manage MIB files."})
|
|
|> halt()
|
|
else
|
|
:ok
|
|
end
|
|
end
|
|
|
|
defp handle_upload(conn, upload, vendor) do
|
|
vendor_dir = Path.join(@mib_dir, vendor)
|
|
File.mkdir_p!(vendor_dir)
|
|
|
|
cond do
|
|
String.ends_with?(upload.filename, ".tar.gz") or String.ends_with?(upload.filename, ".tgz") ->
|
|
extract_tarball(conn, upload, vendor_dir, vendor)
|
|
|
|
String.ends_with?(upload.filename, ".zip") ->
|
|
extract_zip(conn, upload, vendor_dir, vendor)
|
|
|
|
true ->
|
|
copy_single_file(conn, upload, vendor_dir, vendor)
|
|
end
|
|
end
|
|
|
|
defp extract_tarball(conn, upload, vendor_dir, vendor) do
|
|
case check_upload_size(upload) do
|
|
:ok ->
|
|
random_suffix = 16 |> :crypto.strong_rand_bytes() |> Base.encode16(case: :lower)
|
|
temp_dir = Path.join(System.tmp_dir!(), "mib_extract_#{random_suffix}")
|
|
File.mkdir_p!(temp_dir)
|
|
|
|
try do
|
|
case System.cmd("tar", ["-xzf", upload.path, "-C", temp_dir]) do
|
|
{_output, 0} ->
|
|
case validate_extracted_paths(temp_dir) do
|
|
:ok ->
|
|
_ = File.cp_r!(temp_dir, vendor_dir)
|
|
files_count = count_files(vendor_dir)
|
|
Logger.info("Extracted #{files_count} MIB files for vendor: #{vendor}")
|
|
invalidate_mib_cache()
|
|
|
|
conn
|
|
|> put_status(:created)
|
|
|> json(%{
|
|
status: "ok",
|
|
message: "Successfully extracted MIB archive",
|
|
vendor: vendor,
|
|
files_count: files_count
|
|
})
|
|
|
|
{:error, reason} ->
|
|
Logger.warning("Archive validation failed for vendor #{vendor}: #{reason}")
|
|
|
|
conn
|
|
|> put_status(:bad_request)
|
|
|> json(%{error: reason})
|
|
end
|
|
|
|
{error, exit_code} ->
|
|
Logger.error("Failed to extract tarball: #{error}")
|
|
|
|
conn
|
|
|> put_status(:bad_request)
|
|
|> json(%{error: "Failed to extract archive (exit code: #{exit_code})"})
|
|
end
|
|
after
|
|
File.rm_rf!(temp_dir)
|
|
end
|
|
|
|
{:error, reason} ->
|
|
conn |> put_status(:bad_request) |> json(%{error: reason})
|
|
end
|
|
end
|
|
|
|
defp extract_zip(conn, upload, vendor_dir, vendor) do
|
|
case check_upload_size(upload) do
|
|
:ok ->
|
|
random_suffix = 16 |> :crypto.strong_rand_bytes() |> Base.encode16(case: :lower)
|
|
temp_dir = Path.join(System.tmp_dir!(), "mib_extract_#{random_suffix}")
|
|
File.mkdir_p!(temp_dir)
|
|
|
|
try do
|
|
case System.cmd("unzip", ["-o", upload.path, "-d", temp_dir]) do
|
|
{_output, 0} ->
|
|
case validate_extracted_paths(temp_dir) do
|
|
:ok ->
|
|
_ = File.cp_r!(temp_dir, vendor_dir)
|
|
files_count = count_files(vendor_dir)
|
|
Logger.info("Extracted #{files_count} MIB files for vendor: #{vendor}")
|
|
invalidate_mib_cache()
|
|
|
|
conn
|
|
|> put_status(:created)
|
|
|> json(%{
|
|
status: "ok",
|
|
message: "Successfully extracted MIB archive",
|
|
vendor: vendor,
|
|
files_count: files_count
|
|
})
|
|
|
|
{:error, reason} ->
|
|
Logger.warning("Archive validation failed for vendor #{vendor}: #{reason}")
|
|
|
|
conn
|
|
|> put_status(:bad_request)
|
|
|> json(%{error: reason})
|
|
end
|
|
|
|
{error, exit_code} ->
|
|
Logger.error("Failed to extract zip: #{error}")
|
|
|
|
conn
|
|
|> put_status(:bad_request)
|
|
|> json(%{error: "Failed to extract archive (exit code: #{exit_code})"})
|
|
end
|
|
after
|
|
File.rm_rf!(temp_dir)
|
|
end
|
|
|
|
{:error, reason} ->
|
|
conn |> put_status(:bad_request) |> json(%{error: reason})
|
|
end
|
|
end
|
|
|
|
defp copy_single_file(conn, upload, vendor_dir, vendor) do
|
|
# Validate filename to prevent directory traversal
|
|
case validate_filename(upload.filename) do
|
|
:ok ->
|
|
copy_validated_file(conn, upload, vendor_dir, vendor)
|
|
|
|
{:error, reason} ->
|
|
Logger.warning("Invalid filename rejected: #{upload.filename} for vendor: #{vendor}")
|
|
|
|
conn
|
|
|> put_status(:bad_request)
|
|
|> json(%{error: reason})
|
|
end
|
|
end
|
|
|
|
defp copy_validated_file(conn, upload, vendor_dir, vendor) do
|
|
# Use Path.basename to ensure we only use the filename, not any path components
|
|
safe_filename = Path.basename(upload.filename)
|
|
target_path = Path.join(vendor_dir, safe_filename)
|
|
|
|
# Check if target already exists as a directory
|
|
if File.dir?(target_path) do
|
|
Logger.warning("Cannot upload MIB file: target path is a directory: #{safe_filename} for vendor: #{vendor}")
|
|
|
|
conn
|
|
|> put_status(:bad_request)
|
|
|> json(%{error: "Cannot upload: filename conflicts with existing directory"})
|
|
else
|
|
# upload.path is safe - it's controlled by Plug.Upload, not user input
|
|
case File.cp(upload.path, target_path) do
|
|
:ok ->
|
|
Logger.info("Uploaded MIB file: #{upload.filename} for vendor: #{vendor}")
|
|
invalidate_mib_cache()
|
|
|
|
conn
|
|
|> put_status(:created)
|
|
|> json(%{
|
|
status: "ok",
|
|
message: "Successfully uploaded MIB file",
|
|
vendor: vendor,
|
|
filename: upload.filename
|
|
})
|
|
|
|
{:error, :eisdir} ->
|
|
Logger.error("Failed to copy MIB file - target is a directory: #{upload.filename} for vendor: #{vendor}")
|
|
|
|
conn
|
|
|> put_status(:bad_request)
|
|
|> json(%{error: "Cannot upload: filename conflicts with existing directory"})
|
|
|
|
{:error, reason} ->
|
|
Logger.error("Failed to copy MIB file: #{inspect(reason)}")
|
|
|
|
conn
|
|
|> put_status(:internal_server_error)
|
|
|> json(%{error: "Failed to upload file"})
|
|
end
|
|
end
|
|
end
|
|
|
|
@cache_key :towerops_mib_vendor_cache
|
|
|
|
defp list_mib_vendors do
|
|
case :persistent_term.get(@cache_key, nil) do
|
|
nil ->
|
|
result = compute_mib_vendors()
|
|
:persistent_term.put(@cache_key, result)
|
|
result
|
|
|
|
cached ->
|
|
cached
|
|
end
|
|
rescue
|
|
e ->
|
|
Logger.error("Failed to list MIB vendors: #{inspect(e)}")
|
|
{:error, e}
|
|
end
|
|
|
|
defp compute_mib_vendors do
|
|
if File.exists?(@mib_dir) do
|
|
vendors =
|
|
@mib_dir
|
|
|> File.ls!()
|
|
|> Enum.filter(fn name ->
|
|
path = Path.join(@mib_dir, name)
|
|
File.dir?(path) and name != "lost+found"
|
|
end)
|
|
|> Enum.sort()
|
|
|
|
total_files = count_files(@mib_dir)
|
|
{:ok, vendors, total_files}
|
|
else
|
|
{:ok, [], 0}
|
|
end
|
|
end
|
|
|
|
defp invalidate_mib_cache do
|
|
:persistent_term.erase(@cache_key)
|
|
rescue
|
|
ArgumentError -> :ok
|
|
end
|
|
|
|
defp check_upload_size(%Plug.Upload{path: path}) do
|
|
case File.stat(path) do
|
|
{:ok, %{size: size}} when size <= @max_upload_bytes -> :ok
|
|
{:ok, _} -> {:error, "Upload exceeds maximum size of 100 MB"}
|
|
{:error, _} -> {:error, "Failed to read uploaded file"}
|
|
end
|
|
end
|
|
|
|
defp count_files(dir) do
|
|
dir |> list_all_entries() |> Enum.count(&File.regular?/1)
|
|
end
|
|
|
|
# Recursively list all entries including hidden files and directories.
|
|
defp list_all_entries(dir) do
|
|
dir
|
|
|> list_dir_entries()
|
|
|> Enum.flat_map(fn path ->
|
|
case File.lstat(path) do
|
|
{:ok, stat} when stat.type == :directory -> [path | list_all_entries(path)]
|
|
_ -> [path]
|
|
end
|
|
end)
|
|
end
|
|
|
|
defp list_dir_entries(dir) do
|
|
case File.ls(dir) do
|
|
{:ok, entries} -> Enum.map(entries, &Path.join(dir, &1))
|
|
{:error, _} -> []
|
|
end
|
|
end
|
|
|
|
# Validate that extracted archive contents don't contain path traversal attacks,
|
|
# symlinks, or excessive file counts/sizes.
|
|
# Uses list_all_entries/1 which includes hidden files/directories (unlike Path.wildcard).
|
|
defp validate_extracted_paths(extract_dir) do
|
|
canonical_extract_dir = Path.expand(extract_dir)
|
|
all_paths = list_all_entries(extract_dir)
|
|
|
|
with :ok <- check_no_symlinks(all_paths),
|
|
:ok <- check_no_hardlinks(all_paths),
|
|
:ok <- check_no_special_files(all_paths),
|
|
:ok <- check_no_traversal(all_paths, canonical_extract_dir) do
|
|
check_file_limits(all_paths)
|
|
end
|
|
rescue
|
|
e ->
|
|
Logger.error("Failed to validate extracted paths: #{inspect(e)}")
|
|
{:error, "Failed to validate archive contents"}
|
|
end
|
|
|
|
defp check_no_symlinks(paths) do
|
|
if Enum.any?(paths, &symlink?/1) do
|
|
{:error, "Archive contains symlinks, which are not allowed"}
|
|
else
|
|
:ok
|
|
end
|
|
end
|
|
|
|
defp symlink?(path) do
|
|
match?({:ok, %{type: :symlink}}, File.lstat(path))
|
|
end
|
|
|
|
defp check_no_hardlinks(paths) do
|
|
if Enum.any?(paths, &hardlink?/1) do
|
|
{:error, "Archive contains hard links, which are not allowed"}
|
|
else
|
|
:ok
|
|
end
|
|
end
|
|
|
|
defp hardlink?(path) do
|
|
case :file.read_file_info(String.to_charlist(path)) do
|
|
{:ok, file_info} -> elem(file_info, 8) > 1
|
|
_ -> false
|
|
end
|
|
end
|
|
|
|
defp check_no_special_files(paths) do
|
|
if Enum.any?(paths, &special_file?/1) do
|
|
{:error, "Archive contains device nodes, FIFOs, or other special files"}
|
|
else
|
|
:ok
|
|
end
|
|
end
|
|
|
|
defp special_file?(path) do
|
|
case File.lstat(path) do
|
|
{:ok, stat} -> stat.type not in [:regular, :directory, :symlink]
|
|
_ -> false
|
|
end
|
|
end
|
|
|
|
defp check_no_traversal(paths, canonical_base) do
|
|
if Enum.all?(paths, fn path ->
|
|
path |> Path.expand() |> String.starts_with?(canonical_base)
|
|
end) do
|
|
:ok
|
|
else
|
|
{:error, "Archive contains files with invalid paths (possible directory traversal attack)"}
|
|
end
|
|
end
|
|
|
|
defp check_file_limits(paths) do
|
|
file_count = Enum.count(paths, &File.regular?/1)
|
|
|
|
if file_count > @max_extracted_files do
|
|
{:error, "Archive contains too many files (#{file_count}, max #{@max_extracted_files})"}
|
|
else
|
|
check_total_bytes(paths)
|
|
end
|
|
end
|
|
|
|
defp check_total_bytes(paths) do
|
|
total_bytes =
|
|
Enum.reduce(paths, 0, fn path, acc ->
|
|
case File.stat(path) do
|
|
{:ok, %{size: s}} -> acc + s
|
|
_ -> acc
|
|
end
|
|
end)
|
|
|
|
if total_bytes > @max_extracted_bytes do
|
|
{:error,
|
|
"Archive expands to #{div(total_bytes, 1024 * 1024)} MB (max #{div(@max_extracted_bytes, 1024 * 1024)} MB)"}
|
|
else
|
|
:ok
|
|
end
|
|
end
|
|
|
|
# Validate vendor name to prevent directory traversal attacks
|
|
# Only allow alphanumeric characters, hyphens, and underscores
|
|
defp validate_vendor_name(vendor) when is_binary(vendor) do
|
|
# Check for directory traversal sequences
|
|
if String.contains?(vendor, [".", "/", "\\", ":"]) do
|
|
{:error, "Invalid vendor name: cannot contain path separators or dots"}
|
|
else
|
|
# Check if vendor name matches safe pattern (alphanumeric, hyphen, underscore)
|
|
if vendor =~ ~r/^[a-zA-Z0-9_-]+$/ do
|
|
:ok
|
|
else
|
|
{:error, "Invalid vendor name: must contain only letters, numbers, hyphens, and underscores"}
|
|
end
|
|
end
|
|
end
|
|
|
|
defp validate_vendor_name(_), do: {:error, "Invalid vendor name"}
|
|
|
|
# Validate filename to prevent directory traversal
|
|
defp validate_filename(filename) when is_binary(filename) do
|
|
# Check for directory traversal sequences
|
|
if String.contains?(filename, ["..", "/", "\\"]) or String.starts_with?(filename, ".") do
|
|
{:error, "Invalid filename: cannot contain path separators or parent directory references"}
|
|
else
|
|
:ok
|
|
end
|
|
end
|
|
|
|
defp validate_filename(_), do: {:error, "Invalid filename"}
|
|
end
|