File size: 4,229 Bytes
9abace2 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 | """
Path Jail — Directory Traversal Prevention
Part of SOVEREIGN PYTHON LLM ENGINE
All filesystem tool access routes through this module.
Prevents: ../traversal, symlink escape, null-byte injection, device files.
"""
from pathlib import Path, PurePosixPath, PureWindowsPath
import os
import re
class PathJailError(Exception):
pass
class PathJail:
"""
Confines all file operations to allowed root directories.
Usage:
jail = PathJail(roots=[Path("/workspace"), Path("/tmp/scratch")])
safe = jail.resolve("/workspace/src/main.py") # OK
jail.resolve("/workspace/../etc/passwd") # PathJailError
jail.resolve("/workspace/link_to_etc") # PathJailError (symlink escape)
"""
def __init__(self, roots: list[Path]):
self._roots = [r.resolve() for r in roots]
def resolve(self, requested: str | Path) -> Path:
"""
Resolve and validate a path against the jail.
Raises PathJailError if the resolved path escapes all roots.
"""
raw = str(requested)
if '\x00' in raw:
raise PathJailError("Null byte in path")
if re.search(r'[\x01-\x1f]', raw):
raise PathJailError("Control characters in path")
target = Path(raw).resolve()
if os.name == 'nt':
name_lower = target.name.lower()
if name_lower in ('con', 'prn', 'aux', 'nul', 'com1', 'lpt1'):
raise PathJailError(f"Reserved device name: {target.name}")
for root in self._roots:
try:
target.relative_to(root)
return target
except ValueError:
continue
raise PathJailError(
f"Path escapes jail: {target} not under any root: "
f"{[str(r) for r in self._roots]}"
)
def resolve_no_symlink(self, requested: str | Path) -> Path:
"""
Resolve path and reject if any component is a symlink.
Stricter than resolve() — prevents TOCTOU via symlink swap.
"""
target = self.resolve(requested)
current = target
while current != current.parent:
if current.is_symlink():
raise PathJailError(f"Symlink in path: {current}")
current = current.parent
return target
def is_safe(self, requested: str | Path) -> bool:
try:
self.resolve(requested)
return True
except PathJailError:
return False
class SSRFGuard:
"""
Prevent Server-Side Request Forgery in web/HTTP tools.
Blocks: private IPs, link-local, loopback, metadata endpoints.
"""
BLOCKED_PREFIXES = [
'127.', '10.', '192.168.', '172.16.', '172.17.', '172.18.',
'172.19.', '172.20.', '172.21.', '172.22.', '172.23.',
'172.24.', '172.25.', '172.26.', '172.27.', '172.28.',
'172.29.', '172.30.', '172.31.',
'169.254.', # link-local
'0.', # "this" network
'fc', 'fd', # IPv6 ULA
'fe80', # IPv6 link-local
'::1', # IPv6 loopback
]
BLOCKED_HOSTS = {
'localhost',
'metadata.google.internal',
'metadata.google',
'169.254.169.254',
}
@classmethod
def check_url(cls, url: str) -> str:
"""
Validate URL is not targeting internal/private resources.
Returns the URL if safe, raises on SSRF attempt.
"""
from urllib.parse import urlparse
parsed = urlparse(url)
host = (parsed.hostname or '').lower().strip('.')
if not host:
raise PathJailError("Empty host in URL")
if host in cls.BLOCKED_HOSTS:
raise PathJailError(f"SSRF blocked: {host}")
for prefix in cls.BLOCKED_PREFIXES:
if host.startswith(prefix):
raise PathJailError(f"SSRF blocked (private IP): {host}")
if parsed.scheme not in ('http', 'https'):
raise PathJailError(f"Blocked scheme: {parsed.scheme}")
return url
|