- Add organization membership check before API token creation - Fix admin security allowlist form reading current_user instead of current_scope.user - Use recursive File.ls instead of Path.wildcard to include hidden files in MIB validation - Add upload size check before ZIP extraction in MIB controller - Centralize CSP in SecurityHeaders plug with per-request nonces instead of 'unsafe-inline'
531 lines
15 KiB
Elixir
531 lines
15 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_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_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
|