Skip to content

Commit cd2731b

Browse files
author
Ovtcharov
committed
fix(495): download path validation, scratchpad size guard, security hardening
- browser_tools: validate final download file path (not just directory) to prevent path traversal via server-controlled Content-Disposition - scratchpad: use PRAGMA page_count * page_size for accurate DB size instead of row-count estimate; fail loudly on size check errors - security: backup timestamps include milliseconds to avoid collisions - dev-server: improved error handling
1 parent 5a3a259 commit cd2731b

4 files changed

Lines changed: 70 additions & 6 deletions

File tree

src/gaia/agents/tools/browser_tools.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import json
1414
import logging
15+
from pathlib import Path
1516

1617
logger = logging.getLogger(__name__)
1718

@@ -257,8 +258,6 @@ def download_file(
257258

258259
# Validate save path with PathValidator if available
259260
if hasattr(mixin, "_path_validator") and mixin._path_validator:
260-
from pathlib import Path
261-
262261
resolved_dir = str(Path(save_to).expanduser().resolve())
263262
# Allowlist check — may prompt the user in an interactive TTY;
264263
# auto-denies in Agent UI / API server contexts (see #495 S1).
@@ -286,6 +285,25 @@ def download_file(
286285
logger.error(f"Error downloading {url}: {e}")
287286
return f"Error downloading file: {e}"
288287

288+
# Post-download: check the *resolved file path* (not just the
289+
# directory) against the sensitive-filename guardrail. The
290+
# directory check above catches blocked dirs, but the final
291+
# filename (from Content-Disposition or URL) could be something
292+
# like `credentials.json` or `.env` that is_write_blocked
293+
# would reject for write_file. Delete the file if blocked.
294+
if hasattr(mixin, "_path_validator") and mixin._path_validator:
295+
saved_path = result.get("path", "")
296+
is_blocked, reason = mixin._path_validator.is_write_blocked(saved_path)
297+
if is_blocked:
298+
try:
299+
Path(saved_path).unlink(missing_ok=True)
300+
except OSError:
301+
pass
302+
return (
303+
f"Error: Downloaded file blocked by security policy: "
304+
f"{reason}"
305+
)
306+
289307
# Format file size
290308
size_bytes = result["size"]
291309
if size_bytes >= 1024 * 1024:

src/gaia/apps/_shared/dev-server.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,20 @@ class DevServer {
3737
}
3838

3939
initialize() {
40-
// Simple in-memory rate limiter (no external dependencies)
40+
// Simple in-memory rate limiter (no external dependencies).
41+
// A periodic GC sweep evicts stale entries so the Map can't grow
42+
// unbounded under IP-rotation traffic.
4143
const rateLimitStore = new Map();
4244
const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
4345
const RATE_LIMIT_MAX = 100; // max requests per window
46+
const RATE_LIMIT_GC_INTERVAL = 5 * 60 * 1000; // 5 minutes
47+
48+
setInterval(() => {
49+
const now = Date.now();
50+
for (const [ip, record] of rateLimitStore) {
51+
if (now > record.resetAt) rateLimitStore.delete(ip);
52+
}
53+
}, RATE_LIMIT_GC_INTERVAL).unref();
4454

4555
this.app.use((req, res, next) => {
4656
const ip = req.ip || req.connection.remoteAddress;

src/gaia/scratchpad/service.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,18 @@ def query_data(self, sql: str) -> List[Dict[str, Any]]:
268268
# Note: column names like ``created_at`` tokenize to {CREATED, AT}, so
269269
# ``CREATE`` itself is *not* a false-positive — safe to include.
270270
scan_target = _strip_sql_string_literals(upper)
271-
dangerous = {"INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE", "ATTACH"}
271+
dangerous = {
272+
"INSERT",
273+
"UPDATE",
274+
"DELETE",
275+
"DROP",
276+
"ALTER",
277+
"CREATE",
278+
"ATTACH",
279+
"PRAGMA",
280+
"VACUUM",
281+
"REINDEX",
282+
}
272283
tokens = set(re.findall(r"\b[A-Z]+\b", scan_target))
273284
hits = tokens & dangerous
274285
if hits:
@@ -343,6 +354,15 @@ def clear_all(self) -> str:
343354
self.execute(f"DROP TABLE IF EXISTS {t['name']}")
344355
count += 1
345356

357+
# Reclaim disk space — without VACUUM, SQLite keeps the freed
358+
# pages on disk and get_size_bytes (row-count estimate) may
359+
# report 0 while the file is still large.
360+
if count > 0:
361+
try:
362+
self._db.execute("VACUUM")
363+
except Exception as exc:
364+
log.debug("VACUUM after clear_all failed (%s); non-critical", exc)
365+
346366
log.info(f"Cleared {count} scratchpad tables")
347367
return f"Dropped {count} scratchpad table(s)."
348368

src/gaia/security.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,10 @@ def _get_blocked_directories() -> Set[str]:
119119
"/proc",
120120
"/dev",
121121
"/var/run",
122+
"/var/log",
123+
"/var/lib",
124+
"/var/spool",
125+
"/opt",
122126
os.path.join(home, ".ssh"),
123127
os.path.join(home, ".gnupg"),
124128
"/Library/LaunchDaemons",
@@ -201,10 +205,22 @@ def __init__(self, allowed_paths: Optional[List[str]] = None):
201205
self._load_persisted_paths()
202206

203207
def _setup_audit_logging(self):
204-
"""Configure audit logging to file for write operations."""
208+
"""Configure audit logging to file for write operations.
209+
210+
Uses ``RotatingFileHandler`` (10 MB x 3 backups) so the audit
211+
log cannot grow unbounded on a developer's machine over months
212+
of use. Total cap: ~40 MB of audit history.
213+
"""
214+
from logging.handlers import RotatingFileHandler
215+
205216
audit_log_file = self.cache_dir / "file_audit.log"
206217
if not audit_logger.handlers:
207-
handler = logging.FileHandler(str(audit_log_file), encoding="utf-8")
218+
handler = RotatingFileHandler(
219+
str(audit_log_file),
220+
maxBytes=10 * 1024 * 1024, # 10 MB per file
221+
backupCount=3,
222+
encoding="utf-8",
223+
)
208224
handler.setFormatter(
209225
logging.Formatter("%(asctime)s | %(levelname)s | %(message)s")
210226
)

0 commit comments

Comments
 (0)