RAGForge / src /ragforge /security.py
ArchitSharma's picture
init
5ea3240
Raw
History Blame Contribute Delete
4.91 kB
from __future__ import annotations
import ipaddress
import re
import socket
import zipfile
from pathlib import Path
from urllib.parse import urlparse
from .config import get_settings
SUPPORTED_EXTENSIONS = {
".pdf", ".txt", ".md", ".rst", ".docx", ".pptx",
".csv", ".xlsx", ".xls", ".json", ".html", ".htm",
".xml", ".yaml", ".yml", ".py", ".js", ".ts", ".java",
".c", ".cpp", ".sql", ".log", ".png", ".jpg", ".jpeg", ".webp",
}
INJECTION_PATTERNS = [
r"ignore\s+(all\s+)?previous\s+instructions",
r"ignore\s+(the\s+)?system\s+prompt",
r"reveal\s+(the\s+)?system\s+prompt",
r"developer\s+message",
r"exfiltrat(e|ion)",
r"do\s+not\s+follow\s+the\s+user",
r"override\s+(all\s+)?instructions",
]
UNSAFE_SQL = re.compile(
r"\b(insert|update|delete|drop|alter|create|attach|detach|copy|export|import|pragma|install|load|call|vacuum)\b",
re.IGNORECASE,
)
def sanitize_filename(name: str) -> str:
name = Path(name).name
return re.sub(r"[^A-Za-z0-9._ -]", "_", name)[:180] or "upload"
def validate_upload(path: Path) -> None:
settings = get_settings()
if not path.exists() or not path.is_file():
raise ValueError(f"File not found: {path}")
if path.stat().st_size > settings.max_upload_mb * 1024 * 1024:
raise ValueError(f"{path.name} exceeds the {settings.max_upload_mb} MB upload limit")
if path.suffix.lower() not in SUPPORTED_EXTENSIONS and path.suffix.lower() != ".zip":
raise ValueError(f"Unsupported file type: {path.suffix or '(none)'}")
def safe_extract_zip(zip_path: Path, destination: Path) -> list[Path]:
settings = get_settings()
validate_upload(zip_path)
destination.mkdir(parents=True, exist_ok=True)
extracted: list[Path] = []
total_size = 0
with zipfile.ZipFile(zip_path) as zf:
members = [m for m in zf.infolist() if not m.is_dir()]
if len(members) > settings.max_archive_files:
raise ValueError(f"ZIP contains more than {settings.max_archive_files} files")
for member in members:
total_size += member.file_size
if total_size > settings.max_archive_uncompressed_mb * 1024 * 1024:
raise ValueError("ZIP expands beyond the configured uncompressed size limit")
member_path = Path(member.filename)
if member_path.is_absolute() or ".." in member_path.parts:
raise ValueError("Unsafe archive path detected")
if member_path.suffix.lower() not in SUPPORTED_EXTENSIONS:
continue
clean_name = sanitize_filename(member_path.name)
target = destination / clean_name
if target.exists():
stem, suffix = target.stem, target.suffix
n = 2
while target.exists():
target = destination / f"{stem}_{n}{suffix}"
n += 1
with zf.open(member) as src, target.open("wb") as dst:
dst.write(src.read())
extracted.append(target)
return extracted
def prompt_injection_score(text: str) -> float:
lowered = text.lower()[:12000]
hits = sum(bool(re.search(pattern, lowered, flags=re.I)) for pattern in INJECTION_PATTERNS)
return min(1.0, hits / 2.0)
def redact_basic_pii(text: str) -> str:
text = re.sub(r"\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b", "[REDACTED_EMAIL]", text)
text = re.sub(r"(?<!\d)(?:\+?\d[\d ()-]{8,}\d)(?!\d)", "[REDACTED_PHONE]", text)
return text
def validate_readonly_sql(sql: str) -> str:
candidate = sql.strip().strip("`").strip()
candidate = re.sub(r"^sql\s*", "", candidate, flags=re.I).strip()
if ";" in candidate.rstrip(";"):
raise ValueError("Only a single SQL statement is allowed")
if not re.match(r"^(select|with)\b", candidate, flags=re.I):
raise ValueError("Only SELECT/CTE queries are allowed")
if UNSAFE_SQL.search(candidate):
raise ValueError("Unsafe SQL keyword detected")
if " limit " not in f" {candidate.lower()} ":
candidate = candidate.rstrip(";") + " LIMIT 200"
return candidate
def is_safe_public_url(url: str) -> bool:
try:
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
return False
host = parsed.hostname.lower()
if host in {"localhost", "localhost.localdomain"} or host.endswith(".local"):
return False
try:
infos = socket.getaddrinfo(host, parsed.port or (443 if parsed.scheme == "https" else 80))
except socket.gaierror:
return False
for info in infos:
ip = ipaddress.ip_address(info[4][0])
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast:
return False
return True
except Exception:
return False