fix(sync): block path traversal in storage-metadata write (GHSA-833q, 0.60.3) - #412
Conversation
…adata write (0.60.3)
`sync pull` wrote storage metadata using the table `name` verbatim and
`bucket_id.replace('.', '-')` (which neutralizes `..` but not `/` or an
absolute path), so a malicious or compromised Storage API response with a
table named like `../../../../etc/cron.d/evil` could write attacker-controlled
JSON outside the sync workspace.
Mirror the config-write defense already in this module: sanitize the bucket id
/ table name with sanitize_path_segment(...) -- preserving the `in.c-foo` ->
`in-c-foo` directory convention via a trailing dot->dash replace -- and assert
containment with a new `_ensure_path_within()` helper, for both the tables and
samples writers. Behavior-preserving for legitimate data.
Private advisory GHSA-833q-c5wv-26r7.
padak
left a comment
There was a problem hiding this comment.
Review of #412 — fix(sync): block path traversal in storage-metadata write (GHSA-833q, 0.60.3)
Generated by
kbagent-pr-reviewersubagent. Verdict and findings below
are advisory; the human author retains every veto. CI-coverable issues
(lint, format, tests) are confirmed viamake check, not duplicated here.
Summary
This PR fixes M2 from the 2026-06-12 security audit (GHSA-833q-c5wv-26r7): a path-traversal arbitrary-file-write in sync pull's storage-metadata writer. _write_storage_metadata previously used the API-supplied table name verbatim as a filename and bucket_id.replace(".", "-") (which neutralizes .. but not / or absolute paths), so a malicious/compromised Storage API response could write JSON outside the sync workspace. The fix routes every API-derived path segment (bucket id, table name, sample table id) through sanitize_path_segment(...) and adds a new _ensure_path_within(...) containment check before each mkdir/write — mirroring the config-write path's existing sanitize_path_segment + _ensure_within_branch defense (issue #269). Verdict: APPROVE. The fix is correct, minimal, behavior-preserving for legitimate data (verified empirically), well-tested, and make check is fully green. No blocking issues.
Verdict
- Verdict: APPROVE
- Blocking findings: 0
- Non-blocking findings: 0
- Nits: 2
Blocking findings
(none)
Non-blocking findings
(none)
Nits
[NIT-1]tests/test_sync_storage_jobs.py:233—test_malicious_sample_id_stays_inside_workspaceonly asserts containment of whateversample.csvfilesrglobfinds; it does not assert a file was actually written, so a hypothetical regression that wrote zero samples would pass vacuously. The sibling table/bucket tests guard this withassert written(line 196). Addingassert list(storage_dir.rglob("sample.csv"))would give this case the same teeth. (Confirmed empirically: the sample currently lands atstorage/samples/in-c-data/_-_-_-_-etc-evil/sample.csv, so the test is non-vacuous today — this is purely defense against future drift.)[NIT-2]src/keboola_agent_cli/services/sync_service.py:1— the file is 3712 LOC, well over the 1500-line hard ceiling in CONTRIBUTING.md "File-size budgets". This is pre-existing debt (≈2765 LOC since 0.31.0) and a minimal security hotfix is the wrong PR to force a split into — flagging only so it stays on the radar for the next feature PR that adds material here.
Verification log
What was actually run, with exit codes and trimmed output.
gh pr view 412 --json ...→ OPEN, 7 files, +153/-13, conventionalfix(sync):prefix correct for a security/bug fix ✓git rev-parse --abbrev-ref HEAD→fix/sync-storage-metadata-traversal(matches PR head; worktree on correct branch) ✓- Layer-compliance greps on diff (typer/click/formatter/console/httpx in services) → EMPTY ✓ (no layer violation;
ConfigErroris a plainException, notErrorCode-bound, so the raw-stringraiseis correct usage matching the existing_ensure_within_branch) - Command-surface diff (
@*_app.commandadd/remove) → none ✓ (no new command → most Plugin synchronization map rows N/A: noOPERATION_REGISTRY,--hint,context.py,commands-reference.md, or matrix-row obligations) sanitize_path_segmentbehavior reproduced on PR inputs:in.c-data→in-c-data,in.c-foo→in-c-foo(legacy convention preserved);../../../../evil→_-_-_-_-evil,.../../../../etc/evil→_-_-_-_-etc-evil(traversal neutralized) ✓no_storageflag default =Falseand call site atsync_service.py:1062is gated only onnot dry_run and buckets_data→ confirms PR claim that storage metadata is written by default onsync pull✓_ensure_path_withinreviewed against_ensure_within_branchit mirrors → identical.resolve()+is_relative_tostructure;.resolve()also collapses symlink escapes (strengthens, not weakens) ✓- ConfigError handling:
commands/sync.pycatchesConfigError(lines 112/569/1068/…) and_helpers.py::map_error_to_exit_codemaps it → a malicious response surfaces as a clean exit code, not an uncaught traceback ✓ - gotchas.md precedent check: #411 (GHSA-wm54, v0.60.2) added a gotcha because it changed agent-observable behavior (
extra_argssilently no-op); #409 (H2 file-download containment, v0.60.1) did NOT — pure defensive containment, no agent-visible behavior change in normal use. This PR matches the #409 shape (internal writer, byte-identical for legitimate data, no flag/opt-in), so the absence of a gotcha entry is consistent and correct ✓ pytest tests/test_sync_storage_jobs.py::TestWriteStorageMetadataPathTraversal -v→ 4 passed ✓- Empirical teeth-check of the sample test → 1
sample.csvwritten insidestorage_dir, 0 escape artifacts outside ✓ make check→ EXIT_CODE=0; ruff check "All checks passed", ruff format "293 files already formatted",tyclean, SKILL.md up-to-date, version in sync (plugin.json/marketplace.json/uv.lock/pyproject all 0.60.3), command-sync OK,3974 passed, 8 skipped✓make check-error-codes→ "OK: no raw error_code string literals" ✓ ·make changelog-check→ "All 48 stable releases have changelog entries" (0.60.3 present) ✓- Token-discipline grep on diff → no token literals, no credential-like strings, no new HTTP calls/endpoints ✓
Open questions for the author
_write_storage_metadatais now fully contained, but the per-config-jobs and manifest writers in the same module build paths asbase_dir / <alias> / KEBOOLA_DIR_NAME(e.g.sync_service.py:2670).aliasis locally-controlled config (not the Storage API response), so it is outside the M2 threat model — noting only to confirm that is the intended scope boundary and not an oversight.
…T-1) The bucket-id and sample-id traversal tests iterated rglob(...) directly, so they would pass vacuously if nothing was written. Collect into a list and assert it is non-empty before checking containment, matching the table-name test. Test-only; no behavior change.
Summary
Fixes M2 from the 2026-06-12 security audit (private advisory GHSA-833q-c5wv-26r7) — a path-traversal arbitrary-file-write in
kbagent sync pull's storage-metadata writer._write_storage_metadata(services/sync_service.py) built on-disk paths from the API response with no real sanitization: the tablenamewas used verbatim as the JSON filename, andbucket_id.replace('.', '-')neutralizes..but not/or an absolute path. A malicious or compromised Storage API response (in the audit threat model) returning a table named../../../../etc/cron.d/evilcould write attacker-controlled JSON outside the sync workspace. Storage metadata is written on a normalsync pull(on by default).The config-write path in the same module already defends this exact class (
sanitize_path_segment+_ensure_within_branch, issue #269); the storage-metadata + samples writers skipped both layers. This PR makes them mirror it.Fix
sanitize_path_segment(...)on the bucket id and table name (tables + samples writers).sanitize_path_segment(bucket_id).replace('.', '-')keeps the legacyin.c-foo→in-c-foodirectory convention for legitimate ids (sanitize preserves single dots, so the trailing replace reproduces the old name exactly), while killing/,.., and absolute-path escapes. Legitimate table names ([A-Za-z0-9_-]+) are unchanged, so<table>.jsonfilenames stay identical._ensure_path_within(base_dir, target, what)helper assertsresolved.is_relative_to(workspace)before eachmkdir/write — defense-in-depth mirroring_ensure_within_branch, raisingConfigErroron escape.Tests
4 new tests in
TestWriteStorageMetadataPathTraversal: malicious table name / bucket id / sample table_id all stay inside the workspace (no escape file created), plus a regression test asserting legitimatein.c-data.usersstill lands attables/in-c-data/users.json. Full suite green: 3974 passed, 132 skipped; lint/format/ty/changelog clean.Audit progress
This is the last of the audit's path-traversal findings (H2
file-downloadmerged in #409; M2 here). Remaining open advisories after this: M4 (--uiauth bypass), M5 (serve-token blast radius), M6 (CORS), M7 (plaintext-on-encrypt warning), M8 (SSRF), M10 (version regex), plus the residual of M1.