Skip to content

Commit aec26e0

Browse files
authored
Improve field validation for path parameters (#220)
* Return a Path object from path validation function A Path object is much more robust and useful for internal processing. * Use field validation instead of calling functions directly * Raise ToolError instead of returning strings * Allow Path or string objects
1 parent 6cd21d0 commit aec26e0

7 files changed

Lines changed: 73 additions & 109 deletions

File tree

src/linux_mcp_server/formatters.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66

77
from datetime import datetime
8+
from pathlib import Path
89

910
from linux_mcp_server.utils import format_bytes
1011
from linux_mcp_server.utils.types import CpuInfo
@@ -364,7 +365,7 @@ def format_journal_logs(
364365
return "\n".join(lines)
365366

366367

367-
def format_log_file(stdout: str, log_path: str, lines_count: int) -> str:
368+
def format_log_file(stdout: str, log_path: Path, lines_count: int) -> str:
368369
"""Format log file output.
369370
370371
Args:
@@ -456,7 +457,7 @@ def format_hardware_info(results: dict[str, str]) -> str:
456457

457458
def format_directory_listing(
458459
entries: list[NodeEntry],
459-
path: str,
460+
path: str | Path,
460461
sort_by: str,
461462
reverse: bool = False,
462463
) -> str:
@@ -496,7 +497,7 @@ def format_directory_listing(
496497

497498
def format_file_listing(
498499
entries: list[NodeEntry],
499-
path: str,
500+
path: str | Path,
500501
sort_by: str,
501502
reverse: bool = False,
502503
) -> str:

src/linux_mcp_server/tools/logs.py

Lines changed: 19 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44

55
from pathlib import Path
66

7+
from fastmcp.exceptions import ToolError
78
from mcp.types import ToolAnnotations
89
from pydantic import Field
10+
from pydantic.functional_validators import BeforeValidator
911

1012
from linux_mcp_server.audit import log_tool_call
1113
from linux_mcp_server.commands import get_command
@@ -17,7 +19,6 @@
1719
from linux_mcp_server.utils.decorators import disallow_local_execution_in_containers
1820
from linux_mcp_server.utils.types import Host
1921
from linux_mcp_server.utils.validation import is_empty_output
20-
from linux_mcp_server.utils.validation import PathValidationError
2122
from linux_mcp_server.utils.validation import validate_path
2223

2324

@@ -143,9 +144,10 @@ async def get_journal_logs(
143144
)
144145
@log_tool_call
145146
@disallow_local_execution_in_containers
146-
async def read_log_file( # noqa: C901
147+
async def read_log_file(
147148
log_path: t.Annotated[
148-
str,
149+
Path,
150+
BeforeValidator(validate_path),
149151
Field(
150152
description="Absolute path to the log file (must be in allowed list)",
151153
examples=["/var/log/messages", "/var/log/secure", "/var/log/audit/audit.log", "/var/log/dnf.log"],
@@ -163,22 +165,16 @@ async def read_log_file( # noqa: C901
163165
allowed_paths_env = CONFIG.allowed_log_paths
164166

165167
if not allowed_paths_env:
166-
return (
168+
raise ToolError(
167169
"No log files are allowed. Set LINUX_MCP_ALLOWED_LOG_PATHS environment variable "
168170
"with comma-separated list of allowed log file paths."
169171
)
170172

171-
allowed_paths = [p.strip() for p in allowed_paths_env.split(",") if p.strip()]
172-
173-
# Validate path for injection attacks (applies to both local and remote)
174-
try:
175-
validated_path = validate_path(log_path)
176-
except PathValidationError as e:
177-
return f"Invalid log file path: {e}"
173+
allowed_paths = [Path(p.strip()) for p in allowed_paths_env.split(",") if p.strip()]
178174

179175
if not host:
180176
# For local execution, resolve and check against allowlist
181-
requested_path = Path(validated_path).resolve()
177+
requested_path = log_path.resolve()
182178

183179
is_allowed = False
184180
for allowed_path in allowed_paths:
@@ -188,36 +184,32 @@ async def read_log_file( # noqa: C901
188184
break
189185

190186
if not is_allowed:
191-
return (
192-
f"Access to log file '{log_path}' is not allowed.\n"
193-
f"Allowed log files: {', '.join(allowed_paths)}"
194-
) # nofmt
187+
raise ToolError(f"Access to log file '{log_path}' is not allowed.")
195188

196189
if not requested_path.exists():
197-
return f"Log file not found: {log_path}"
190+
raise ToolError(f"Log file not found: {log_path}")
198191

199192
if not requested_path.is_file():
200-
return f"Path is not a file: {log_path}"
193+
raise ToolError(f"Path is not a file: {log_path}")
201194

202195
log_path_str = str(requested_path)
203196
else:
204197
# For remote execution, check against allowlist without resolving
205-
if validated_path not in allowed_paths:
206-
return (
207-
f"Access to log file '{log_path}' is not allowed.\n"
208-
f"Allowed log files: {', '.join(allowed_paths)}"
209-
) # nofmt
210-
log_path_str = validated_path
198+
if log_path not in allowed_paths:
199+
raise ToolError(f"Access to log file '{log_path}' is not allowed.")
200+
201+
log_path_str = str(log_path)
211202

212203
cmd = get_command("read_log_file")
213204
returncode, stdout, stderr = await cmd.run(host=host, lines=lines, log_path=log_path_str)
214205

215206
if returncode != 0:
216207
if "Permission denied" in stderr:
217-
return f"Permission denied reading log file: {log_path}"
218-
return f"Error reading log file: {stderr}"
208+
raise ToolError(f"Permission denied reading log file: {log_path}")
209+
210+
raise ToolError(f"Error reading log file: {stderr}")
219211

220212
if is_empty_output(stdout):
221-
return f"Log file is empty: {log_path}"
213+
raise ToolError(f"Log file is empty: {log_path}")
222214

223215
return format_log_file(stdout, log_path, lines)

src/linux_mcp_server/tools/storage.py

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
import os
44
import typing as t
55

6+
from pathlib import Path
7+
68
from fastmcp.exceptions import ToolError
79
from mcp.types import ToolAnnotations
810
from pydantic import Field
11+
from pydantic.functional_validators import BeforeValidator
912

1013
from linux_mcp_server.audit import log_tool_call
1114
from linux_mcp_server.commands import get_command
@@ -19,7 +22,6 @@
1922
from linux_mcp_server.utils.decorators import disallow_local_execution_in_containers
2023
from linux_mcp_server.utils.types import Host
2124
from linux_mcp_server.utils.validation import is_successful_output
22-
from linux_mcp_server.utils.validation import PathValidationError
2325
from linux_mcp_server.utils.validation import validate_path
2426

2527

@@ -47,14 +49,6 @@ class SortBy(StrEnum):
4749
}
4850

4951

50-
def _validate_path(path: str) -> str:
51-
"""Validate path, converting PathValidationError to ToolError for MCP compatibility."""
52-
try:
53-
return validate_path(path)
54-
except PathValidationError as e:
55-
raise ToolError(str(e)) from e
56-
57-
5852
@mcp.tool(
5953
title="List block devices",
6054
description="List block devices on the system",
@@ -90,7 +84,8 @@ async def list_block_devices(
9084
@disallow_local_execution_in_containers
9185
async def list_directories(
9286
path: t.Annotated[
93-
str,
87+
Path,
88+
BeforeValidator(validate_path),
9489
Field(
9590
description="Absolute path to the directory to analyze",
9691
examples=["/var/log", "/etc", "/home", "/opt", "/tmp"],
@@ -113,8 +108,6 @@ async def list_directories(
113108
Retrieves subdirectories with their size (when ordered by size) or
114109
modification time, supporting flexible sorting and result limiting.
115110
"""
116-
path = _validate_path(path)
117-
118111
# Get the appropriate command for the order_by field
119112
cmd_name = DIRECTORY_COMMANDS[order_by]
120113
cmd = get_command(cmd_name)
@@ -154,7 +147,8 @@ async def list_directories(
154147
@disallow_local_execution_in_containers
155148
async def list_files(
156149
path: t.Annotated[
157-
str,
150+
Path,
151+
BeforeValidator(validate_path),
158152
Field(
159153
description="Absolute path to the directory to analyze",
160154
examples=["/var/log", "/etc", "/home", "/opt", "/tmp"],
@@ -177,10 +171,6 @@ async def list_files(
177171
Retrieves files with their size or modification time, supporting flexible
178172
sorting and result limiting. Useful for finding large or recently modified files.
179173
"""
180-
# For local execution, validate path
181-
if not host:
182-
path = _validate_path(path)
183-
184174
# Get the appropriate command for the order_by field
185175
cmd_name = FILE_COMMANDS[order_by]
186176
cmd = get_command(cmd_name)
@@ -218,7 +208,8 @@ async def list_files(
218208
@disallow_local_execution_in_containers
219209
async def read_file(
220210
path: t.Annotated[
221-
str,
211+
Path,
212+
BeforeValidator(validate_path),
222213
Field(
223214
description="Absolute path to the file to read",
224215
examples=["/etc/hosts", "/etc/resolv.conf", "/etc/os-release", "/proc/cpuinfo"],
@@ -231,10 +222,6 @@ async def read_file(
231222
Retrieves the full contents of a text file. The path must be absolute
232223
and the file must exist. Binary files may not display correctly.
233224
"""
234-
235-
# Validate path
236-
path = _validate_path(path)
237-
238225
if not host:
239226
# For local execution, check early if file exists
240227
if not os.path.isfile(path):

src/linux_mcp_server/utils/validation.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ class PathValidationError(ValueError):
1111
pass
1212

1313

14-
def validate_path(path: str) -> str:
14+
def validate_path(path: str) -> Path:
1515
"""Validate a filesystem path for security and correctness.
1616
1717
Performs security checks to prevent command injection and path traversal attacks:
@@ -53,7 +53,7 @@ def validate_path(path: str) -> str:
5353
if not Path(path).is_absolute():
5454
raise PathValidationError(f"Path must be absolute: {path}")
5555

56-
return Path(path).as_posix()
56+
return Path(path)
5757

5858

5959
def is_empty_output(stdout: str | None) -> bool:

tests/test_formatters.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Tests for formatters module."""
22

3+
from pathlib import Path
4+
35
from linux_mcp_server.formatters import format_block_devices
46
from linux_mcp_server.formatters import format_cpu_info
57
from linux_mcp_server.formatters import format_directory_listing
@@ -416,7 +418,7 @@ class TestFormatLogFile:
416418
def test_format_log_file(self):
417419
"""Test formatting log file."""
418420
stdout = "2024-01-01 10:00:00 INFO Application started"
419-
result = format_log_file(stdout, "/var/log/app.log", 100)
421+
result = format_log_file(stdout, Path("/var/log/app.log"), 100)
420422
assert "=== Log File: /var/log/app.log (last 100 lines) ===" in result
421423
assert "Application started" in result
422424

0 commit comments

Comments
 (0)