Harden zip handling, rate limiter, auth polling, and log streaming - #438
Open
lfiaschi wants to merge 2 commits into
Open
Harden zip handling, rate limiter, auth polling, and log streaming#438lfiaschi wants to merge 2 commits into
lfiaschi wants to merge 2 commits into
Conversation
Deep code review surfaced several small, high-leverage issues; this PR bundles the safe, testable ones. Security - shared/ziputil.py: reject symlink zip entries, backslash separators, and cap entry count + total uncompressed size. The old validator missed Windows-style separators on POSIX (normpath doesn't collapse them) and had no zip-bomb defense. - client publish: _create_zip now skips symlinks. Previously a stray link like `notes.md -> ~/.aws/credentials` would exfiltrate the target's bytes into a public zip on the registry. - registry_service.require_org_membership: return the same 404 for "org missing" and "caller is not a member", so a differing status code can no longer be used to enumerate the org namespace. The admin-only role check still returns 403 (no enumeration leak: caller has already proven membership). Correctness - api/registry_routes.get_eval_run_logs: skip lines that fail json.loads. A truncated write / aborted worker / partial S3 chunk used to raise JSONDecodeError inside the response path and 500 every subsequent poll. - client/cli/registry._render_skills_table: coerce updated_at=None to "" before slicing so a null timestamp no longer crashes the table. - client/cli/auth: clamp the server-supplied device-flow poll interval to [1, 30]s so a malformed or hostile 0/-1/huge value can't spin the CLI or hang login forever. Performance - api/rate_limit: replace the O(N-tracked-IPs) per-request `sum(len(v) for v in ...)` purge trigger with a monotonic per-limiter counter. Same purge cadence, zero hot-path scan. Tests - New: ziputil symlink/backslash/size-cap/entry-cap cases. - New: CLI _clamp_poll_interval unit tests (zero/negative/huge/garbage). - New: _create_zip skips symlinks; _render_skills_table survives updated_at=None. - New: RateLimiter purges stale IPs. - New: get_eval_run_logs tolerates malformed lines. - New: regression guard that the org-not-found and not-a-member responses are byte-for-byte identical. Full suite: 104 shared + 299 client + 936 server pass. The 5 test_docx_integration failures are pre-existing (hardcoded file counts drift against ~/.claude/skills/docx/) and reproduce on main.
Mypy raises `call-overload` (not `arg-type`) for `int(raw)` when `raw` is typed as `object`. The narrower ignore code lets CI pass and leaves any future type-related regression visible instead of masked.
3 tasks
This was referenced Aug 12, 2026
Closed
This was referenced Aug 12, 2026
Closed
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed
Small, high-leverage hardening across
shared/, the client CLI, and the server API — grouped into one PR because each change is a couple of lines and they all landed while doing a full-codebase review pass. 12 files, ~397 insertions / 38 deletions, +65 tests, no schema change.Security
shared/ziputil.py— reject symlink zip entries, reject\path separators (POSIXnormpathdoesn't collapse them → a Windows extractor could still escape), and cap entry count + total uncompressed size (zip-bomb defense).client/cli/registry._create_zip— skip symlinks on publish. Previouslyfor file in path.rglob("*"): if not file.is_file()followed a link likenotes.md -> ~/.aws/credentialsand packaged the target's bytes into a public zip on the registry.server/api/registry_service.require_org_membership— return the same 404 for "org does not exist" and "caller is not a member", so a differing status code no longer lets any authenticated user enumerate the org namespace. The admin-only role check keeps its 403 (no enumeration leak — caller has already proven membership).Correctness
server/api/registry_routes.get_eval_run_logs— skip lines that failjson.loadsinstead of lettingJSONDecodeErrorbubble. A truncated write, a killed worker, or a partial S3 chunk used to 500 every subsequent poll for that run's logs.client/cli/registry._render_skills_table— coerceupdated_at=Noneto""before slicing. A null timestamp used to crash the list table withNoneType is not subscriptable.client/cli/auth._clamp_poll_interval— clamp the server-supplied device-flow interval to[1, 30]s. A malformed / hostile0,-1, or huge value could spin the CLI in a tight loop or make login hang effectively forever.Performance
server/api/rate_limit.RateLimiter— replace thesum(len(v) for v in self._requests.values()) % 100 == 0purge trigger, which was O(N tracked IPs) on every request while holding the lock, with a monotonic per-limiter counter. Same purge cadence, zero hot-path scan.Why
Full deep-dive review of the codebase (see appendix at the bottom) surfaced ~40 findings across four axes: architecture, correctness, security, and testing. This PR picks the ones that are (a) real, concrete bugs with a failing scenario, (b) small and safe to implement in one pass, and (c) covered by a new regression test. Larger refactors (split
database.py/registry.py/gauntlet.pygod-modules, move rate-limit backend to Redis for cross-container correctness, real-Postgres integration tests, Sentry +/metrics) are called out in the appendix but deferred to follow-up PRs.How to test
The 5
test_docx_integration.pyfailures observed locally reproduce onmainbefore this branch — they compare hardcoded file names / counts against~/.claude/skills/docx/which drifts. Not caused by this PR.Manual smoke tests that map to each change:
python -c "import zipfile, io, stat; from dhub_core.ziputil import validate_zip_entries; ..."— inject a symlink entry, assertValueError.dhub publisha directory containing a symlink → check the resulting zip does not include the link's target.dhub loginagainst a server returninginterval: 0→ CLI polls at 1s cadence instead of tight-looping.curl -H 'Authorization: Bearer <valid-jwt>' https://.../v1/skills/no-such-org/foo/1.0.0(DELETE) → 404, byte-for-byte identical to a genuinely missing org.Checklist
make test) — 1,339 tests pass; only pre-existing docx integration brittleness fails.Appendix — Deep code review findings
Reviewed by four parallel principal-engineer sweeps (server domain + infra, API routes, client + shared, tests + CI + observability). This appendix is the synthesis; only the bolded items are addressed in this PR — the rest are the follow-up backlog.
System map
uvworkspace monorepo:client/(dhub-cli, PyPI),server/(decision-hub-server, Modal),shared/(dhub-core, source-of-truth models),frontend/(React 19 + Vite, bundled into the server image). Backend: FastAPI + SQLAlchemy Core (no ORM) + Postgres + S3 + Gemini + Anthropic + Modal. ~54k LOC Python, ~6.7k LOC TS. Router-levelDepends(get_current_user)on every write router is a clean defense-in-depth pattern. Boundaries betweendomain/andinfra/are respected in shape but leak in practice via inline imports.Top-15 highest-leverage findings (severity-ordered)
_create_zipfollows symlinks → publishes host secrets to public zipextractallwrites through pre-existing symlinks in the target dir (noO_NOFOLLOWin stdlib)os.replacerequire_org_membershipleaks org existence via 404 vs 403_extract_username_from_jwtreads unverified JWT claim into request log context → log spoofing~/.dhub/config.*.jsonwritten with process umask (world-readable JWT on shared boxes)get_eval_run_logscrashes on any malformed chunk line → 500s all subsequent polls_dispatch_changed_trackersre-runs already-published trackers whenfn.mapiteration raises mid-streamEngineper-invocation, never dispose → FD leak under cron loadeval_runrow inserted+committed before Modalspawn; failed spawn leaves ghostpendingrunsintervalaccepted unchecked → 0/negative → tight-loop, huge → hang_render_skills_tablecrashes onupdated_at=Nonesum(len(v)) % 100 == 0scan on every request under lockrequest.client.host(proxy IP behind Modal edge, not client) + per-container storage → effective limit is N× at scaleget_eval_run_logsre-reads every S3 chunk on every poll (quadratic) — cursor is event-seq but not translated to chunk offsetTesting / CI / observability posture
Repo has ~1,370 tests (886 server, 345 client, 57 shared, 82 frontend). Strengths: excellent gauntlet coverage, thorough manifest / semver / zip-slip cases, JSON-output tests across ~10 CLI commands, per-endpoint route tests. Gaps: the API
TestClientusesMagicMock()for the DB engine in every test — no SQL is ever exercised in CI, so_SKILL_SUMMARY_COLUMNSdrift or missed RLS grant fails silently until prod.motois a dev dep but never imported. No E2E test. No/metrics, no Sentry, no tracing — an outage today is grep-through-Modal-logs, not dashboards. CI has nouvcache (every job cold-installs the workspace, ~30-60s each × 6 jobs).Suggested follow-up PRs (not in this one)
test-server— copy theservices: postgresblock already used bymigrate-checkto unlock T1/T2 from the testing review (guard_SKILL_SUMMARY_COLUMNS, publish-e2e, RLS boundary).database.py(3,465 LOC),registry_routes.py(1,352 LOC),gauntlet.py(1,355 LOC),client/cli/registry.py(1,912 LOC) into aggregate-scoped modules. Re-export from__init__.pyso callers are unaffected./metrics+ Sentry — one env var each, ~50 lines, turns silent 500s into paged alerts.dhub/http.py) with retry, timeout, and friendly error mapping — replaces ~35 ad-hochttpx.Client(timeout=60)blocks scattered across CLI modules and eliminates the raw-traceback UX for common failure modes.uvcache in CI (cache-dependency-glob: "**/uv.lock") — one-line change per job, ~5 min faster PRs._CREDENTIAL_PATTERNSingauntlet.pycovers AWS/GitHub/Slack/Stripe/JWTs;logging.pyonly redacts?key=;evals.pycovers only Anthropic/OpenAI/Google. Move to a singledecision_hub.redactmodule.Generated by Claude Code