Harden FS source against symlink escape; surface pool/poller liveness in /health, additional auth tests

This commit is contained in:
Yiorgis Gozadinos 2026-05-25 17:36:31 +03:00
parent 4aee18dcbe
commit ef9cacf981
No known key found for this signature in database
8 changed files with 206 additions and 15 deletions

View file

@ -9,15 +9,27 @@ router = APIRouter()
@router.get("/health", response_model=HealthResponse)
async def health(state: APIState = Depends(get_state)) -> HealthResponse:
"""Liveness signal + queue/worker overview. Unauthenticated so load
balancers and uptime monitors can hit it without a token."""
balancers and uptime monitors can hit it without a token.
`status="degraded"` when at least one configured worker or poller task
has died (workers_alive < worker_count or pollers_alive < poller_count).
The DB query alone can't catch that — workers might be all dead and the
queue would still report counts cheerfully so this branch is what
actually distinguishes "alive" from "alive but doing nothing useful".
"""
counts = await state.job_repo.counts_by_status()
worker_count = (
state.config.ingester.workers.worker_count if state.pool is not None else 0
)
workers_alive = state.pool.live_workers if state.pool is not None else 0
poller_count = len(state.pollers.pollers) if state.pollers is not None else 0
pollers_alive = state.pollers.live_pollers if state.pollers is not None else 0
degraded = workers_alive < worker_count or pollers_alive < poller_count
return HealthResponse(
status="ok",
status="degraded" if degraded else "ok",
queue_counts=counts,
worker_count=worker_count,
poller_count=poller_count,
workers_alive=workers_alive,
pollers_alive=pollers_alive,
)

View file

@ -8,6 +8,11 @@ class HealthResponse(BaseModel):
queue_counts: dict[str, int]
worker_count: int
poller_count: int
# Live counters — non-zero shortfalls vs the configured count signal a
# crashed task. status="degraded" when either shortfall is non-zero so
# uptime monitors can alert without needing to do the math themselves.
workers_alive: int
pollers_alive: int
class SourceSummary(BaseModel):

View file

@ -87,3 +87,9 @@ class PollerManager:
@property
def pollers(self) -> list[BasePoller]:
return list(self._pollers)
@property
def live_pollers(self) -> int:
"""Poller tasks that are still running. Equal to len(pollers) under
normal operation; less when a poller has crashed."""
return sum(1 for t in self._tasks if not t.done())

View file

@ -1,10 +1,12 @@
import hashlib
import mimetypes
import os
from collections.abc import AsyncIterator
from datetime import UTC, datetime
from pathlib import Path
from urllib.parse import unquote, urlparse
from haiku.rag.client.exceptions import UnsupportedSourceError
from haiku.rag.ingester.sources.base import (
FetchResult,
RevisionSnapshot,
@ -51,27 +53,38 @@ class FSSource:
supported_extensions=self.supported_extensions,
)
def _resolve_within_root(self, uri: str) -> Path | None:
"""Resolve a URI to a real path guaranteed to live under ``self.root``.
Returns ``None`` if the URI parses but resolves outside the root
(path-traversal via ``..``, symlinks pointing elsewhere). Callers
treat this as "not ours" `supports()` returns False, `head()`
returns None, `fetch()` raises ``UnsupportedSourceError``.
"""
try:
path = _uri_to_path(uri).resolve(strict=False)
except (ValueError, OSError):
return None
if not path.is_relative_to(self.root):
return None
return path
def supports(self, uri: str) -> bool:
scheme = urlparse(uri).scheme
if scheme not in ("", "file"):
return False
try:
_uri_to_path(uri)
except ValueError:
return False
return True
return self._resolve_within_root(uri) is not None
async def head(self, uri: str) -> str | None:
path = _uri_to_path(uri).absolute()
if not path.exists():
path = self._resolve_within_root(uri)
if path is None or not path.exists():
return None
return str(path.stat().st_mtime_ns)
async def fetch(self, uri: str) -> FetchResult:
# Absolute path is needed for as_uri() and matches the old
# _create_document_from_file behavior (which keyed docs on the
# absolute file:// URI).
path = _uri_to_path(uri).absolute()
path = self._resolve_within_root(uri)
if path is None:
raise UnsupportedSourceError(f"Path escapes FS root ({self.root}): {uri}")
body = path.read_bytes()
content_type, _ = mimetypes.guess_type(path.name)
if content_type is None:
@ -95,7 +108,21 @@ class FSSource:
now = datetime.now(UTC)
seen: set[str] = set()
for path in sorted(self.root.rglob("*")):
# os.walk with followlinks=False so symlinked directories aren't
# traversed. Then per-file: skip individual file-symlinks too, since
# they could point outside root and reading them would leak data.
# Operators wanting to ingest content from outside root should
# bind-mount it in or configure a second source.
candidates: list[Path] = []
for dirpath, _dirnames, filenames in os.walk(self.root, followlinks=False):
for filename in filenames:
path = Path(dirpath) / filename
if path.is_symlink():
continue
candidates.append(path)
candidates.sort()
for path in candidates:
if not path.is_file():
continue
if not self.filter.include_file(str(path)):

View file

@ -50,6 +50,12 @@ class WorkerPool:
self._workers: list[asyncio.Task] = []
self._reaper: asyncio.Task | None = None
@property
def live_workers(self) -> int:
"""Worker tasks that are still running. Equal to worker_count under
normal operation; less when a worker has crashed."""
return sum(1 for t in self._workers if not t.done())
async def start(self) -> None:
if self._workers:
raise RuntimeError("WorkerPool already started")

View file

@ -63,8 +63,13 @@ async def _apply_canonical_metadata_keys(store: Store) -> None:
try:
meta = json.loads(raw)
except Exception:
# exc_info=True so the actual JSONDecodeError reaches the logs;
# otherwise a permanently malformed row stays malformed forever
# and the operator has nothing to grep for.
logger.warning(
"Could not parse metadata JSON for document %s; skipping", doc_id
"Could not parse metadata JSON for document %s; skipping",
doc_id,
exc_info=True,
)
skipped += 1
continue

View file

@ -71,6 +71,31 @@ async def test_health_ok_with_counts(state, jobs):
assert body["queue_counts"] == {"queued": 1, "dead": 1}
assert body["worker_count"] == 0 # pool not attached in the test state
assert body["poller_count"] == 0
assert body["workers_alive"] == 0
assert body["pollers_alive"] == 0
@pytest.mark.asyncio
async def test_health_degraded_when_worker_died(jobs, sync):
"""If a worker task crashed (live_workers < worker_count), /health must
flip to status='degraded' so uptime monitors notice."""
from unittest.mock import MagicMock
from haiku.rag.config import AppConfig
config = AppConfig()
config.ingester.workers.worker_count = 4
pool = MagicMock()
pool.live_workers = 3 # one dead
state = APIState(config=config, job_repo=jobs, sync_repo=sync, pool=pool)
async with _client(state) as client:
resp = await client.get("/health")
body = resp.json()
assert body["status"] == "degraded"
assert body["worker_count"] == 4
assert body["workers_alive"] == 3
@pytest.mark.asyncio
@ -111,6 +136,37 @@ async def test_no_auth_token_allows_everything(state, jobs):
assert (await client.get("/health")).status_code == 200
@pytest.mark.asyncio
async def test_mutation_endpoints_require_auth(state, jobs):
"""Existing tests prove auth gates GETs; this pins that the *mutation*
endpoints (retry, cancel, DLQ requeue, source refresh) also require the
bearer. A missing-auth regression on these would silently let anyone
cancel jobs or reset the DLQ."""
j = await jobs.enqueue("src", "u", JobOp.UPSERT)
assert j is not None
await jobs.mark_dead(j.id, "boom")
async with _client(state, auth_token="secret") as client:
# Cancel: blocked without token
resp = await client.delete(f"/jobs/{j.id}")
assert resp.status_code == 401
# Retry: blocked without token
resp = await client.post(f"/jobs/{j.id}/retry")
assert resp.status_code == 401
# DLQ retry: blocked without token
resp = await client.post(f"/dlq/{j.id}/retry")
assert resp.status_code == 401
# Source refresh: blocked without token
resp = await client.post("/sources/anything/refresh")
assert resp.status_code == 401
# With correct token: 200 for retry (job is dead, gets resurrected).
ok = await client.post(
f"/jobs/{j.id}/retry", headers={"Authorization": "Bearer secret"}
)
assert ok.status_code == 200
# --- /jobs ---

View file

@ -3,6 +3,7 @@ from pathlib import Path
import pytest
from haiku.rag.client.exceptions import UnsupportedSourceError
from haiku.rag.ingester.sources.base import SourceEventKind
from haiku.rag.ingester.sources.fs import FSSource
@ -152,3 +153,76 @@ async def test_fs_source_discover_respects_include_patterns(fs_root: Path):
uris = {e.uri async for e in src.discover(since=None)}
assert (fs_root / "b.txt").as_uri() not in uris
assert (fs_root / "a.md").as_uri() in uris
# --- symlink escape defenses ---
def test_fs_source_supports_rejects_paths_outside_root(fs_root: Path, tmp_path: Path):
"""A URI for a file outside the configured root must not match — even
if it's a valid file:// URI. Otherwise the resolve_fetcher chain could
end up handing /etc/passwd to FSSource.fetch()."""
outside = tmp_path.parent / "outside.md"
outside.write_text("not yours")
src = FSSource(root=fs_root)
assert src.supports(outside.as_uri()) is False
@pytest.mark.asyncio
async def test_fs_source_fetch_rejects_paths_outside_root(
fs_root: Path, tmp_path: Path
):
outside = tmp_path.parent / "outside.md"
outside.write_text("not yours")
src = FSSource(root=fs_root)
with pytest.raises(UnsupportedSourceError, match="escapes FS root"):
await src.fetch(outside.as_uri())
@pytest.mark.asyncio
async def test_fs_source_fetch_rejects_symlink_to_outside_file(
fs_root: Path, tmp_path: Path
):
"""Symlink under root that points outside — the classic FS escape.
resolve() chases the link to the real path, which fails the root check."""
secret = tmp_path.parent / "secret.md"
secret.write_text("sensitive")
link = fs_root / "looks_local.md"
link.symlink_to(secret)
src = FSSource(root=fs_root)
with pytest.raises(UnsupportedSourceError, match="escapes FS root"):
await src.fetch(link.as_uri())
@pytest.mark.asyncio
async def test_fs_source_discover_skips_symlinks(fs_root: Path, tmp_path: Path):
"""rglob (and os.walk by default) follows symlinks. We use
followlinks=False AND an explicit per-file is_symlink() filter so a
malicious symlink under root can't be discovered, can't be queued, and
can't be fetched even if its URI ends up enqueued some other way."""
secret = tmp_path.parent / "secret.md"
secret.write_text("sensitive")
link = fs_root / "evil.md"
link.symlink_to(secret)
src = FSSource(root=fs_root, supported_extensions=[".md"])
uris = {e.uri async for e in src.discover(since=None)}
assert link.as_uri() not in uris
# And the legitimate files in fs_root still come through.
assert (fs_root / "a.md").as_uri() in uris
@pytest.mark.asyncio
async def test_fs_source_discover_skips_symlinked_directories(
fs_root: Path, tmp_path: Path
):
"""os.walk(followlinks=False) must NOT descend into directory symlinks —
otherwise a `ln -s /etc /docs/escape` would walk into /etc and try to
yield its contents."""
outside_dir = tmp_path.parent / "outside_dir"
outside_dir.mkdir()
(outside_dir / "stolen.md").write_text("not yours")
(fs_root / "escape").symlink_to(outside_dir)
src = FSSource(root=fs_root, supported_extensions=[".md"])
uris = {e.uri async for e in src.discover(since=None)}
# No URI under /escape/* should appear.
assert not any("escape" in u for u in uris)