Harden scraper inputs and exports
ci / lint-prettier (push) Successful in 7s
ci / lint-ruff (push) Successful in 5s
ci / lint-yaml (push) Successful in 6s
ci / lint-dockerfiles (push) Successful in 4s
ci / validate (push) Successful in 4s
ci / publish (push) Successful in 8s

This commit is contained in:
2026-06-29 17:27:43 +02:00
parent 27296caf69
commit 145e7de7f4
5 changed files with 150 additions and 41 deletions
+110 -38
View File
@@ -99,6 +99,27 @@ def normalize_media_url(media_path: Optional[str]) -> Optional[str]:
return "/media/" + urllib.parse.quote(str(relative).replace("\\", "/"))
def normalize_channel_id(value: Any) -> str:
channel_id = str(value or "").strip().lstrip("@")
if (
not channel_id
or "/" in channel_id
or "\\" in channel_id
or channel_id in {".", ".."}
or any(ord(ch) < 32 for ch in channel_id)
):
raise ValueError("channel_id contains unsupported path characters")
return channel_id
def export_account_state(state: Dict[str, Any], include_secrets: bool = False) -> Dict[str, Any]:
exported = dict(state)
if not include_secrets and "api_hash" in exported:
exported["api_hash_present"] = bool(exported.get("api_hash"))
exported["api_hash"] = None
return exported
def channel_db_path(account_id: Optional[str], channel_id: str) -> Path:
if account_id:
return account_data_dir(DATA_DIR, account_id) / channel_id / f"{channel_id}.db"
@@ -1832,7 +1853,10 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
return self.send_json(list_channels_snapshot())
if path.startswith("/api/channels/") and path.endswith("/messages"):
parts = path.split("/")
channel_id = urllib.parse.unquote(parts[3]).lstrip("@")
try:
channel_id = normalize_channel_id(urllib.parse.unquote(parts[3]))
except ValueError as exc:
return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
limit = max(1, min(int(query.get("limit", ["120"])[0]), 300))
before = query.get("before")
before_message_id = int(before[0]) if before else None
@@ -1912,16 +1936,20 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
if sub == ["health"]:
return self.send_json(account_health_summary(account_id, self.app.job_runner))
if sub == ["export"]:
include_secrets = query.get("include_secrets", ["0"])[0].lower() in {"1", "true", "yes"}
payload = {
"version": 1,
"account_id": account_id,
"state": load_account(DATA_DIR, account_id),
"state": export_account_state(load_account(DATA_DIR, account_id), include_secrets=include_secrets),
}
return self.send_json(payload)
if sub == ["channels"]:
return self._handle_get_account_channels(account_id)
if len(sub) >= 3 and sub[0] == "channels" and sub[-1] == "messages":
channel_id = urllib.parse.unquote(sub[1]).lstrip("@")
try:
channel_id = normalize_channel_id(urllib.parse.unquote(sub[1]))
except ValueError as exc:
return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
return self._handle_get_account_channel_messages(account_id, channel_id, query)
if sub == ["continuous"]:
return self.send_json(
@@ -2117,9 +2145,10 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
return self.send_error_json(HTTPStatus.BAD_REQUEST, "Legacy auth is not available in multi-account mode. Use /api/accounts/{id}/auth/password instead.")
if path == "/api/channels/add":
channel_id = str(body.get("channel_id", "")).strip()
if not channel_id:
return self.send_error_json(HTTPStatus.BAD_REQUEST, "channel_id is required")
try:
channel_id = normalize_channel_id(body.get("channel_id"))
except ValueError as exc:
return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
if self.app.legacy_account_id:
return self._handle_account_channel_add(self.app.legacy_account_id, channel_id, body.get("name"))
state = load_state()
@@ -2131,7 +2160,10 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
return self.send_json({"ok": True, "channel_id": channel_id})
if path == "/api/channels/remove":
channel_id = str(body.get("channel_id", "")).strip()
try:
channel_id = normalize_channel_id(body.get("channel_id"))
except ValueError as exc:
return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
if self.app.legacy_account_id:
return self._handle_account_channel_remove(self.app.legacy_account_id, channel_id)
state = load_state()
@@ -2147,10 +2179,13 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
if account_id:
payload["account_id"] = account_id
if channel_id:
payload["channel_id"] = str(channel_id)
try:
payload["channel_id"] = normalize_channel_id(channel_id)
except ValueError as exc:
return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
job = self.app.job_runner.create_job(
"scrape_channel",
f"Scrape channel {channel_id}" + (f" [{account_id}]" if account_id else ""),
f"Scrape channel {payload['channel_id']}" + (f" [{account_id}]" if account_id else ""),
payload,
)
else:
@@ -2173,9 +2208,10 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
return self.send_json(job.to_dict(), status=HTTPStatus.ACCEPTED)
if path == "/api/jobs/export-channel":
channel_id = str(body.get("channel_id", "")).strip()
if not channel_id:
return self.send_error_json(HTTPStatus.BAD_REQUEST, "channel_id is required")
try:
channel_id = normalize_channel_id(body.get("channel_id"))
except ValueError as exc:
return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
payload = {"channel_id": channel_id}
if self.app.legacy_account_id:
payload["account_id"] = self.app.legacy_account_id
@@ -2187,9 +2223,10 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
return self.send_json(job.to_dict(), status=HTTPStatus.ACCEPTED)
if path == "/api/jobs/rescrape-media":
channel_id = str(body.get("channel_id", "")).strip()
if not channel_id:
return self.send_error_json(HTTPStatus.BAD_REQUEST, "channel_id is required")
try:
channel_id = normalize_channel_id(body.get("channel_id"))
except ValueError as exc:
return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
payload = {"channel_id": channel_id}
if self.app.legacy_account_id:
payload["account_id"] = self.app.legacy_account_id
@@ -2201,9 +2238,10 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
return self.send_json(job.to_dict(), status=HTTPStatus.ACCEPTED)
if path == "/api/jobs/fix-media":
channel_id = str(body.get("channel_id", "")).strip()
if not channel_id:
return self.send_error_json(HTTPStatus.BAD_REQUEST, "channel_id is required")
try:
channel_id = normalize_channel_id(body.get("channel_id"))
except ValueError as exc:
return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
payload = {"channel_id": channel_id}
if self.app.legacy_account_id:
payload["account_id"] = self.app.legacy_account_id
@@ -2293,8 +2331,8 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
"label": str(body.get("label") or state.get("label") or account_id).strip(),
"api_id": state.get("api_id"),
"api_hash": state.get("api_hash"),
"channels": state.get("channels") if isinstance(state.get("channels"), dict) else {},
"channel_names": state.get("channel_names") if isinstance(state.get("channel_names"), dict) else {},
"channels": self._clean_imported_channels(state.get("channels")),
"channel_names": self._clean_imported_channel_names(state.get("channel_names")),
"scrape_media": bool(state.get("scrape_media", True)),
"forwarding_rules": state.get("forwarding_rules") if isinstance(state.get("forwarding_rules"), list) else [],
"continuous_scraping": state.get("continuous_scraping") if isinstance(state.get("continuous_scraping"), dict) else {
@@ -2337,9 +2375,9 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
if sub == ["auth", "password"]:
return self._handle_post_account_auth_password(account_id, body)
if sub == ["channels", "add"]:
return self._handle_account_channel_add(account_id, str(body.get("channel_id", "")).strip(), body.get("name"))
return self._handle_account_channel_add(account_id, body.get("channel_id"), body.get("name"))
if sub == ["channels", "remove"]:
return self._handle_account_channel_remove(account_id, str(body.get("channel_id", "")).strip())
return self._handle_account_channel_remove(account_id, body.get("channel_id"))
if sub == ["jobs", "scrape"]:
return self._handle_account_job_scrape(account_id, body)
if sub == ["jobs", "export"]:
@@ -2412,10 +2450,35 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
return self.send_json(payload)
def _handle_account_channel_add(self, account_id: str, channel_id: str, name: Optional[str]) -> None:
channel_id = channel_id.lstrip("@")
if not channel_id:
return self.send_error_json(HTTPStatus.BAD_REQUEST, "channel_id is required")
def _clean_imported_channels(self, channels: Any) -> Dict[str, Any]:
if not isinstance(channels, dict):
return {}
cleaned: Dict[str, Any] = {}
for raw_channel_id, last_message_id in channels.items():
try:
channel_id = normalize_channel_id(raw_channel_id)
except ValueError:
continue
cleaned[channel_id] = last_message_id
return cleaned
def _clean_imported_channel_names(self, channel_names: Any) -> Dict[str, str]:
if not isinstance(channel_names, dict):
return {}
cleaned: Dict[str, str] = {}
for raw_channel_id, name in channel_names.items():
try:
channel_id = normalize_channel_id(raw_channel_id)
except ValueError:
continue
cleaned[channel_id] = str(name).strip()
return cleaned
def _handle_account_channel_add(self, account_id: str, channel_id: Any, name: Optional[str]) -> None:
try:
channel_id = normalize_channel_id(channel_id)
except ValueError as exc:
return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
store = get_account_store(DATA_DIR, account_id)
def mutate(state: Dict[str, Any]) -> None:
if channel_id not in state.setdefault("channels", {}):
@@ -2426,9 +2489,11 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
self.app.continuous_orchestrator.refresh_account(account_id)
return self.send_json({"ok": True, "channel_id": channel_id})
def _handle_account_channel_remove(self, account_id: str, channel_id: str) -> None:
if not channel_id:
return self.send_error_json(HTTPStatus.BAD_REQUEST, "channel_id is required")
def _handle_account_channel_remove(self, account_id: str, channel_id: Any) -> None:
try:
channel_id = normalize_channel_id(channel_id)
except ValueError as exc:
return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
store = get_account_store(DATA_DIR, account_id)
existed = [False]
def mutate(state: Dict[str, Any]) -> None:
@@ -2444,6 +2509,10 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
def _handle_account_job_scrape(self, account_id: str, body: Dict[str, Any]) -> None:
channel_id = body.get("channel_id")
if channel_id:
try:
channel_id = normalize_channel_id(channel_id)
except ValueError as exc:
return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
job = self.app.job_runner.create_job(
"scrape_channel",
f"Scrape channel {channel_id} [{account_id}]",
@@ -2466,9 +2535,10 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
return self.send_json(job.to_dict(), status=HTTPStatus.ACCEPTED)
def _handle_account_job_export_channel(self, account_id: str, body: Dict[str, Any]) -> None:
channel_id = str(body.get("channel_id", "")).strip()
if not channel_id:
return self.send_error_json(HTTPStatus.BAD_REQUEST, "channel_id is required")
try:
channel_id = normalize_channel_id(body.get("channel_id"))
except ValueError as exc:
return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
job = self.app.job_runner.create_job(
"export_channel",
f"Export channel {channel_id} [{account_id}]",
@@ -2477,9 +2547,10 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
return self.send_json(job.to_dict(), status=HTTPStatus.ACCEPTED)
def _handle_account_job_rescrape_media(self, account_id: str, body: Dict[str, Any]) -> None:
channel_id = str(body.get("channel_id", "")).strip()
if not channel_id:
return self.send_error_json(HTTPStatus.BAD_REQUEST, "channel_id is required")
try:
channel_id = normalize_channel_id(body.get("channel_id"))
except ValueError as exc:
return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
job = self.app.job_runner.create_job(
"rescrape_media",
f"Rescrape media for {channel_id} [{account_id}]",
@@ -2488,9 +2559,10 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
return self.send_json(job.to_dict(), status=HTTPStatus.ACCEPTED)
def _handle_account_job_fix_media(self, account_id: str, body: Dict[str, Any]) -> None:
channel_id = str(body.get("channel_id", "")).strip()
if not channel_id:
return self.send_error_json(HTTPStatus.BAD_REQUEST, "channel_id is required")
try:
channel_id = normalize_channel_id(body.get("channel_id"))
except ValueError as exc:
return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
job = self.app.job_runner.create_job(
"fix_missing_media",
f"Fix missing media for {channel_id} [{account_id}]",